craft-native 0.0.487 → 0.0.490
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/src/index.d.ts +28 -0
- package/dist/android/src/index.js +15 -0
- package/dist/android/templates/CraftNativeServices.kt.template +10 -1
- package/dist/android/templates/CraftNativeShell.kt.template +96 -8
- package/dist/android/templates/MainActivityNative.kt.template +143 -28
- 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 +42 -1
- package/dist/ios/src/index.js +15 -0
- package/dist/ios/templates/CraftApp.swift +30 -0
- package/dist/ios/templates/CraftHybrid.swift +49 -0
- package/dist/ios/templates/CraftNativeActions.swift +9 -1
- package/dist/ios/templates/CraftNativeScreen.swift +213 -28
- package/dist/mobile.js +2 -2
- package/package.json +1 -1
|
@@ -183,6 +183,20 @@ export interface CraftAndroidConfig {
|
|
|
183
183
|
* screen, and a change of it is `SESSION_CHANGED { key, token, previous }`.
|
|
184
184
|
*/
|
|
185
185
|
sessionTokenKey?: string;
|
|
186
|
+
/**
|
|
187
|
+
* Which screens a signed-out launch may start on.
|
|
188
|
+
*
|
|
189
|
+
* The shell resolves this while choosing the start path, before any screen
|
|
190
|
+
* is built. The `nativeApp` script cannot do the same job: `openScreen`
|
|
191
|
+
* returns as soon as it has an isolate and evaluates the script later, so
|
|
192
|
+
* the gate and the first screen race — and on a launch that follows an
|
|
193
|
+
* expired token rather than a sign-out, losing paints the previous
|
|
194
|
+
* session's kept snapshot.
|
|
195
|
+
*
|
|
196
|
+
* The same shape as the iOS field, deliberately: one `nativeGate` block
|
|
197
|
+
* must gate both hosts or an app is protected on one and not the other.
|
|
198
|
+
*/
|
|
199
|
+
nativeGate?: AndroidNativeGate;
|
|
186
200
|
/** Wake the app script every so often (`craftBackgroundRefresh`), through WorkManager. */
|
|
187
201
|
backgroundRefresh?: {
|
|
188
202
|
enabled?: boolean;
|
|
@@ -208,6 +222,20 @@ export interface CraftAndroidConfig {
|
|
|
208
222
|
sessions?: boolean;
|
|
209
223
|
};
|
|
210
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* An auth gate the shell applies while choosing the start path.
|
|
227
|
+
*
|
|
228
|
+
* See `CraftAndroidConfig.nativeGate`. Mirrors the iOS `NativeGate`, field for
|
|
229
|
+
* field, because one config block has to gate both hosts.
|
|
230
|
+
*/
|
|
231
|
+
export interface AndroidNativeGate {
|
|
232
|
+
/** Paths a signed-out launch may start on, each admitting what sits under it. */
|
|
233
|
+
publicPaths?: string[];
|
|
234
|
+
/** Where a signed-out launch starts instead. */
|
|
235
|
+
redirect: string;
|
|
236
|
+
/** The secure-storage key holding the sign-in. Defaults to `sessionTokenKey`. */
|
|
237
|
+
token?: string;
|
|
238
|
+
}
|
|
211
239
|
export interface CraftTabBarConfig {
|
|
212
240
|
selection?: {
|
|
213
241
|
/** The selected tab indicator's corner radius in dp; omitted keeps the default. */
|
|
@@ -1247,6 +1247,21 @@ function validateNativeRouteConfig(config) {
|
|
|
1247
1247
|
if (!matchesNativeRoute(config.nativeScreens, start, config.nativeScreenConstraints))
|
|
1248
1248
|
throw new Error(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
|
|
1249
1249
|
}
|
|
1250
|
+
const gate = config.nativeGate;
|
|
1251
|
+
if (gate !== undefined) {
|
|
1252
|
+
if (typeof gate.redirect !== "string" || normalizeNativeRoutePath(gate.redirect) !== gate.redirect)
|
|
1253
|
+
throw new Error(`nativeGate.redirect "${String(gate.redirect)}" must be an absolute path with no query, hash or trailing slash.`);
|
|
1254
|
+
if (config.renderer === "native" && screens.length > 0 && !matchesNativeRoute(config.nativeScreens, gate.redirect, config.nativeScreenConstraints))
|
|
1255
|
+
throw new Error(`nativeGate.redirect "${gate.redirect}" is not a nativeScreens path; a signed-out launch would have nothing to show.`);
|
|
1256
|
+
for (const path of gate.publicPaths ?? []) {
|
|
1257
|
+
if (typeof path !== "string" || normalizeNativeRoutePath(path) !== path)
|
|
1258
|
+
throw new Error(`nativeGate.publicPaths "${String(path)}" must be an absolute path with no query, hash or trailing slash.`);
|
|
1259
|
+
}
|
|
1260
|
+
if (!(gate.publicPaths ?? []).some((path) => gate.redirect === path || gate.redirect.startsWith(`${path}/`)))
|
|
1261
|
+
throw new Error(`nativeGate.redirect "${gate.redirect}" must also be listed in nativeGate.publicPaths.`);
|
|
1262
|
+
if (gate.token !== undefined && (typeof gate.token !== "string" || !gate.token))
|
|
1263
|
+
throw new Error("nativeGate.token must be the secure-storage key the sign-in lives under.");
|
|
1264
|
+
}
|
|
1250
1265
|
if (config.nativeApp !== undefined) {
|
|
1251
1266
|
if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
|
|
1252
1267
|
throw new Error(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
|
|
@@ -685,7 +685,16 @@ internal class CraftNativeServices(
|
|
|
685
685
|
voiceRecorder = null
|
|
686
686
|
val metadata = MediaMetadataRetriever()
|
|
687
687
|
val duration = try { metadata.setDataSource(file.absolutePath); (metadata.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toDoubleOrNull() ?: 0.0) / 1000 } finally { metadata.release() }
|
|
688
|
-
|
|
688
|
+
// Nothing recorded is its own answer, not a read failure.
|
|
689
|
+
// Thrown into the blanket catch below, it reached callers as
|
|
690
|
+
// RECORDING_FAILED and became indistinguishable from a damaged
|
|
691
|
+
// file, so an app could offer "nothing was recorded — try
|
|
692
|
+
// again" on iOS and not here (#496).
|
|
693
|
+
if (!file.exists() || file.length() == 0L || duration <= 0) {
|
|
694
|
+
cancelVoice()
|
|
695
|
+
done(failure("EMPTY_RECORDING", "The recording was empty"))
|
|
696
|
+
return
|
|
697
|
+
}
|
|
689
698
|
done(success(JSONObject().put("path", file.absolutePath).put("name", file.name).put("type", "audio/mp4").put("size", file.length()).put("durationSeconds", duration)))
|
|
690
699
|
} catch (_: Exception) { cancelVoice(); done(failure("RECORDING_FAILED", "The recording could not be read")) }
|
|
691
700
|
return
|
|
@@ -398,6 +398,15 @@ internal class CraftNativeStores private constructor(
|
|
|
398
398
|
|
|
399
399
|
fun snapshot(name: String): String? = snapshotCache[name]
|
|
400
400
|
|
|
401
|
+
/**
|
|
402
|
+
* One secure value, read directly rather than through the preload.
|
|
403
|
+
*
|
|
404
|
+
* For code that runs before any isolate exists. The auth gate resolves the
|
|
405
|
+
* start path before the first screen is built, so it cannot wait for the
|
|
406
|
+
* preload every isolate is handed (#499).
|
|
407
|
+
*/
|
|
408
|
+
fun secureValue(key: String): String? = secure?.getString(key, null)
|
|
409
|
+
|
|
401
410
|
/** `craft.storage.setSync`: null removes the key. Tells every isolate. */
|
|
402
411
|
fun setStorage(key: String, json: String?, from: String?) {
|
|
403
412
|
synchronized(CraftNativeStorage.lock) {
|
|
@@ -465,7 +474,19 @@ internal class CraftNativeStores private constructor(
|
|
|
465
474
|
CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from)
|
|
466
475
|
}
|
|
467
476
|
|
|
468
|
-
|
|
477
|
+
/**
|
|
478
|
+
* The same names UIKit accepts, so one checkpoint key is not valid on one
|
|
479
|
+
* host and refused on the other.
|
|
480
|
+
*
|
|
481
|
+
* `CraftSnapshots.isValidName` allows ASCII alphanumerics and `-_.:`, up
|
|
482
|
+
* to 128 characters, and rejects only a leading dot. The `:` is deliberate
|
|
483
|
+
* there — its own comment calls it the account namespace — so a key like
|
|
484
|
+
* `account:slot` wrote on iOS and came back `false` on Android.
|
|
485
|
+
*
|
|
486
|
+
* A colon never reaches the filesystem: `snapshotFile` percent-encodes the
|
|
487
|
+
* name, and `snapshotName` decodes it back.
|
|
488
|
+
*/
|
|
489
|
+
private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_:-][A-Za-z0-9_.:-]{0,127}"))
|
|
469
490
|
|
|
470
491
|
private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json")
|
|
471
492
|
|
|
@@ -495,6 +516,32 @@ internal object CraftNativePrelude {
|
|
|
495
516
|
*/
|
|
496
517
|
fun sessionTokenKey(config: JSONObject): String = config.optString("sessionTokenKey").ifBlank { "auth.token" }
|
|
497
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Whether `path` may be shown to someone who is not signed in.
|
|
521
|
+
*
|
|
522
|
+
* Listed, or under something listed: `/m/store` admits `/m/store/42` and
|
|
523
|
+
* not `/m/storefront`. The separator is attached before comparing so a
|
|
524
|
+
* listed path cannot admit a sibling that merely starts with its letters.
|
|
525
|
+
*
|
|
526
|
+
* A literal mirror of `CraftHybrid.gateAdmits` in
|
|
527
|
+
* `packages/ios/templates/CraftHybrid.swift`, down to the trailing-slash
|
|
528
|
+
* trimming, because the two hosts must admit exactly the same paths from
|
|
529
|
+
* the same `publicPaths` list. `native-gate.test.ts` runs both against one
|
|
530
|
+
* table of cases.
|
|
531
|
+
*/
|
|
532
|
+
fun gateAdmits(path: String, allowed: List<String>): Boolean = allowed.any { entry ->
|
|
533
|
+
var listed = entry
|
|
534
|
+
while (listed.length > 1 && listed.endsWith("/")) listed = listed.dropLast(1)
|
|
535
|
+
when {
|
|
536
|
+
listed.isEmpty() -> false
|
|
537
|
+
path == listed -> true
|
|
538
|
+
// "/" is the root path, not a prefix of everything. Reading it as
|
|
539
|
+
// one would admit every path, which is a gate that is not a gate.
|
|
540
|
+
listed == "/" -> false
|
|
541
|
+
else -> path.startsWith("$listed/")
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
498
545
|
fun script(
|
|
499
546
|
bridgeFields: String,
|
|
500
547
|
post: String,
|
|
@@ -632,7 +679,12 @@ internal object CraftNativePrelude {
|
|
|
632
679
|
if (message && message.type === 'FRAME_FIRE') {
|
|
633
680
|
var due = frames;
|
|
634
681
|
frames = [];
|
|
635
|
-
|
|
682
|
+
// A real first frame is 0, which `||` would replace with a Unix
|
|
683
|
+
// epoch value twelve orders of magnitude away. Only a missing or
|
|
684
|
+
// non-finite time falls back, and it falls back to the same
|
|
685
|
+
// origin-relative scale rather than to wall-clock.
|
|
686
|
+
var reported = message.payload && message.payload.time;
|
|
687
|
+
var time = typeof reported === 'number' && isFinite(reported) ? reported : 0;
|
|
636
688
|
due.forEach(function(entry) {
|
|
637
689
|
if (cancelledFrames[entry.id]) { delete cancelledFrames[entry.id]; return; }
|
|
638
690
|
try { entry.fn(time); } catch (error) { console.error('[frame] ' + (error && error.stack || error)); }
|
|
@@ -704,8 +756,11 @@ internal object CraftNativePrelude {
|
|
|
704
756
|
if (json === null || json === undefined) return null;
|
|
705
757
|
try { return JSON.parse(json); } catch (error) { return null; }
|
|
706
758
|
}
|
|
759
|
+
// The same names the host accepts, including the `:` UIKit calls the
|
|
760
|
+
// account namespace. This gate runs inside the isolate, so a stricter
|
|
761
|
+
// rule here refuses a key the host would have kept.
|
|
707
762
|
function validSnapshotName(name) {
|
|
708
|
-
return /^[A-Za-z0-9_
|
|
763
|
+
return /^[A-Za-z0-9_:-][A-Za-z0-9_.:-]{0,127}$/.test(name);
|
|
709
764
|
}
|
|
710
765
|
var craft = globalThis.craft = globalThis.craft || {};
|
|
711
766
|
function keep(name, extras) {
|
|
@@ -735,7 +790,7 @@ internal object CraftNativePrelude {
|
|
|
735
790
|
},
|
|
736
791
|
set: function(name, value) {
|
|
737
792
|
name = String(name);
|
|
738
|
-
if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_" and "
|
|
793
|
+
if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_", "-" and ":"'));
|
|
739
794
|
var json = value === null ? null : JSON.stringify(value);
|
|
740
795
|
if (json === undefined) return Promise.reject(new TypeError('Snapshot ' + name + ' is not JSON'));
|
|
741
796
|
if (json === null) delete sync.snapshots[name];
|
|
@@ -824,9 +879,24 @@ internal object CraftNativePrelude {
|
|
|
824
879
|
|
|
825
880
|
/**
|
|
826
881
|
* The bar above a screen (back, title, the screen's buttons) and the tab bar
|
|
827
|
-
* below it, in plain Android views.
|
|
828
|
-
*
|
|
829
|
-
*
|
|
882
|
+
* below it, in plain Android views. The tabs come from the app's `tabs`; a
|
|
883
|
+
* screen sets its own chrome with
|
|
884
|
+
*
|
|
885
|
+
* `craft.navigation.setOptions({ title, largeTitle, titleColor, hidden,
|
|
886
|
+
* backgroundColor, leftButtons, rightButtons, tabItem, tabAccessory,
|
|
887
|
+
* tabBarHidden, tabBarHiddenWhenPushed, tabSwipeEnabled })`
|
|
888
|
+
*
|
|
889
|
+
* which `apply` below reads. That list is checked against `apply` itself
|
|
890
|
+
* rather than kept by hand, and only the list is: the iOS comment named
|
|
891
|
+
* twelve keys while fifteen shipped (#489), and a first attempt at the same
|
|
892
|
+
* ratchet there passed with keys deleted because the prose underneath
|
|
893
|
+
* happened to mention them. Six of these twelve had never been written down.
|
|
894
|
+
*
|
|
895
|
+
* `tabBarHiddenWhenPushed` was honoured here and nowhere else in Craft until
|
|
896
|
+
* #493: hiding the tab bar on push is the ordinary iOS idiom, so a caller who
|
|
897
|
+
* set it got the behaviour on one host and silence on the other. UIKit reads
|
|
898
|
+
* the same key now, at the same depth, through CraftNativeScreen's
|
|
899
|
+
* `wantsTabBarHidden`.
|
|
830
900
|
*/
|
|
831
901
|
internal class CraftNativeChrome(
|
|
832
902
|
context: Context,
|
|
@@ -1316,7 +1386,25 @@ internal class CraftNativeChrome(
|
|
|
1316
1386
|
if (badge > 0) {
|
|
1317
1387
|
view.text = "${view.text}${if (button.optString("badgeStyle") == "dot") " •" else " $badge"}"
|
|
1318
1388
|
}
|
|
1319
|
-
|
|
1389
|
+
// `menu`: the button opens a popup instead of reporting its own id, and
|
|
1390
|
+
// each choice is reported as a press of that choice's id.
|
|
1391
|
+
val choices = button.optJSONArray("menu")
|
|
1392
|
+
val menu = (0 until minOf(choices?.length() ?: 0, 12)).mapNotNull { index ->
|
|
1393
|
+
choices?.optJSONObject(index)?.takeIf { it.optString("id").isNotBlank() && it.optString("title").isNotBlank() }
|
|
1394
|
+
}
|
|
1395
|
+
view.setOnClickListener {
|
|
1396
|
+
if (menu.isEmpty()) onButton?.invoke(id)
|
|
1397
|
+
else android.widget.PopupMenu(context, view).apply {
|
|
1398
|
+
menu.forEachIndexed { index, choice ->
|
|
1399
|
+
this.menu.add(0, index, index, choice.optString("title")).isEnabled = !choice.optBoolean("disabled")
|
|
1400
|
+
}
|
|
1401
|
+
setOnMenuItemClickListener { selected ->
|
|
1402
|
+
menu.getOrNull(selected.itemId)?.let { onButton?.invoke(it.optString("id")) }
|
|
1403
|
+
true
|
|
1404
|
+
}
|
|
1405
|
+
show()
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1320
1408
|
view.layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
|
1321
1409
|
return view
|
|
1322
1410
|
}
|
|
@@ -149,6 +149,34 @@ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean
|
|
|
149
149
|
|
|
150
150
|
private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* A scroll event's geometry in density-independent units.
|
|
154
|
+
*
|
|
155
|
+
* UIKit reports `contentOffset`, `contentSize` and `layoutMeasurement` in
|
|
156
|
+
* points, and this host's own `emitLayoutEvent` already divides a View's
|
|
157
|
+
* pixels by the display density. These three were the exception: they shipped
|
|
158
|
+
* raw pixels, so the same scroll handler saw values three times larger on a
|
|
159
|
+
* 3x Android screen than on iOS — and larger than the `onLayout` bounds the
|
|
160
|
+
* same screen reports, which is the comparison infinite-scroll code makes.
|
|
161
|
+
*/
|
|
162
|
+
private fun craftNativeScrollMetrics(
|
|
163
|
+
offsetX: Int,
|
|
164
|
+
offsetY: Int,
|
|
165
|
+
contentWidth: Int,
|
|
166
|
+
contentHeight: Int,
|
|
167
|
+
viewportWidth: Int,
|
|
168
|
+
viewportHeight: Int,
|
|
169
|
+
density: Double,
|
|
170
|
+
): JSONObject {
|
|
171
|
+
// A density of zero would be a broken DisplayMetrics; report pixels rather
|
|
172
|
+
// than NaN, which JSON cannot carry and a handler cannot compare.
|
|
173
|
+
val scale = if (density.isFinite() && density > 0.0) density else 1.0
|
|
174
|
+
return JSONObject()
|
|
175
|
+
.put("contentOffset", JSONObject().put("x", offsetX / scale).put("y", offsetY / scale))
|
|
176
|
+
.put("contentSize", JSONObject().put("width", contentWidth / scale).put("height", contentHeight / scale))
|
|
177
|
+
.put("layoutMeasurement", JSONObject().put("width", viewportWidth / scale).put("height", viewportHeight / scale))
|
|
178
|
+
}
|
|
179
|
+
|
|
152
180
|
private fun craftNativePickerValue(value: Any?): String? = when (value) {
|
|
153
181
|
is String -> value
|
|
154
182
|
is Number -> value.toDouble().takeIf { it.isFinite() }?.let { value.toString() }
|
|
@@ -391,6 +419,16 @@ private object CraftNativeSkewTransforms {
|
|
|
391
419
|
/** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
|
|
392
420
|
/** Local native chart selection: no JS events are needed while scrubbing. */
|
|
393
421
|
private class CraftNativeChartInspection(private val view: View) {
|
|
422
|
+
companion object {
|
|
423
|
+
/** The one chart with a reading pinned open, if any. */
|
|
424
|
+
private var pinned: java.lang.ref.WeakReference<CraftNativeChartInspection>? = null
|
|
425
|
+
/** A touch that starts off the pinned chart closes its reading; the touch still lands where it was aimed. */
|
|
426
|
+
fun touchBegan(event: MotionEvent) {
|
|
427
|
+
if (event.actionMasked != MotionEvent.ACTION_DOWN) return
|
|
428
|
+
val open = pinned?.get() ?: return
|
|
429
|
+
if (!open.contains(event.rawX, event.rawY)) open.dismiss()
|
|
430
|
+
}
|
|
431
|
+
}
|
|
394
432
|
private var fingerprint = ""
|
|
395
433
|
private var title = ""
|
|
396
434
|
private var points = emptyList<JSONObject>()
|
|
@@ -399,8 +437,9 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
399
437
|
private var startY = 0f
|
|
400
438
|
private var dragging = false
|
|
401
439
|
private var abandoned = false
|
|
440
|
+
private var holding = false
|
|
402
441
|
private val slop = android.view.ViewConfiguration.get(view.context).scaledTouchSlop
|
|
403
|
-
private val hold = Runnable { if (!abandoned) selectAt(startX) }
|
|
442
|
+
private val hold = Runnable { if (!abandoned) { holding = true; selectAt(startX) } }
|
|
404
443
|
val enabled: Boolean get() = points.isNotEmpty()
|
|
405
444
|
private val density get() = view.resources.displayMetrics.density
|
|
406
445
|
private fun dp(value: Float) = value * density
|
|
@@ -414,7 +453,7 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
414
453
|
val x = it.optDouble("x", Double.NaN)
|
|
415
454
|
x.isFinite() && x in 0.0..1.0 && it.opt("label") is String
|
|
416
455
|
}
|
|
417
|
-
|
|
456
|
+
dismiss()
|
|
418
457
|
view.isFocusable = enabled
|
|
419
458
|
view.invalidate()
|
|
420
459
|
}
|
|
@@ -431,16 +470,31 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
431
470
|
view.sendAccessibilityEvent(android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SELECTED)
|
|
432
471
|
view.invalidate()
|
|
433
472
|
}
|
|
434
|
-
private fun
|
|
435
|
-
if (view.width <= 0) return
|
|
473
|
+
private fun nearest(x: Float): Int? {
|
|
474
|
+
if (view.width <= 0) return null
|
|
436
475
|
val fraction = (x / view.width).coerceIn(0f, 1f)
|
|
437
|
-
points.indices.minByOrNull { kotlin.math.abs(points[it].optDouble("x") - fraction) }
|
|
476
|
+
return points.indices.minByOrNull { kotlin.math.abs(points[it].optDouble("x") - fraction) }
|
|
477
|
+
}
|
|
478
|
+
private fun selectAt(x: Float) { nearest(x)?.let { select(it); pinned = java.lang.ref.WeakReference(this) } }
|
|
479
|
+
/** Puts the reading away. Returns whether one was showing. */
|
|
480
|
+
fun dismiss(): Boolean {
|
|
481
|
+
if (pinned?.get() === this) pinned = null
|
|
482
|
+
if (selected < 0) return false
|
|
483
|
+
selected = -1
|
|
484
|
+
view.invalidate()
|
|
485
|
+
return true
|
|
486
|
+
}
|
|
487
|
+
/** A tap pins the reading under it; tapping the pinned reading again puts it away. */
|
|
488
|
+
private fun tapAt(x: Float) { if (nearest(x) == selected) dismiss() else selectAt(x) }
|
|
489
|
+
private fun contains(rawX: Float, rawY: Float): Boolean {
|
|
490
|
+
val origin = IntArray(2).also { view.getLocationOnScreen(it) }
|
|
491
|
+
return rawX >= origin[0] && rawX < origin[0] + view.width && rawY >= origin[1] && rawY < origin[1] + view.height
|
|
438
492
|
}
|
|
439
493
|
fun touch(event: MotionEvent): Boolean {
|
|
440
494
|
if (!enabled) return false
|
|
441
495
|
when (event.actionMasked) {
|
|
442
496
|
MotionEvent.ACTION_DOWN -> {
|
|
443
|
-
startX = event.x; startY = event.y; dragging = false; abandoned = false
|
|
497
|
+
startX = event.x; startY = event.y; dragging = false; abandoned = false; holding = false
|
|
444
498
|
view.postDelayed(hold, 250)
|
|
445
499
|
}
|
|
446
500
|
MotionEvent.ACTION_MOVE -> {
|
|
@@ -455,17 +509,19 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
455
509
|
}
|
|
456
510
|
MotionEvent.ACTION_UP -> {
|
|
457
511
|
view.removeCallbacks(hold)
|
|
458
|
-
|
|
512
|
+
// A drag or a hold reads the chart only while the finger is down.
|
|
513
|
+
if (dragging || holding) dismiss()
|
|
514
|
+
else if (!abandoned) { view.performClick(); tapAt(event.x) }
|
|
459
515
|
view.parent?.requestDisallowInterceptTouchEvent(false)
|
|
460
516
|
}
|
|
461
517
|
MotionEvent.ACTION_CANCEL -> {
|
|
462
|
-
view.removeCallbacks(hold); abandoned = true;
|
|
463
|
-
view.parent?.requestDisallowInterceptTouchEvent(false)
|
|
518
|
+
view.removeCallbacks(hold); abandoned = true; dismiss()
|
|
519
|
+
view.parent?.requestDisallowInterceptTouchEvent(false)
|
|
464
520
|
}
|
|
465
521
|
}
|
|
466
522
|
return !abandoned
|
|
467
523
|
}
|
|
468
|
-
fun detach() { view.removeCallbacks(hold);
|
|
524
|
+
fun detach() { view.removeCallbacks(hold); dismiss() }
|
|
469
525
|
fun accessibility(info: android.view.accessibility.AccessibilityNodeInfo) {
|
|
470
526
|
if (!enabled) return
|
|
471
527
|
info.className = "android.widget.SeekBar"
|
|
@@ -480,7 +536,7 @@ private class CraftNativeChartInspection(private val view: View) {
|
|
|
480
536
|
when (action) {
|
|
481
537
|
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> select(if (selected < 0) 0 else selected + 1)
|
|
482
538
|
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> select(if (selected < 0) points.lastIndex else selected - 1)
|
|
483
|
-
android.view.accessibility.AccessibilityNodeInfo.ACTION_DISMISS ->
|
|
539
|
+
android.view.accessibility.AccessibilityNodeInfo.ACTION_DISMISS -> dismiss()
|
|
484
540
|
else -> return false
|
|
485
541
|
}
|
|
486
542
|
return true
|
|
@@ -1186,6 +1242,7 @@ internal object CraftNativePointers {
|
|
|
1186
1242
|
fun ownsTouch(view: View): Boolean = route(view).firstOrNull() !in setOf("none", "box-none")
|
|
1187
1243
|
|
|
1188
1244
|
fun dispatch(view: View, event: MotionEvent, native: (MotionEvent) -> Boolean): Boolean {
|
|
1245
|
+
CraftNativeChartInspection.touchBegan(event)
|
|
1189
1246
|
val state = states.getOrPut(view) { State() }
|
|
1190
1247
|
val route = route(view)
|
|
1191
1248
|
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
@@ -4827,6 +4884,17 @@ class MainActivity : AppCompatActivity() {
|
|
|
4827
4884
|
val chunks = CraftNativeChunks()
|
|
4828
4885
|
/** A `requestAnimationFrame` is waiting for the next frame. */
|
|
4829
4886
|
var frameRequested = false
|
|
4887
|
+
/**
|
|
4888
|
+
* Choreographer's frame time at this screen's first delivered frame.
|
|
4889
|
+
*
|
|
4890
|
+
* `requestAnimationFrame` hands its callback a `DOMHighResTimeStamp`:
|
|
4891
|
+
* milliseconds measured from the context's own origin, so the first one
|
|
4892
|
+
* is near zero. `frameTimeNanos` counts from boot, which on a device up
|
|
4893
|
+
* for a month is nine digits of offset, and UIKit already subtracts its
|
|
4894
|
+
* own origin (`frameTimeOrigin` in `CraftNativeScreen.swift`). Without
|
|
4895
|
+
* this the same shared animation code sees two different epochs.
|
|
4896
|
+
*/
|
|
4897
|
+
var frameTimeOrigin: Long? = null
|
|
4830
4898
|
/** The screen's timers (`setTimeout`), kept by the host. */
|
|
4831
4899
|
val timers = mutableMapOf<Int, Runnable>()
|
|
4832
4900
|
val drafts = mutableMapOf<String, String>()
|
|
@@ -5182,9 +5250,51 @@ class MainActivity : AppCompatActivity() {
|
|
|
5182
5250
|
}
|
|
5183
5251
|
val launch = initialDeepLink?.let { routes.appPath(it.toString(), urlSchemes) }
|
|
5184
5252
|
if (launch != null) deliveredInitialDeepLink = true
|
|
5185
|
-
|
|
5253
|
+
// A launch link is deliberately not gated. Suppressing one would have
|
|
5254
|
+
// to decide whether it is dropped or replayed after signing in, and
|
|
5255
|
+
// UIKit does not gate them either — its gate resolves the start path
|
|
5256
|
+
// while links are still queued for replay. Same gap, both hosts, and
|
|
5257
|
+
// better as its own change than as a silent asymmetry here.
|
|
5258
|
+
val start = if (launch != null) launch else gatedStartPath(routes.initialPath)
|
|
5259
|
+
selectTab(routes.tabFor(start)?.let { routes.tabs.indexOf(it) } ?: 0)
|
|
5186
5260
|
if (launch != null && routes.tabFor(launch) == null) navigateOpen(launch, fromLink = true)
|
|
5187
|
-
else if (routes.tabs.isEmpty() && screens.isEmpty()) navigateOpen(
|
|
5261
|
+
else if (routes.tabs.isEmpty() && screens.isEmpty()) navigateOpen(start)
|
|
5262
|
+
}
|
|
5263
|
+
|
|
5264
|
+
/**
|
|
5265
|
+
* Send a signed-out launch to the gate's redirect before any screen is
|
|
5266
|
+
* built (#499).
|
|
5267
|
+
*
|
|
5268
|
+
* The app script cannot do this. `openScreen` returns as soon as it has an
|
|
5269
|
+
* isolate and evaluates the script later on an executor, so opening the
|
|
5270
|
+
* app script before the first screen only creates its isolate first — the
|
|
5271
|
+
* two then evaluate with nothing ordering them, and whether the gate runs
|
|
5272
|
+
* before the first screen paints is a race. On a launch that follows an
|
|
5273
|
+
* expired session rather than a sign-out, losing that race paints the
|
|
5274
|
+
* previous session's kept snapshot.
|
|
5275
|
+
*
|
|
5276
|
+
* UIKit loses the same race by construction rather than by chance, which
|
|
5277
|
+
* is where this was reported; the fix is the same on both and so is the
|
|
5278
|
+
* config.
|
|
5279
|
+
*/
|
|
5280
|
+
private fun gatedStartPath(start: String): String {
|
|
5281
|
+
val gate = config.optJSONObject("nativeGate") ?: return start
|
|
5282
|
+
val redirect = gate.optString("redirect").takeIf { it.isNotBlank() } ?: return start
|
|
5283
|
+
val listed = gate.optJSONArray("publicPaths")
|
|
5284
|
+
val allowed = (0 until (listed?.length() ?: 0)).mapNotNull { index ->
|
|
5285
|
+
listed?.optString(index)?.takeIf { it.isNotBlank() }
|
|
5286
|
+
}
|
|
5287
|
+
if (CraftNativePrelude.gateAdmits(start, allowed)) return start
|
|
5288
|
+
val key = gate.optString("token").ifBlank { CraftNativePrelude.sessionTokenKey(config) }
|
|
5289
|
+
if (!stores.secureValue(key).isNullOrEmpty()) return start
|
|
5290
|
+
// A redirect nothing can draw would trade a private screen for a blank
|
|
5291
|
+
// one. Say so and leave the start path alone.
|
|
5292
|
+
if (routes.match(redirect) == null) {
|
|
5293
|
+
Log.w(TAG, "nativeGate redirect $redirect has no native screen; the gate did not run")
|
|
5294
|
+
return start
|
|
5295
|
+
}
|
|
5296
|
+
Log.i(TAG, "no sign-in under $key: starting on $redirect instead of $start")
|
|
5297
|
+
return redirect
|
|
5188
5298
|
}
|
|
5189
5299
|
|
|
5190
5300
|
private fun installChrome() {
|
|
@@ -5814,7 +5924,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
5814
5924
|
if (!screen.active || !screen.frameRequested) return@postFrameCallback
|
|
5815
5925
|
if (visibleScreen !== screen && !screen.headless) return@postFrameCallback
|
|
5816
5926
|
screen.frameRequested = false
|
|
5817
|
-
|
|
5927
|
+
// Milliseconds from this screen's first frame, not from boot: see
|
|
5928
|
+
// Screen.frameTimeOrigin. Nanoseconds are subtracted before the
|
|
5929
|
+
// division so the difference keeps full resolution.
|
|
5930
|
+
val origin = screen.frameTimeOrigin ?: frameTime.also { screen.frameTimeOrigin = it }
|
|
5931
|
+
send(screen, "FRAME_FIRE", JSONObject().put("time", (frameTime - origin) / 1_000_000.0))
|
|
5818
5932
|
}
|
|
5819
5933
|
}
|
|
5820
5934
|
|
|
@@ -7136,10 +7250,15 @@ class MainActivity : AppCompatActivity() {
|
|
|
7136
7250
|
onScroll = { scrollX, scrollY ->
|
|
7137
7251
|
screen.scrollHandlers[nativeScroll]?.let { current ->
|
|
7138
7252
|
val contentView = nativeScroll.content
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7253
|
+
// Density-independent, like `emitLayoutEvent` and
|
|
7254
|
+
// like UIKit's points: see craftNativeScrollMetrics.
|
|
7255
|
+
val scale = resources.displayMetrics.density.toDouble()
|
|
7256
|
+
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", craftNativeScrollMetrics(
|
|
7257
|
+
offsetX = scrollX, offsetY = scrollY,
|
|
7258
|
+
contentWidth = contentView.width, contentHeight = contentView.height,
|
|
7259
|
+
viewportWidth = nativeScroll.width, viewportHeight = nativeScroll.height,
|
|
7260
|
+
density = scale,
|
|
7261
|
+
)))
|
|
7143
7262
|
}
|
|
7144
7263
|
},
|
|
7145
7264
|
onBeginDrag = {
|
|
@@ -7476,16 +7595,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
7476
7595
|
?: craftNativeOptionalString(props.opt("resizeMode")).orEmpty()
|
|
7477
7596
|
}
|
|
7478
7597
|
|
|
7479
|
-
private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject =
|
|
7480
|
-
.
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
.
|
|
7484
|
-
|
|
7485
|
-
.put("height", list.computeVerticalScrollRange()))
|
|
7486
|
-
.put("layoutMeasurement", JSONObject()
|
|
7487
|
-
.put("width", list.width)
|
|
7488
|
-
.put("height", list.height))
|
|
7598
|
+
private fun flatListScrollEvent(list: CraftNativeFlatList): JSONObject = craftNativeScrollMetrics(
|
|
7599
|
+
offsetX = list.computeHorizontalScrollOffset(), offsetY = list.computeVerticalScrollOffset(),
|
|
7600
|
+
contentWidth = list.computeHorizontalScrollRange(), contentHeight = list.computeVerticalScrollRange(),
|
|
7601
|
+
viewportWidth = list.width, viewportHeight = list.height,
|
|
7602
|
+
density = resources.displayMetrics.density.toDouble(),
|
|
7603
|
+
)
|
|
7489
7604
|
|
|
7490
7605
|
private fun reconcileChildren(
|
|
7491
7606
|
screen: Screen,
|
package/dist/cli.js
CHANGED
|
@@ -3243,7 +3243,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3243
3243
|
`);
|
|
3244
3244
|
}
|
|
3245
3245
|
// package.json
|
|
3246
|
-
var version = "0.0.
|
|
3246
|
+
var version = "0.0.490";
|
|
3247
3247
|
|
|
3248
3248
|
// bin/cli.ts
|
|
3249
3249
|
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
|
@@ -317,6 +317,28 @@ export interface CraftConfig {
|
|
|
317
317
|
* `craftsession` event on the page.
|
|
318
318
|
*/
|
|
319
319
|
sessionTokenKey?: string;
|
|
320
|
+
/**
|
|
321
|
+
* Which screens a signed-out launch may start on.
|
|
322
|
+
*
|
|
323
|
+
* The shell resolves this while it is deciding the start path, before any
|
|
324
|
+
* screen is built. An app script cannot do the same job: it is started from
|
|
325
|
+
* the first frame's completion block, so by the time it can read a token the
|
|
326
|
+
* signed-in screen it would have prevented has already been painted — with
|
|
327
|
+
* the previous session's kept snapshot in it when the launch follows an
|
|
328
|
+
* expired token rather than a sign-out.
|
|
329
|
+
*
|
|
330
|
+
* ```ts
|
|
331
|
+
* nativeGate: {
|
|
332
|
+
* publicPaths: ['/m/login', '/m/signup', '/m/store'],
|
|
333
|
+
* redirect: '/m/login',
|
|
334
|
+
* }
|
|
335
|
+
* ```
|
|
336
|
+
*
|
|
337
|
+
* `publicPaths` admits a listed path and anything under it, so `/m/store`
|
|
338
|
+
* covers `/m/store/42` and not `/m/storefront`. `token` defaults to
|
|
339
|
+
* `sessionTokenKey`, the item `craft.session` already follows.
|
|
340
|
+
*/
|
|
341
|
+
nativeGate?: NativeGate;
|
|
320
342
|
/**
|
|
321
343
|
* Crash and error reporting to bughq (CraftCrashReporter.swift): crashes
|
|
322
344
|
* and hangs (signal handlers, NSException, MetricKit), uncaught JavaScript
|
|
@@ -325,6 +347,25 @@ export interface CraftConfig {
|
|
|
325
347
|
*/
|
|
326
348
|
crashReporting?: CraftCrashReporting;
|
|
327
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* An auth gate the shell applies while resolving the start path.
|
|
352
|
+
*
|
|
353
|
+
* See `CraftConfig.nativeGate`. The allowlist is static data in the apps that
|
|
354
|
+
* need this, so emitting it into the config costs nothing at runtime.
|
|
355
|
+
*/
|
|
356
|
+
export interface NativeGate {
|
|
357
|
+
/**
|
|
358
|
+
* Paths a signed-out launch may start on, each admitting what sits under it.
|
|
359
|
+
*
|
|
360
|
+
* Not `public`: that is a Swift keyword, and remapping it would make the
|
|
361
|
+
* stored key differ from the property the host decodes it into.
|
|
362
|
+
*/
|
|
363
|
+
publicPaths?: string[];
|
|
364
|
+
/** Where a signed-out launch starts instead. Must itself be public. */
|
|
365
|
+
redirect: string;
|
|
366
|
+
/** The secure-storage key holding the sign-in. Defaults to `sessionTokenKey`. */
|
|
367
|
+
token?: string;
|
|
368
|
+
}
|
|
328
369
|
export interface CraftCrashReporting {
|
|
329
370
|
/** The bughq project's public ingest key. Ships in the app; it is not a secret. */
|
|
330
371
|
key: string;
|
|
@@ -362,7 +403,7 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
|
|
|
362
403
|
*/
|
|
363
404
|
export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
|
|
364
405
|
/** What is wrong with a config's hybrid and tab settings, one sentence each. */
|
|
365
|
-
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'nativeScreenConstraints' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread' | 'notificationCategories'>): string[];
|
|
406
|
+
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'nativeScreenConstraints' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread' | 'notificationCategories' | 'nativeGate'>): string[];
|
|
366
407
|
/**
|
|
367
408
|
* The builder installs a directory of route bundles (stx's `routesDir`) as
|
|
368
409
|
* well as the one-file bundle, and the app runs each screen from its own
|
package/dist/ios/src/index.js
CHANGED
|
@@ -740,6 +740,21 @@ function hybridConfigProblems(config) {
|
|
|
740
740
|
if (!matchNativePath(config.nativeScreens, start, config.nativeScreenConstraints))
|
|
741
741
|
problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
|
|
742
742
|
}
|
|
743
|
+
const gate = config.nativeGate;
|
|
744
|
+
if (gate !== undefined) {
|
|
745
|
+
if (typeof gate.redirect !== "string" || normalizeNativePath(gate.redirect) !== gate.redirect)
|
|
746
|
+
problems.push(`nativeGate.redirect "${String(gate.redirect)}" must be an absolute path with no query, hash or trailing slash.`);
|
|
747
|
+
else if (hasNativeRoutes(config) && !matchNativePath(config.nativeScreens, gate.redirect, config.nativeScreenConstraints))
|
|
748
|
+
problems.push(`nativeGate.redirect "${gate.redirect}" is not a nativeScreens path; a signed-out launch would have nothing to show.`);
|
|
749
|
+
for (const path of gate.publicPaths ?? []) {
|
|
750
|
+
if (typeof path !== "string" || normalizeNativePath(path) !== path)
|
|
751
|
+
problems.push(`nativeGate.publicPaths "${String(path)}" must be an absolute path with no query, hash or trailing slash.`);
|
|
752
|
+
}
|
|
753
|
+
if (typeof gate.redirect === "string" && !(gate.publicPaths ?? []).some((path) => gate.redirect === path || gate.redirect.startsWith(`${path}/`)))
|
|
754
|
+
problems.push(`nativeGate.redirect "${gate.redirect}" must also be listed in nativeGate.publicPaths.`);
|
|
755
|
+
if (gate.token !== undefined && (typeof gate.token !== "string" || !gate.token))
|
|
756
|
+
problems.push("nativeGate.token must be the secure-storage key the sign-in lives under.");
|
|
757
|
+
}
|
|
743
758
|
if (config.nativeApp !== undefined) {
|
|
744
759
|
if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
|
|
745
760
|
problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
|
|
@@ -2046,6 +2046,9 @@ struct CraftConfig: Codable {
|
|
|
2046
2046
|
/// (`auth.token` unless set): `craft.session` follows it, and a change of
|
|
2047
2047
|
/// it is `SESSION_CHANGED` on every screen (CraftSession).
|
|
2048
2048
|
var sessionTokenKey: String? = nil
|
|
2049
|
+
/// Which screens a signed-out launch may start on, resolved before the
|
|
2050
|
+
/// first frame is built (CraftHybrid.configure).
|
|
2051
|
+
var nativeGate: NativeGate? = nil
|
|
2049
2052
|
/// The bundle route of the app script: a screen-less script that runs for
|
|
2050
2053
|
/// the app's whole life and hears the app's events (CraftHybrid.swift).
|
|
2051
2054
|
var nativeApp: String? = nil
|
|
@@ -2057,6 +2060,33 @@ struct CraftConfig: Codable {
|
|
|
2057
2060
|
/// unless the config names a project key.
|
|
2058
2061
|
var crashReporting: CrashReporting? = nil
|
|
2059
2062
|
|
|
2063
|
+
/// An auth gate the shell applies while resolving the start path.
|
|
2064
|
+
///
|
|
2065
|
+
/// An app script cannot do this. It is started from the first frame's
|
|
2066
|
+
/// `CATransaction` completion block, so by the time it can read a token
|
|
2067
|
+
/// the signed-in screen it would have prevented is already on screen —
|
|
2068
|
+
/// with the previous session's kept snapshot in it when the launch
|
|
2069
|
+
/// follows an expired token rather than a sign-out (#499). The check has
|
|
2070
|
+
/// to belong to whoever decides the start path, and that is the shell.
|
|
2071
|
+
struct NativeGate: Codable {
|
|
2072
|
+
/// Paths that may be shown without a sign-in. A path is public when
|
|
2073
|
+
/// it is listed, or when it sits under a listed one: `/m/store`
|
|
2074
|
+
/// admits `/m/store/42` and not `/m/storefront`.
|
|
2075
|
+
///
|
|
2076
|
+
/// Spelled `publicPaths` rather than `public` in the stored config
|
|
2077
|
+
/// too. `public` is a Swift keyword, and remapping it through
|
|
2078
|
+
/// `CodingKeys` would make the property name and the stored key
|
|
2079
|
+
/// differ — which is exactly what the Zig conformance mirror checks,
|
|
2080
|
+
/// and a divergence worth not having for the sake of one word.
|
|
2081
|
+
var publicPaths: [String]? = nil
|
|
2082
|
+
/// Where a signed-out launch starts instead.
|
|
2083
|
+
var redirect: String? = nil
|
|
2084
|
+
/// The secure-storage key holding the sign-in. Defaults to
|
|
2085
|
+
/// `sessionTokenKey`, which is the same item `craft.session` follows,
|
|
2086
|
+
/// so an app that already declares one need not repeat it.
|
|
2087
|
+
var token: String? = nil
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2060
2090
|
struct Tab: Codable {
|
|
2061
2091
|
var id: String
|
|
2062
2092
|
var title: String
|
|
@@ -425,6 +425,7 @@ final class CraftHybrid: NSObject {
|
|
|
425
425
|
: "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; the app has no screens to show")
|
|
426
426
|
}
|
|
427
427
|
isEnabled = wanted && Self.hasBundle
|
|
428
|
+
applyGate(config)
|
|
428
429
|
if isEnabled, !hasWeb, routes.match(startPath) == nil {
|
|
429
430
|
// No web view to show it in: the first tab a screen draws.
|
|
430
431
|
let fallback = (config.tabs ?? []).map(\.id).first { routes.match($0) != nil }
|
|
@@ -458,6 +459,54 @@ final class CraftHybrid: NSObject {
|
|
|
458
459
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { self.startAppScript() }
|
|
459
460
|
}
|
|
460
461
|
|
|
462
|
+
/// Whether `path` may be shown to someone who is not signed in.
|
|
463
|
+
///
|
|
464
|
+
/// Listed, or under something listed: `/m/store` admits `/m/store/42` and
|
|
465
|
+
/// not `/m/storefront`. Prefixes are compared with the separator attached
|
|
466
|
+
/// so a listed path cannot admit a sibling that merely starts with its
|
|
467
|
+
/// letters.
|
|
468
|
+
static func gateAdmits(_ path: String, public allowed: [String]) -> Bool {
|
|
469
|
+
allowed.contains { entry in
|
|
470
|
+
var listed = entry
|
|
471
|
+
while listed.count > 1, listed.hasSuffix("/") { listed.removeLast() }
|
|
472
|
+
guard !listed.isEmpty else { return false }
|
|
473
|
+
if path == listed { return true }
|
|
474
|
+
// "/" is the root path, not a prefix of everything. Reading it as
|
|
475
|
+
// one would admit every path, which is a gate that is not a gate.
|
|
476
|
+
guard listed != "/" else { return false }
|
|
477
|
+
return path.hasPrefix(listed + "/")
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// Send a signed-out launch to the gate's redirect before anything is built.
|
|
482
|
+
///
|
|
483
|
+
/// This runs while `startPath` is still being resolved, which is the
|
|
484
|
+
/// whole point: the app script that would otherwise do it does not start
|
|
485
|
+
/// until the first frame has been committed, so it can only push sign-in
|
|
486
|
+
/// on top of a screen that has already been drawn with the previous
|
|
487
|
+
/// session's data in it (#499).
|
|
488
|
+
///
|
|
489
|
+
/// The Keychain read is synchronous and can cost 100-900ms cold. It is
|
|
490
|
+
/// not an extra read: every signed-in screen reads this same item when it
|
|
491
|
+
/// opens, and `CraftKeychain` caches it, so the gate moves the app's first
|
|
492
|
+
/// read earlier rather than adding one. Only apps that declare a gate pay
|
|
493
|
+
/// it at all.
|
|
494
|
+
private func applyGate(_ config: CraftConfig) {
|
|
495
|
+
guard let gate = config.nativeGate, let redirect = gate.redirect, !redirect.isEmpty else { return }
|
|
496
|
+
guard !Self.gateAdmits(startPath, public: gate.publicPaths ?? []) else { return }
|
|
497
|
+
let key = gate.token ?? config.sessionTokenKey ?? "auth.token"
|
|
498
|
+
guard (CraftNativeActions.webSecureValue(forKey: key) ?? "").isEmpty else { return }
|
|
499
|
+
// A redirect nothing can draw would trade a private screen for a
|
|
500
|
+
// blank one. Say so and leave the start path alone; the fallback
|
|
501
|
+
// below then treats it as any other unroutable start.
|
|
502
|
+
if isEnabled, !hasWeb, routes.match(redirect) == nil {
|
|
503
|
+
NSLog("[craft hybrid] nativeGate redirect %@ has no native screen; the gate did not run", redirect)
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
NSLog("[craft hybrid] no sign-in under %@: starting on %@ instead of %@", key, redirect, startPath)
|
|
507
|
+
startPath = redirect
|
|
508
|
+
}
|
|
509
|
+
|
|
461
510
|
/// A silent push or a background refresh is waiting for the app script,
|
|
462
511
|
/// with iOS's clock running: it starts now rather than after a frame,
|
|
463
512
|
/// which a launch in the background never draws.
|
|
@@ -2001,7 +2001,15 @@ enum CraftSnapshots {
|
|
|
2001
2001
|
}
|
|
2002
2002
|
lock.unlock()
|
|
2003
2003
|
DispatchQueue.main.async {
|
|
2004
|
-
|
|
2004
|
+
// Posted whether or not the write landed. A screen displays its
|
|
2005
|
+
// checkpoint optimistically, and this notification carries only
|
|
2006
|
+
// the name, so a reader answers it by calling `read` — which
|
|
2007
|
+
// after a failure finds the previous value, because `pending`
|
|
2008
|
+
// was cleared above and the cache was only refilled on success.
|
|
2009
|
+
// Posting only on success left a failed checkpoint on screen as
|
|
2010
|
+
// though it had been kept; Android already restores it
|
|
2011
|
+
// (`changed("snapshots", name, if (ok) json else previous, null)`).
|
|
2012
|
+
NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name])
|
|
2005
2013
|
completion?(ok)
|
|
2006
2014
|
}
|
|
2007
2015
|
}
|
|
@@ -12,6 +12,7 @@ import OSLog
|
|
|
12
12
|
import Security
|
|
13
13
|
import SwiftUI
|
|
14
14
|
import UIKit
|
|
15
|
+
import UIKit.UIGestureRecognizerSubclass
|
|
15
16
|
|
|
16
17
|
/// A menu belongs to its native view, not to coordinates in a web page.
|
|
17
18
|
final class CraftNativeContextMenu: NSObject, UIContextMenuInteractionDelegate {
|
|
@@ -5195,6 +5196,23 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
5195
5196
|
/// Native inspection of an SVG plot. Coordinates are fractions of the plot's
|
|
5196
5197
|
/// bounds; labels and values are already localized by the application. No JS
|
|
5197
5198
|
/// callback or bridge round trip is needed while a finger moves.
|
|
5199
|
+
/// Reports where each touch starts and then fails, so it observes a window
|
|
5200
|
+
/// without ever taking a touch from the view underneath.
|
|
5201
|
+
final class CraftTouchBeganRecognizer: UIGestureRecognizer {
|
|
5202
|
+
private let began: (CGPoint) -> Void
|
|
5203
|
+
init(began: @escaping (CGPoint) -> Void) {
|
|
5204
|
+
self.began = began
|
|
5205
|
+
super.init(target: nil, action: nil)
|
|
5206
|
+
cancelsTouchesInView = false
|
|
5207
|
+
delaysTouchesBegan = false
|
|
5208
|
+
delaysTouchesEnded = false
|
|
5209
|
+
}
|
|
5210
|
+
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
|
5211
|
+
state = .failed
|
|
5212
|
+
if let touch = touches.first { began(touch.location(in: view)) }
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
|
|
5198
5216
|
final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
5199
5217
|
struct Reading {
|
|
5200
5218
|
let key: String
|
|
@@ -5214,6 +5232,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5214
5232
|
private lazy var tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
|
|
5215
5233
|
private lazy var pan = UIPanGestureRecognizer(target: self, action: #selector(panned(_:)))
|
|
5216
5234
|
private lazy var hold = UILongPressGestureRecognizer(target: self, action: #selector(held(_:)))
|
|
5235
|
+
private var outside: CraftTouchBeganRecognizer?
|
|
5217
5236
|
|
|
5218
5237
|
init(view: UIView) {
|
|
5219
5238
|
self.view = view
|
|
@@ -5241,6 +5260,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5241
5260
|
color: (point["color"] as? String).flatMap { UIColor(css: $0) } ?? .systemBlue, label: label, rows: rows)
|
|
5242
5261
|
}
|
|
5243
5262
|
selectedIndex = -1
|
|
5263
|
+
unwatchOutside()
|
|
5244
5264
|
view?.isUserInteractionEnabled = !readings.isEmpty
|
|
5245
5265
|
if readings.isEmpty { view?.accessibilityTraits.remove(.adjustable); view?.accessibilityValue = nil }
|
|
5246
5266
|
[tap, pan, hold].forEach { $0.isEnabled = !readings.isEmpty }
|
|
@@ -5270,6 +5290,7 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5270
5290
|
}
|
|
5271
5291
|
|
|
5272
5292
|
@discardableResult func dismiss() -> Bool {
|
|
5293
|
+
unwatchOutside()
|
|
5273
5294
|
guard selectedIndex >= 0 else { return false }
|
|
5274
5295
|
selectedIndex = -1
|
|
5275
5296
|
configureAccessibility()
|
|
@@ -5277,20 +5298,47 @@ final class CraftChartInspection: NSObject, UIGestureRecognizerDelegate {
|
|
|
5277
5298
|
return true
|
|
5278
5299
|
}
|
|
5279
5300
|
|
|
5280
|
-
private func
|
|
5281
|
-
guard let view, view.bounds.width > 0 else { return }
|
|
5301
|
+
private func nearest(at point: CGPoint) -> Int? {
|
|
5302
|
+
guard let view, view.bounds.width > 0 else { return nil }
|
|
5282
5303
|
let fraction = max(0, min(1, point.x / view.bounds.width))
|
|
5283
|
-
|
|
5304
|
+
return readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) })
|
|
5305
|
+
}
|
|
5306
|
+
private func select(at point: CGPoint) {
|
|
5307
|
+
if let index = nearest(at: point) { select(index); watchOutside() }
|
|
5308
|
+
}
|
|
5309
|
+
/// A tap pins the reading under it. Tapping the pinned reading again puts
|
|
5310
|
+
/// it away, so a reading can always be closed where it was opened.
|
|
5311
|
+
func tap(at point: CGPoint) {
|
|
5312
|
+
guard let index = nearest(at: point) else { return }
|
|
5313
|
+
if index == selectedIndex { dismiss() } else { select(at: point) }
|
|
5314
|
+
}
|
|
5315
|
+
/// A drag or a hold reads the chart only while the finger is down.
|
|
5316
|
+
func scrub(_ state: UIGestureRecognizer.State, at point: CGPoint) {
|
|
5317
|
+
switch state {
|
|
5318
|
+
case .began, .changed: select(at: point)
|
|
5319
|
+
case .ended, .cancelled, .failed: dismiss()
|
|
5320
|
+
default: break
|
|
5321
|
+
}
|
|
5284
5322
|
}
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5323
|
+
/// A touch that starts anywhere off the chart closes a pinned reading. The
|
|
5324
|
+
/// watcher never recognises, so the touch still reaches what it landed on.
|
|
5325
|
+
func touchBegan(inWindow point: CGPoint) {
|
|
5326
|
+
guard let view, let window = view.window else { return }
|
|
5327
|
+
if !view.bounds.contains(view.convert(point, from: window)) { dismiss() }
|
|
5289
5328
|
}
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5329
|
+
private func watchOutside() {
|
|
5330
|
+
guard outside == nil, let window = view?.window else { return }
|
|
5331
|
+
let watcher = CraftTouchBeganRecognizer { [weak self] point in self?.touchBegan(inWindow: point) }
|
|
5332
|
+
window.addGestureRecognizer(watcher)
|
|
5333
|
+
outside = watcher
|
|
5293
5334
|
}
|
|
5335
|
+
private func unwatchOutside() {
|
|
5336
|
+
if let outside { outside.view?.removeGestureRecognizer(outside) }
|
|
5337
|
+
outside = nil
|
|
5338
|
+
}
|
|
5339
|
+
@objc private func tapped(_ gesture: UITapGestureRecognizer) { tap(at: gesture.location(in: view)) }
|
|
5340
|
+
@objc private func panned(_ gesture: UIPanGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
|
|
5341
|
+
@objc private func held(_ gesture: UILongPressGestureRecognizer) { scrub(gesture.state, at: gesture.location(in: view)) }
|
|
5294
5342
|
func gestureRecognizerShouldBegin(_ gesture: UIGestureRecognizer) -> Bool {
|
|
5295
5343
|
guard gesture === pan else { return true }
|
|
5296
5344
|
let velocity = pan.velocity(in: view)
|
|
@@ -6002,9 +6050,23 @@ final class CraftNativeSolidSwitch: UIControl {
|
|
|
6002
6050
|
return CGRect(x: min(max(x, track.minX), track.maxX - rect.width - stretch),
|
|
6003
6051
|
y: rect.minY, width: rect.width + stretch, height: rect.height)
|
|
6004
6052
|
}
|
|
6053
|
+
/// How much wider than tall the thumb is drawn.
|
|
6054
|
+
///
|
|
6055
|
+
/// iOS 26 draws a wide pill where every system before it drew a circle,
|
|
6056
|
+
/// so a square thumb reads as a control from an older OS sitting among
|
|
6057
|
+
/// native ones. Measured on iOS 26.5 (iPhone 17 Pro simulator) by walking
|
|
6058
|
+
/// a real `UISwitch`: the control is 63x28 and its thumb 37x24, inset 2 on
|
|
6059
|
+
/// every side, travelling from x=2 to x=24.
|
|
6060
|
+
///
|
|
6061
|
+
/// Expressed against the thumb's own height rather than as a fixed size,
|
|
6062
|
+
/// so it keeps following whatever the real `UISwitch` reports — which is
|
|
6063
|
+
/// where `naturalSize` already comes from. At the system's own 24-point
|
|
6064
|
+
/// thumb height this is exactly 37.
|
|
6065
|
+
private static let thumbAspect: CGFloat = 37.0 / 24.0
|
|
6005
6066
|
private func thumbRect(at position: CGFloat) -> CGRect {
|
|
6006
6067
|
let track = trackRect.insetBy(dx: 2, dy: 2)
|
|
6007
|
-
|
|
6068
|
+
let width = min(track.width, track.height * Self.thumbAspect)
|
|
6069
|
+
return CGRect(x: track.minX + (track.width - width) * position, y: track.minY, width: width, height: track.height)
|
|
6008
6070
|
}
|
|
6009
6071
|
override func draw(_ rect: CGRect) {
|
|
6010
6072
|
let track = trackRect
|
|
@@ -6341,12 +6403,37 @@ private final class CraftNativeTextView: UITextView {
|
|
|
6341
6403
|
if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
|
|
6342
6404
|
let inset = textContainerInset
|
|
6343
6405
|
let padding = textContainer.lineFragmentPadding
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6406
|
+
let measured = placeholderLabel
|
|
6407
|
+
.sizeThatFits(CGSize(width: Self.placeholderWidth(bounds: bounds, inset: inset, padding: padding),
|
|
6408
|
+
height: .greatestFiniteMagnitude))
|
|
6409
|
+
placeholderLabel.frame = Self.placeholderFrame(bounds: bounds, inset: inset, padding: padding,
|
|
6410
|
+
fittedHeight: measured.height)
|
|
6411
|
+
}
|
|
6412
|
+
|
|
6413
|
+
static func placeholderWidth(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat) -> CGFloat {
|
|
6414
|
+
max(0, bounds.width - inset.left - inset.right - padding * 2)
|
|
6415
|
+
}
|
|
6416
|
+
|
|
6417
|
+
/// Where the placeholder sits inside the box.
|
|
6418
|
+
///
|
|
6419
|
+
/// Anchored at the top, and no taller than the text it holds. `UILabel`
|
|
6420
|
+
/// centres its text block vertically within its frame, while
|
|
6421
|
+
/// `UITextView` lays its glyphs out from the top, so a label handed the
|
|
6422
|
+
/// box's whole remaining height drew the placeholder of a four-row notes
|
|
6423
|
+
/// field halfway down it — nowhere near the caret that replaced it the
|
|
6424
|
+
/// moment the field was tapped (#501). The two rules only agreed when
|
|
6425
|
+
/// the box was exactly one line tall, which is why single-line fields
|
|
6426
|
+
/// looked right and every `textarea` did not. Given only the height its
|
|
6427
|
+
/// text needs, the label has no spare height to centre within.
|
|
6428
|
+
///
|
|
6429
|
+
/// Clamped to what is left of the box so that a placeholder longer than
|
|
6430
|
+
/// the field stays inside it rather than drawing past the bottom.
|
|
6431
|
+
static func placeholderFrame(bounds: CGRect, inset: UIEdgeInsets, padding: CGFloat, fittedHeight: CGFloat) -> CGRect {
|
|
6432
|
+
let available = max(0, bounds.height - inset.top - inset.bottom)
|
|
6433
|
+
return CGRect(x: inset.left + padding,
|
|
6434
|
+
y: inset.top,
|
|
6435
|
+
width: placeholderWidth(bounds: bounds, inset: inset, padding: padding),
|
|
6436
|
+
height: min(max(0, fittedHeight), available))
|
|
6350
6437
|
}
|
|
6351
6438
|
|
|
6352
6439
|
func refreshPlaceholder() {
|
|
@@ -7037,6 +7124,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7037
7124
|
|
|
7038
7125
|
override func viewWillDisappear(_ animated: Bool) {
|
|
7039
7126
|
super.viewWillDisappear(animated)
|
|
7127
|
+
// A screen does not take the keyboard with it. Its field is still
|
|
7128
|
+
// first responder at this point, and once the view leaves the window
|
|
7129
|
+
// the next screen's `window?.endEditing` no longer reaches it — the
|
|
7130
|
+
// keyboard then stays up with nothing able to dismiss it until the
|
|
7131
|
+
// app is backgrounded (#497). This is the one moment both facts hold:
|
|
7132
|
+
// the screen is going, and its view still owns the responder.
|
|
7133
|
+
//
|
|
7134
|
+
// `lastFocusedInput` is deliberately left alone. A screen merely
|
|
7135
|
+
// covered by a push comes back, and restoreFocus putting the person
|
|
7136
|
+
// back where they were is the behaviour that was already there.
|
|
7137
|
+
view.endEditing(true)
|
|
7040
7138
|
screenVisibilityChanged(false)
|
|
7041
7139
|
stopCelebrations(in: view)
|
|
7042
7140
|
// Release only this screen's accessory. A retained or prepared screen
|
|
@@ -7045,7 +7143,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7045
7143
|
CraftChrome.shared.releaseTabAccessory(ownedBy: self)
|
|
7046
7144
|
// Popped (or its stack dismissed) while the bar was away: it comes
|
|
7047
7145
|
// back for whatever shows next, a web page included.
|
|
7048
|
-
if
|
|
7146
|
+
if tabBarHiddenApplied, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
|
|
7049
7147
|
CraftChrome.shared.setHiddenByScreen(false)
|
|
7050
7148
|
}
|
|
7051
7149
|
}
|
|
@@ -7087,7 +7185,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7087
7185
|
|
|
7088
7186
|
private func restoreShellChrome() {
|
|
7089
7187
|
guard !isAppScript else { return }
|
|
7090
|
-
|
|
7188
|
+
applyTabBarHidden()
|
|
7091
7189
|
// A screen not yet asked about the accessory (one shown for the first
|
|
7092
7190
|
// time, before its script has run) leaves the bar as it is; one that
|
|
7093
7191
|
// has drawn without asking for it takes off what nobody claims.
|
|
@@ -7183,6 +7281,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7183
7281
|
private func disposeRuntime() {
|
|
7184
7282
|
guard !runtimeDisposed else { return }
|
|
7185
7283
|
runtimeDisposed = true
|
|
7284
|
+
// Again here, and forgetting the input this time: a screen can be torn
|
|
7285
|
+
// down without ever appearing (one prepared and then replaced), so
|
|
7286
|
+
// viewWillDisappear is not guaranteed to have run. Nothing comes back
|
|
7287
|
+
// from here, so there is no focus worth restoring.
|
|
7288
|
+
//
|
|
7289
|
+
// `viewIfLoaded`, never `view`: this also runs from `deinit`, where
|
|
7290
|
+
// `view` is a `UIView!` that UIKit has already cleared — reading it
|
|
7291
|
+
// traps on the implicit unwrap, and a screen that never appeared
|
|
7292
|
+
// would instead have `loadView` resurrect `self` while it is being
|
|
7293
|
+
// deallocated. A screen with no loaded view has nothing focused.
|
|
7294
|
+
forgetFocusedInput()
|
|
7295
|
+
viewIfLoaded?.endEditing(true)
|
|
7186
7296
|
frameDelivery.close()
|
|
7187
7297
|
storageLifetime.close()
|
|
7188
7298
|
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
@@ -7658,7 +7768,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7658
7768
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
7659
7769
|
/// `craft.secureStorage.set` wrote.
|
|
7660
7770
|
/// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
|
|
7661
|
-
/// backTitle, tabBarHidden, tabItem, swipeBack,
|
|
7771
|
+
/// backTitle, tabBarHidden, tabBarHiddenWhenPushed, tabItem, swipeBack,
|
|
7772
|
+
/// contentSwipeBack,
|
|
7662
7773
|
/// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
|
|
7663
7774
|
/// rightButtons, tabAccessory })`; a button's tap is the event
|
|
7664
7775
|
/// `navButton`. `craft.d.ts` types no navigation surface, so this list
|
|
@@ -8125,6 +8236,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8125
8236
|
private var statusBarHiddenOverride: Bool?
|
|
8126
8237
|
/// The app's tab bar away while this screen shows (`tabBarHidden`).
|
|
8127
8238
|
private var tabBarHidden = false
|
|
8239
|
+
/// The bar away only once this screen is pushed onto something
|
|
8240
|
+
/// (`tabBarHiddenWhenPushed`): UIKit's `hidesBottomBarWhenPushed` idiom,
|
|
8241
|
+
/// for a screen that keeps the bar as its stack's root and drops it when
|
|
8242
|
+
/// it is a detail. Craft's tab bar is drawn by CraftChrome rather than a
|
|
8243
|
+
/// UITabBarController, so the depth is read here and the bar is asked
|
|
8244
|
+
/// directly; the Android shell has honoured the same key all along.
|
|
8245
|
+
private var tabBarHiddenWhenPushed = false
|
|
8246
|
+
/// What this screen last told the chrome. Teardown has to put back a bar
|
|
8247
|
+
/// this screen took away, and recomputing the answer there is no good:
|
|
8248
|
+
/// UIKit has already taken a popped screen out of `viewControllers` by
|
|
8249
|
+
/// `viewWillDisappear`, so a bar hidden for depth would read as never
|
|
8250
|
+
/// hidden at all and stay off for whatever shows next.
|
|
8251
|
+
private var tabBarHiddenApplied = false
|
|
8252
|
+
|
|
8253
|
+
/// Whether the bar should be away for this screen as it stands now.
|
|
8254
|
+
private var wantsTabBarHidden: Bool {
|
|
8255
|
+
if tabBarHidden { return true }
|
|
8256
|
+
guard tabBarHiddenWhenPushed, let stack = navigationController?.viewControllers else { return false }
|
|
8257
|
+
guard let depth = stack.firstIndex(where: { $0 === self }) else { return false }
|
|
8258
|
+
return depth > 0
|
|
8259
|
+
}
|
|
8260
|
+
|
|
8261
|
+
private func applyTabBarHidden() {
|
|
8262
|
+
tabBarHiddenApplied = wantsTabBarHidden
|
|
8263
|
+
CraftChrome.shared.setHiddenByScreen(tabBarHiddenApplied)
|
|
8264
|
+
}
|
|
8128
8265
|
private var tabAccessory: [String: Any]?
|
|
8129
8266
|
/// Whether the screen has said anything about the accessory yet
|
|
8130
8267
|
/// (`tabAccessory`, null included): until it has, the bar keeps showing
|
|
@@ -8210,8 +8347,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8210
8347
|
}
|
|
8211
8348
|
if let hidden = options["tabBarHidden"] as? Bool {
|
|
8212
8349
|
tabBarHidden = hidden
|
|
8350
|
+
}
|
|
8351
|
+
if let hiddenWhenPushed = options["tabBarHiddenWhenPushed"] as? Bool {
|
|
8352
|
+
tabBarHiddenWhenPushed = hiddenWhenPushed
|
|
8353
|
+
}
|
|
8354
|
+
if options.keys.contains("tabBarHidden") || options.keys.contains("tabBarHiddenWhenPushed") {
|
|
8355
|
+
// Prepared tabs and screens under a push keep the answer and apply
|
|
8356
|
+
// it when they are shown (restoreShellChrome); only the screen on
|
|
8357
|
+
// display may move the shared bar.
|
|
8213
8358
|
if navigationController?.topViewController === self, isShownOnScreen {
|
|
8214
|
-
|
|
8359
|
+
applyTabBarHidden()
|
|
8215
8360
|
}
|
|
8216
8361
|
}
|
|
8217
8362
|
if let swipeBack = options["swipeBack"] as? Bool {
|
|
@@ -8324,6 +8469,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8324
8469
|
|
|
8325
8470
|
/// A bar button from `{ id, symbol | title }`. Its tap is the event
|
|
8326
8471
|
/// `navButton` with `{ id }`.
|
|
8472
|
+
/// A bar button's choices, or nil when it has none worth a menu.
|
|
8473
|
+
static func navigationMenu(_ raw: Any?, chosen: @escaping (String) -> Void) -> UIMenu? {
|
|
8474
|
+
let actions: [UIAction] = ((raw as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
|
|
8475
|
+
guard let id = choice["id"] as? String, !id.isEmpty,
|
|
8476
|
+
let title = choice["title"] as? String, !title.isEmpty else { return nil }
|
|
8477
|
+
var attributes: UIMenuElement.Attributes = []
|
|
8478
|
+
if choice["disabled"] as? Bool == true { attributes.insert(.disabled) }
|
|
8479
|
+
if choice["destructive"] as? Bool == true { attributes.insert(.destructive) }
|
|
8480
|
+
let action = UIAction(title: title, image: (choice["symbol"] as? String).flatMap { UIImage(systemName: $0) },
|
|
8481
|
+
attributes: attributes) { _ in chosen(id) }
|
|
8482
|
+
action.accessibilityIdentifier = "nav-menu-\(id)"
|
|
8483
|
+
return action
|
|
8484
|
+
}
|
|
8485
|
+
return actions.isEmpty ? nil : UIMenu(children: actions)
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8327
8488
|
private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
|
|
8328
8489
|
guard let id = spec["id"] as? String, !id.isEmpty else { return nil }
|
|
8329
8490
|
let action = UIAction { [weak self] _ in
|
|
@@ -8332,11 +8493,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8332
8493
|
if spec["variant"] as? String == "accent" || spec["variant"] as? String == "account" {
|
|
8333
8494
|
return accountNavigationButton(spec, action: action)
|
|
8334
8495
|
}
|
|
8496
|
+
// `menu`: the button opens a native menu instead of sending its own
|
|
8497
|
+
// id, and each choice is reported as a press of that choice's id.
|
|
8498
|
+
let menu = Self.navigationMenu(spec["menu"]) { [weak self] choice in
|
|
8499
|
+
self?.send(type: "EVENT", payload: ["handlerName": "navButton", "nativeEvent": ["id": choice]])
|
|
8500
|
+
}
|
|
8335
8501
|
let item: UIBarButtonItem
|
|
8336
8502
|
if let symbol = spec["symbol"] as? String, let image = UIImage(systemName: symbol) {
|
|
8337
|
-
item = UIBarButtonItem(image: image, primaryAction: action)
|
|
8503
|
+
item = menu.map { UIBarButtonItem(image: image, menu: $0) } ?? UIBarButtonItem(image: image, primaryAction: action)
|
|
8338
8504
|
} else if let title = spec["title"] as? String {
|
|
8339
|
-
item = UIBarButtonItem(title: title, primaryAction: action)
|
|
8505
|
+
item = menu.map { UIBarButtonItem(title: title, menu: $0) } ?? UIBarButtonItem(title: title, primaryAction: action)
|
|
8340
8506
|
} else {
|
|
8341
8507
|
return nil
|
|
8342
8508
|
}
|
|
@@ -8927,14 +9093,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8927
9093
|
])
|
|
8928
9094
|
}
|
|
8929
9095
|
case "NAVIGATE", "NAVIGATE_REPLACE":
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
9096
|
+
guard let screen = payload["screen"] as? String, !screen.isEmpty else { return }
|
|
9097
|
+
// Replacing a path needs no stack of its own: it asks the shell to
|
|
9098
|
+
// show that path, which is why it delegates rather than pushing.
|
|
9099
|
+
// The app script has no stack, so this was refused to it along
|
|
9100
|
+
// with everything else, and its only way to redirect was to push
|
|
9101
|
+
// sign-in on top of the screen it meant to prevent (#499). The
|
|
9102
|
+
// gate in CraftHybrid.configure is the fix for launch; this is
|
|
9103
|
+
// what lets a session that ends mid-run replace rather than stack.
|
|
8934
9104
|
if type == "NAVIGATE_REPLACE", screen.hasPrefix("/") {
|
|
9105
|
+
guard isAppScript || navigationController?.topViewController === self else { return }
|
|
8935
9106
|
hybridEvents?.nativeScreen(self, replace: screen)
|
|
8936
9107
|
return
|
|
8937
9108
|
}
|
|
9109
|
+
// A screen's own stack; the app script has none (it opens paths).
|
|
9110
|
+
guard !isAppScript, navigationController?.topViewController === self,
|
|
9111
|
+
let navigation = navigationController else { return }
|
|
8938
9112
|
let params = payload["params"] as? [String: Any] ?? [:]
|
|
8939
9113
|
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
8940
9114
|
next.hybridEvents = hybridEvents
|
|
@@ -9376,7 +9550,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9376
9550
|
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
9377
9551
|
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
9378
9552
|
indexPreviousNodes(previous)
|
|
9379
|
-
|
|
9553
|
+
// The app's configured body face seeds the inheritance chain, so a
|
|
9554
|
+
// screen's text is branded for the same reason its chrome is. Without
|
|
9555
|
+
// this the chain starts empty and `fontFamily` reached the navigation
|
|
9556
|
+
// bar, the tab labels and the bar buttons — all UIAppearance proxies —
|
|
9557
|
+
// while every label, button and field inside a screen stayed in the
|
|
9558
|
+
// system face (#500).
|
|
9559
|
+
//
|
|
9560
|
+
// A node that sets its own family still wins: the fill below only
|
|
9561
|
+
// applies where a node set none or `inherit`. Nil unless the app
|
|
9562
|
+
// configured one, which the same guard makes free.
|
|
9563
|
+
let next = reconcile(document, identity: "root", path: "root", previous: previous,
|
|
9564
|
+
inheritedFontFamily: CraftChromeFonts.body)
|
|
9380
9565
|
previousNodesByProtocolID.removeAll(keepingCapacity: true)
|
|
9381
9566
|
adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
|
|
9382
9567
|
if previous?.view !== next.view {
|
package/dist/mobile.js
CHANGED
|
@@ -1510,7 +1510,7 @@ function postHybrid(message) {
|
|
|
1510
1510
|
}
|
|
1511
1511
|
}
|
|
1512
1512
|
function isSnapshotName(name) {
|
|
1513
|
-
return typeof name === "string" && /^[A-Za-z0-9_
|
|
1513
|
+
return typeof name === "string" && /^[A-Za-z0-9_:-][\w.:-]{0,127}$/.test(name);
|
|
1514
1514
|
}
|
|
1515
1515
|
var snapshots = {
|
|
1516
1516
|
isAvailable() {
|
|
@@ -1518,7 +1518,7 @@ var snapshots = {
|
|
|
1518
1518
|
},
|
|
1519
1519
|
set(name, value) {
|
|
1520
1520
|
if (!isSnapshotName(name))
|
|
1521
|
-
throw new TypeError(`Snapshot names are letters, digits, ".", "_" and "
|
|
1521
|
+
throw new TypeError(`Snapshot names are letters, digits, ".", "_", "-" and ":": ${String(name)}`);
|
|
1522
1522
|
const json = JSON.stringify(value);
|
|
1523
1523
|
if (json === undefined)
|
|
1524
1524
|
throw new TypeError(`Snapshot ${name} is not JSON`);
|