craft-native 0.0.487 → 0.0.491

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) {
@@ -416,6 +425,13 @@ internal class CraftNativeStores private constructor(
416
425
  main.post { done(false) }
417
426
  return
418
427
  }
428
+ // UIKit refuses a payload that is not JSON before it touches the
429
+ // disk, and a checkpoint kept on one host and refused on the other is
430
+ // the divergence this contract exists to prevent (#396).
431
+ if (json != null && !craftNativeIsJsonText(json)) {
432
+ main.post { done(false) }
433
+ return
434
+ }
419
435
  writer.execute {
420
436
  val file = snapshotFile(name)
421
437
  val previous = snapshotCache[name]
@@ -465,7 +481,19 @@ internal class CraftNativeStores private constructor(
465
481
  CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from)
466
482
  }
467
483
 
468
- private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}"))
484
+ /**
485
+ * The same names UIKit accepts, so one checkpoint key is not valid on one
486
+ * host and refused on the other.
487
+ *
488
+ * `CraftSnapshots.isValidName` allows ASCII alphanumerics and `-_.:`, up
489
+ * to 128 characters, and rejects only a leading dot. The `:` is deliberate
490
+ * there — its own comment calls it the account namespace — so a key like
491
+ * `account:slot` wrote on iOS and came back `false` on Android.
492
+ *
493
+ * A colon never reaches the filesystem: `snapshotFile` percent-encodes the
494
+ * name, and `snapshotName` decodes it back.
495
+ */
496
+ private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_:-][A-Za-z0-9_.:-]{0,127}"))
469
497
 
470
498
  private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json")
471
499
 
@@ -495,6 +523,32 @@ internal object CraftNativePrelude {
495
523
  */
496
524
  fun sessionTokenKey(config: JSONObject): String = config.optString("sessionTokenKey").ifBlank { "auth.token" }
497
525
 
526
+ /**
527
+ * Whether `path` may be shown to someone who is not signed in.
528
+ *
529
+ * Listed, or under something listed: `/m/store` admits `/m/store/42` and
530
+ * not `/m/storefront`. The separator is attached before comparing so a
531
+ * listed path cannot admit a sibling that merely starts with its letters.
532
+ *
533
+ * A literal mirror of `CraftHybrid.gateAdmits` in
534
+ * `packages/ios/templates/CraftHybrid.swift`, down to the trailing-slash
535
+ * trimming, because the two hosts must admit exactly the same paths from
536
+ * the same `publicPaths` list. `native-gate.test.ts` runs both against one
537
+ * table of cases.
538
+ */
539
+ fun gateAdmits(path: String, allowed: List<String>): Boolean = allowed.any { entry ->
540
+ var listed = entry
541
+ while (listed.length > 1 && listed.endsWith("/")) listed = listed.dropLast(1)
542
+ when {
543
+ listed.isEmpty() -> false
544
+ path == listed -> true
545
+ // "/" is the root path, not a prefix of everything. Reading it as
546
+ // one would admit every path, which is a gate that is not a gate.
547
+ listed == "/" -> false
548
+ else -> path.startsWith("$listed/")
549
+ }
550
+ }
551
+
498
552
  fun script(
499
553
  bridgeFields: String,
500
554
  post: String,
@@ -632,7 +686,12 @@ internal object CraftNativePrelude {
632
686
  if (message && message.type === 'FRAME_FIRE') {
633
687
  var due = frames;
634
688
  frames = [];
635
- var time = message.payload && message.payload.time || Date.now();
689
+ // A real first frame is 0, which `||` would replace with a Unix
690
+ // epoch value twelve orders of magnitude away. Only a missing or
691
+ // non-finite time falls back, and it falls back to the same
692
+ // origin-relative scale rather than to wall-clock.
693
+ var reported = message.payload && message.payload.time;
694
+ var time = typeof reported === 'number' && isFinite(reported) ? reported : 0;
636
695
  due.forEach(function(entry) {
637
696
  if (cancelledFrames[entry.id]) { delete cancelledFrames[entry.id]; return; }
638
697
  try { entry.fn(time); } catch (error) { console.error('[frame] ' + (error && error.stack || error)); }
@@ -704,8 +763,11 @@ internal object CraftNativePrelude {
704
763
  if (json === null || json === undefined) return null;
705
764
  try { return JSON.parse(json); } catch (error) { return null; }
706
765
  }
766
+ // The same names the host accepts, including the `:` UIKit calls the
767
+ // account namespace. This gate runs inside the isolate, so a stricter
768
+ // rule here refuses a key the host would have kept.
707
769
  function validSnapshotName(name) {
708
- return /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name);
770
+ return /^[A-Za-z0-9_:-][A-Za-z0-9_.:-]{0,127}$/.test(name);
709
771
  }
710
772
  var craft = globalThis.craft = globalThis.craft || {};
711
773
  function keep(name, extras) {
@@ -735,7 +797,7 @@ internal object CraftNativePrelude {
735
797
  },
736
798
  set: function(name, value) {
737
799
  name = String(name);
738
- if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_" and "-"'));
800
+ if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_", "-" and ":"'));
739
801
  var json = value === null ? null : JSON.stringify(value);
740
802
  if (json === undefined) return Promise.reject(new TypeError('Snapshot ' + name + ' is not JSON'));
741
803
  if (json === null) delete sync.snapshots[name];
@@ -824,9 +886,24 @@ internal object CraftNativePrelude {
824
886
 
825
887
  /**
826
888
  * 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`.
889
+ * below it, in plain Android views. The tabs come from the app's `tabs`; a
890
+ * screen sets its own chrome with
891
+ *
892
+ * `craft.navigation.setOptions({ title, largeTitle, titleColor, hidden,
893
+ * backgroundColor, leftButtons, rightButtons, tabItem, tabAccessory,
894
+ * tabBarHidden, tabBarHiddenWhenPushed, tabSwipeEnabled })`
895
+ *
896
+ * which `apply` below reads. That list is checked against `apply` itself
897
+ * rather than kept by hand, and only the list is: the iOS comment named
898
+ * twelve keys while fifteen shipped (#489), and a first attempt at the same
899
+ * ratchet there passed with keys deleted because the prose underneath
900
+ * happened to mention them. Six of these twelve had never been written down.
901
+ *
902
+ * `tabBarHiddenWhenPushed` was honoured here and nowhere else in Craft until
903
+ * #493: hiding the tab bar on push is the ordinary iOS idiom, so a caller who
904
+ * set it got the behaviour on one host and silence on the other. UIKit reads
905
+ * the same key now, at the same depth, through CraftNativeScreen's
906
+ * `wantsTabBarHidden`.
830
907
  */
831
908
  internal class CraftNativeChrome(
832
909
  context: Context,
@@ -1316,7 +1393,25 @@ internal class CraftNativeChrome(
1316
1393
  if (badge > 0) {
1317
1394
  view.text = "${view.text}${if (button.optString("badgeStyle") == "dot") " •" else " $badge"}"
1318
1395
  }
1319
- view.setOnClickListener { onButton?.invoke(id) }
1396
+ // `menu`: the button opens a popup instead of reporting its own id, and
1397
+ // each choice is reported as a press of that choice's id.
1398
+ val choices = button.optJSONArray("menu")
1399
+ val menu = (0 until minOf(choices?.length() ?: 0, 12)).mapNotNull { index ->
1400
+ choices?.optJSONObject(index)?.takeIf { it.optString("id").isNotBlank() && it.optString("title").isNotBlank() }
1401
+ }
1402
+ view.setOnClickListener {
1403
+ if (menu.isEmpty()) onButton?.invoke(id)
1404
+ else android.widget.PopupMenu(context, view).apply {
1405
+ menu.forEachIndexed { index, choice ->
1406
+ this.menu.add(0, index, index, choice.optString("title")).isEnabled = !choice.optBoolean("disabled")
1407
+ }
1408
+ setOnMenuItemClickListener { selected ->
1409
+ menu.getOrNull(selected.itemId)?.let { onButton?.invoke(it.optString("id")) }
1410
+ true
1411
+ }
1412
+ show()
1413
+ }
1414
+ }
1320
1415
  view.layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
1321
1416
  return view
1322
1417
  }
@@ -2052,3 +2147,182 @@ internal object CraftNativeIcons {
2052
2147
  override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
2053
2148
  }
2054
2149
  }
2150
+
2151
+ /**
2152
+ * Whether `text` is JSON that UIKit's own writer would also accept.
2153
+ *
2154
+ * UIKit refuses a snapshot whose payload is not JSON, before it touches the
2155
+ * disk: `JSONSerialization.jsonObject(with:options: [.fragmentsAllowed])`
2156
+ * returning nil fails the write. Android had no such check, so a payload iOS
2157
+ * rejected was written here and the two hosts disagreed about whether a
2158
+ * checkpoint had been kept (#396).
2159
+ *
2160
+ * The grammar is Foundation's, measured rather than assumed, because it is
2161
+ * not RFC 8259 and a strict parser would have diverged in the other
2162
+ * direction:
2163
+ *
2164
+ * - top-level fragments are allowed (`42`, `"text"`, `null`);
2165
+ * - **one** trailing comma is allowed in an object or array — `{"a":1,}`
2166
+ * and `[1,]` are accepted, `{"a":1,,}` and `[,1]` are not;
2167
+ * - a lone surrogate escape is rejected, a matched pair accepted;
2168
+ * - a raw control character inside a string is rejected, its escape is not;
2169
+ * - `00`, `+1`, `.5`, `1.`, `1.0e`, `NaN` and `Infinity` are all rejected;
2170
+ * - trailing content after the value is rejected, surrounding space is not.
2171
+ *
2172
+ * `org.json` cannot stand in for this: `JSONTokener` accepts unquoted keys,
2173
+ * single quotes and more, so it would admit payloads UIKit refuses.
2174
+ * `android.util.JsonReader` in strict mode refuses top-level fragments that
2175
+ * UIKit accepts. Both are wrong in a direction that matters, which is why
2176
+ * this is scanned here and pinned against Foundation itself by
2177
+ * `packages/android/src/snapshot-json-contract.test.ts`.
2178
+ */
2179
+ private class CraftNativeJsonScanner(private val text: String) {
2180
+ private var at = 0
2181
+
2182
+ fun validate(): Boolean {
2183
+ skipWhitespace()
2184
+ if (!value()) return false
2185
+ skipWhitespace()
2186
+ return at == text.length
2187
+ }
2188
+
2189
+ private fun skipWhitespace() {
2190
+ while (at < text.length && (text[at] == ' ' || text[at] == '\t' || text[at] == '\n' || text[at] == '\r')) at++
2191
+ }
2192
+
2193
+ private fun value(): Boolean {
2194
+ if (at >= text.length) return false
2195
+ return when (text[at]) {
2196
+ '{' -> obj()
2197
+ '[' -> arr()
2198
+ '"' -> string()
2199
+ 't' -> literal("true")
2200
+ 'f' -> literal("false")
2201
+ 'n' -> literal("null")
2202
+ else -> number()
2203
+ }
2204
+ }
2205
+
2206
+ private fun literal(word: String): Boolean {
2207
+ if (!text.startsWith(word, at)) return false
2208
+ at += word.length
2209
+ return true
2210
+ }
2211
+
2212
+ /** After a member, consume the separator. Returns null when malformed. */
2213
+ private fun afterMember(closing: Char): Boolean? {
2214
+ skipWhitespace()
2215
+ if (at >= text.length) return null
2216
+ return when (text[at]) {
2217
+ ',' -> {
2218
+ at++
2219
+ skipWhitespace()
2220
+ // Foundation allows exactly one trailing comma before the
2221
+ // close. A second comma, or a close that never comes, is not.
2222
+ if (at < text.length && text[at] == closing) { at++; true } else false
2223
+ }
2224
+ closing -> { at++; true }
2225
+ else -> null
2226
+ }
2227
+ }
2228
+
2229
+ private fun obj(): Boolean {
2230
+ at++
2231
+ skipWhitespace()
2232
+ if (at < text.length && text[at] == '}') { at++; return true }
2233
+ while (true) {
2234
+ skipWhitespace()
2235
+ if (at >= text.length || text[at] != '"') return false
2236
+ if (!string()) return false
2237
+ skipWhitespace()
2238
+ if (at >= text.length || text[at] != ':') return false
2239
+ at++
2240
+ skipWhitespace()
2241
+ if (!value()) return false
2242
+ val done = afterMember('}') ?: return false
2243
+ if (done) return true
2244
+ }
2245
+ }
2246
+
2247
+ private fun arr(): Boolean {
2248
+ at++
2249
+ skipWhitespace()
2250
+ if (at < text.length && text[at] == ']') { at++; return true }
2251
+ while (true) {
2252
+ skipWhitespace()
2253
+ if (!value()) return false
2254
+ val done = afterMember(']') ?: return false
2255
+ if (done) return true
2256
+ }
2257
+ }
2258
+
2259
+ private fun string(): Boolean {
2260
+ at++
2261
+ var pendingHighSurrogate = false
2262
+ while (at < text.length) {
2263
+ val character = text[at]
2264
+ when {
2265
+ character == '"' -> { at++; return !pendingHighSurrogate }
2266
+ character == '\\' -> {
2267
+ at++
2268
+ if (at >= text.length) return false
2269
+ val escape = text[at]
2270
+ if (escape == 'u') {
2271
+ if (at + 4 >= text.length) return false
2272
+ var code = 0
2273
+ for (offset in 1..4) {
2274
+ val digit = Character.digit(text[at + offset], 16)
2275
+ if (digit < 0) return false
2276
+ code = code * 16 + digit
2277
+ }
2278
+ at += 5
2279
+ val high = code in 0xD800..0xDBFF
2280
+ val low = code in 0xDC00..0xDFFF
2281
+ // A low surrogate is only legal directly after a high
2282
+ // one, and a high one must be followed by a low one.
2283
+ if (pendingHighSurrogate != low) return false
2284
+ pendingHighSurrogate = high
2285
+ continue
2286
+ }
2287
+ if (pendingHighSurrogate) return false
2288
+ if (escape !in listOf('"', '\\', '/', 'b', 'f', 'n', 'r', 't')) return false
2289
+ at++
2290
+ }
2291
+ character.code < 0x20 -> return false
2292
+ else -> {
2293
+ if (pendingHighSurrogate) return false
2294
+ at++
2295
+ }
2296
+ }
2297
+ }
2298
+ return false
2299
+ }
2300
+
2301
+ private fun number(): Boolean {
2302
+ val start = at
2303
+ if (at < text.length && text[at] == '-') at++
2304
+ val integerStart = at
2305
+ while (at < text.length && text[at] in '0'..'9') at++
2306
+ if (at == integerStart) return false
2307
+ // A leading zero may not be followed by another digit.
2308
+ if (text[integerStart] == '0' && at - integerStart > 1) return false
2309
+ if (at < text.length && text[at] == '.') {
2310
+ at++
2311
+ val fractionStart = at
2312
+ while (at < text.length && text[at] in '0'..'9') at++
2313
+ if (at == fractionStart) return false
2314
+ }
2315
+ if (at < text.length && (text[at] == 'e' || text[at] == 'E')) {
2316
+ at++
2317
+ if (at < text.length && (text[at] == '+' || text[at] == '-')) at++
2318
+ val exponentStart = at
2319
+ while (at < text.length && text[at] in '0'..'9') at++
2320
+ if (at == exponentStart) return false
2321
+ }
2322
+ return at > start
2323
+ }
2324
+ }
2325
+
2326
+ /** Whether a snapshot payload is JSON both hosts accept. */
2327
+ private fun craftNativeIsJsonText(text: String): Boolean = CraftNativeJsonScanner(text).validate()
2328
+