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.
- 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 +282 -8
- package/dist/android/templates/MainActivityNative.kt.template +166 -28
- package/dist/cli.js +6 -3
- package/dist/index.cjs +6 -3
- package/dist/index.js +6 -3
- 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/ios/templates/CraftNativeServices.swift +35 -0
- package/dist/ios/templates/CraftVoiceRecording.swift +17 -1
- package/dist/ios/templates/CraftWebAudio.swift +77 -12
- package/dist/ios/templates/testing/DriverTests.swift.template +31 -1
- package/dist/mobile.js +2 -2
- package/dist/package.d.ts +5 -0
- 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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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_
|
|
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.
|
|
828
|
-
*
|
|
829
|
-
*
|
|
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
|
-
|
|
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
|
+
|