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.
@@ -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
- if (!file.exists() || file.length() == 0L || duration <= 0) throw IllegalStateException("Empty recording")
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
- private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}"))
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
- var time = message.payload && message.payload.time || Date.now();
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_-][A-Za-z0-9_.-]{0,127}$/.test(name);
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. A screen sets its bar with
828
- * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
829
- * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
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
- view.setOnClickListener { onButton?.invoke(id) }
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
- selected = -1
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 selectAt(x: Float) {
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) }?.let { select(it) }
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
- if (!abandoned) { view.performClick(); selectAt(event.x) }
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; selected = -1
463
- view.parent?.requestDisallowInterceptTouchEvent(false); view.invalidate()
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); selected = -1 }
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 -> { selected = -1; view.invalidate() }
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
- selectTab(routes.tabFor(launch ?: routes.initialPath)?.let { routes.tabs.indexOf(it) } ?: 0)
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(routes.initialPath)
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
- send(screen, "FRAME_FIRE", JSONObject().put("time", frameTime / 1_000_000.0))
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
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()
7140
- .put("contentOffset", JSONObject().put("x", scrollX).put("y", scrollY))
7141
- .put("contentSize", JSONObject().put("width", contentView.width).put("height", contentView.height))
7142
- .put("layoutMeasurement", JSONObject().put("width", nativeScroll.width).put("height", nativeScroll.height))))
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 = JSONObject()
7480
- .put("contentOffset", JSONObject()
7481
- .put("x", list.computeHorizontalScrollOffset())
7482
- .put("y", list.computeVerticalScrollOffset()))
7483
- .put("contentSize", JSONObject()
7484
- .put("width", list.computeHorizontalScrollRange())
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.487";
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
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.487";
379
+ var version = "0.0.490";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.487";
120
+ var version = "0.0.490";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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
@@ -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
- if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
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 select(at point: CGPoint) {
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
- if let nearest = readings.indices.min(by: { abs(readings[$0].x - fraction) < abs(readings[$1].x - fraction) }) { select(nearest) }
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
- @objc private func tapped(_ gesture: UITapGestureRecognizer) { select(at: gesture.location(in: view)) }
5286
- @objc private func panned(_ gesture: UIPanGestureRecognizer) {
5287
- if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5288
- if gesture.state == .cancelled { dismiss() }
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
- @objc private func held(_ gesture: UILongPressGestureRecognizer) {
5291
- if gesture.state == .began || gesture.state == .changed { select(at: gesture.location(in: view)) }
5292
- if gesture.state == .cancelled { dismiss() }
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
- return CGRect(x: track.minX + (track.width - track.height) * position, y: track.minY, width: track.height, height: track.height)
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
- placeholderLabel.frame = CGRect(
6345
- x: inset.left + padding,
6346
- y: inset.top,
6347
- width: max(0, bounds.width - inset.left - inset.right - padding * 2),
6348
- height: max(0, bounds.height - inset.top - inset.bottom)
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 tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
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
- CraftChrome.shared.setHiddenByScreen(tabBarHidden)
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, contentSwipeBack,
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
- CraftChrome.shared.setHiddenByScreen(hidden)
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
- // A screen's own stack; the app script has none (it opens paths).
8931
- guard !isAppScript, navigationController?.topViewController === self,
8932
- let screen = payload["screen"] as? String, !screen.isEmpty,
8933
- let navigation = navigationController else { return }
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
- let next = reconcile(document, identity: "root", path: "root", previous: previous)
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_-][\w.-]{0,127}$/.test(name);
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 "-": ${String(name)}`);
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.487",
3
+ "version": "0.0.490",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",