craft-native 0.0.490 → 0.0.492
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/testing.d.ts +7 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +132 -19
- package/dist/android/templates/CraftNativeShell.kt.template +328 -39
- package/dist/android/templates/CraftNativeSounds.kt.template +25 -2
- package/dist/android/templates/MainActivityNative.kt.template +23 -0
- package/dist/cli.js +6 -3
- package/dist/index.cjs +6 -3
- package/dist/index.js +6 -3
- package/dist/ios/templates/CraftApp.swift +5 -2
- package/dist/ios/templates/CraftHybrid.swift +60 -11
- package/dist/ios/templates/CraftNativeActions.swift +3 -2
- package/dist/ios/templates/CraftNativeFiles.swift +36 -3
- package/dist/ios/templates/CraftNativeScreen.swift +12 -1
- 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 +47 -3
- package/dist/package.d.ts +5 -0
- package/package.json +1 -1
|
@@ -1,2 +1,9 @@
|
|
|
1
1
|
/** Uses the Android builder's Kotlin version, including its transitive compiler dependencies. */
|
|
2
2
|
export declare function runKotlinFixture(source: string, libraries?: readonly string[]): Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* One member of a Kotlin class in a template, as shipped, so a fixture can run
|
|
5
|
+
* the real text rather than a copy of it: a function, or a property with its
|
|
6
|
+
* initialiser. Members sit at four spaces; the member is everything deeper,
|
|
7
|
+
* to its closing brace when it has a block body.
|
|
8
|
+
*/
|
|
9
|
+
export declare function liftKotlinMember(template: string, name: string): string;
|
|
@@ -34,10 +34,12 @@ import org.json.JSONTokener
|
|
|
34
34
|
import java.io.ByteArrayOutputStream
|
|
35
35
|
import java.io.File
|
|
36
36
|
import java.io.FileOutputStream
|
|
37
|
+
import java.io.IOException
|
|
37
38
|
import java.net.HttpURLConnection
|
|
38
39
|
import java.net.SocketTimeoutException
|
|
39
40
|
import java.net.URL
|
|
40
41
|
import java.net.URLConnection
|
|
42
|
+
import java.net.URLDecoder
|
|
41
43
|
import java.util.UUID
|
|
42
44
|
import java.util.concurrent.ConcurrentHashMap
|
|
43
45
|
import java.util.concurrent.Executors
|
|
@@ -540,8 +542,7 @@ internal class CraftNativeCapabilities(
|
|
|
540
542
|
val options = args.optJSONObject(2) ?: JSONObject()
|
|
541
543
|
val bytes = when (options.optString("encoding", "utf8")) {
|
|
542
544
|
"utf8" -> data.toByteArray(Charsets.UTF_8)
|
|
543
|
-
"base64" ->
|
|
544
|
-
.getOrElse { return failure("INVALID_ARGUMENT", "files.write base64 is invalid") }
|
|
545
|
+
"base64" -> decodeBase64(data) ?: return failure("INVALID_ARGUMENT", "files.write base64 is invalid")
|
|
545
546
|
else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
|
|
546
547
|
}
|
|
547
548
|
store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
|
|
@@ -555,6 +556,30 @@ internal class CraftNativeCapabilities(
|
|
|
555
556
|
}
|
|
556
557
|
}
|
|
557
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Base64 as `atob` reads it, which is what the browser host runs and what
|
|
561
|
+
* UIKit is held to: the alphabet, ASCII white space anywhere, padding
|
|
562
|
+
* optional but right when it is there, and nothing else. Android's own
|
|
563
|
+
* decoder skips any character it does not know, so the `-` and `_` of a
|
|
564
|
+
* URL-safe encoding would vanish and a wrong file be written with no
|
|
565
|
+
* error. It is handed only what has already been read as valid.
|
|
566
|
+
*/
|
|
567
|
+
private fun decodeBase64(text: String): ByteArray? {
|
|
568
|
+
val data = StringBuilder(text.length)
|
|
569
|
+
var padding = 0
|
|
570
|
+
for (letter in text) {
|
|
571
|
+
when {
|
|
572
|
+
letter == ' ' || letter == '\t' || letter == '\n' || letter == '\u000C' || letter == '\r' -> Unit
|
|
573
|
+
letter == '=' -> padding += 1
|
|
574
|
+
padding > 0 -> return null
|
|
575
|
+
letter in 'A'..'Z' || letter in 'a'..'z' || letter in '0'..'9' || letter == '+' || letter == '/' -> data.append(letter)
|
|
576
|
+
else -> return null
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (padding > 2 || data.length % 4 == 1 || (padding > 0 && (data.length + padding) % 4 != 0)) return null
|
|
580
|
+
return runCatching { Base64.decode(data.toString(), Base64.DEFAULT) }.getOrNull()
|
|
581
|
+
}
|
|
582
|
+
|
|
558
583
|
private fun download(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
559
584
|
val address = args.optString(0).trim()
|
|
560
585
|
val url = validURL(address) ?: return failure("INVALID_ARGUMENT", "files.download needs an absolute http or https URL")
|
|
@@ -585,18 +610,47 @@ internal class CraftNativeCapabilities(
|
|
|
585
610
|
return try {
|
|
586
611
|
val status = connection.responseCode
|
|
587
612
|
if (status !in 200..299) return refusedDownload(connection, status, requestToken)
|
|
588
|
-
|
|
613
|
+
// The name the screen asked for, else the server's, else the
|
|
614
|
+
// address's: the order UIKit's download keeps a file under.
|
|
615
|
+
val name = options.optString("name").ifBlank { downloadName(connection.getHeaderField("Content-Disposition"), connection.url.path) }
|
|
589
616
|
val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
val count = input.read(buffer)
|
|
595
|
-
if (count < 0) break
|
|
596
|
-
output.write(buffer, 0, count)
|
|
617
|
+
var kept = false
|
|
618
|
+
try {
|
|
619
|
+
val output = try { FileOutputStream(file) } catch (error: IOException) {
|
|
620
|
+
return failure("WRITE_ERROR", error.message ?: "File could not be written")
|
|
597
621
|
}
|
|
598
|
-
|
|
599
|
-
|
|
622
|
+
var written = 0L
|
|
623
|
+
output.use { connection.inputStream.use { input ->
|
|
624
|
+
val buffer = ByteArray(8192)
|
|
625
|
+
while (true) {
|
|
626
|
+
if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
|
|
627
|
+
val count = input.read(buffer)
|
|
628
|
+
if (count < 0) break
|
|
629
|
+
// A full disk is not the network's failure.
|
|
630
|
+
try { output.write(buffer, 0, count) } catch (error: IOException) {
|
|
631
|
+
return failure("WRITE_ERROR", error.message ?: "File could not be written")
|
|
632
|
+
}
|
|
633
|
+
written += count
|
|
634
|
+
}
|
|
635
|
+
} }
|
|
636
|
+
// A body shorter than the server said it would be is not the
|
|
637
|
+
// file. Android's connection fails such a read itself, and
|
|
638
|
+
// other implementations simply stop, so the length is checked
|
|
639
|
+
// here and the answer is this function's own. (When the
|
|
640
|
+
// connection decompressed the body it reports no length.)
|
|
641
|
+
val declared = connection.getHeaderField("Content-Length")?.trim()?.toLongOrNull()
|
|
642
|
+
if (declared != null && written != declared) {
|
|
643
|
+
// A cancel can end the read early rather than fail it.
|
|
644
|
+
return if (cancelled.contains(requestToken)) failure("CANCELLED", "File download was cancelled")
|
|
645
|
+
else failure("NETWORK_ERROR", "File download ended after $written of $declared bytes")
|
|
646
|
+
}
|
|
647
|
+
kept = true
|
|
648
|
+
// `ok` is how a screen tells this from a refusal, which
|
|
649
|
+
// resolves `{ ok: false, status, body }` rather than rejecting.
|
|
650
|
+
success(descriptor(file, connection.contentType ?: "application/octet-stream").put("ok", true).put("status", status))
|
|
651
|
+
} finally {
|
|
652
|
+
if (!kept) removePartial(file)
|
|
653
|
+
}
|
|
600
654
|
} catch (error: SocketTimeoutException) {
|
|
601
655
|
failure("TIMEOUT", error.message ?: "File download timed out")
|
|
602
656
|
} catch (error: Exception) {
|
|
@@ -608,6 +662,38 @@ internal class CraftNativeCapabilities(
|
|
|
608
662
|
}
|
|
609
663
|
}
|
|
610
664
|
|
|
665
|
+
/**
|
|
666
|
+
* A download that stopped part-way is not a file. Nothing was told its
|
|
667
|
+
* path, so nothing would remove it before the daily sweep.
|
|
668
|
+
*/
|
|
669
|
+
private fun removePartial(file: File) {
|
|
670
|
+
runCatching { file.delete() }
|
|
671
|
+
// Its folder was made for it alone; delete refuses one that is not empty.
|
|
672
|
+
runCatching { file.parentFile?.takeIf { it != fileRoot }?.delete() }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The server's name for a download (`Content-Disposition`, the encoded
|
|
677
|
+
* `filename*` before the plain one), else the address's last segment as a
|
|
678
|
+
* person reads it rather than as it is percent-encoded.
|
|
679
|
+
*/
|
|
680
|
+
private fun downloadName(disposition: String?, path: String): String {
|
|
681
|
+
val header = disposition.orEmpty()
|
|
682
|
+
Regex("filename\\*\\s*=\\s*UTF-8''([^;\\s]+)", RegexOption.IGNORE_CASE).find(header)?.let { match ->
|
|
683
|
+
percentDecoded(match.groupValues[1])?.takeIf { it.isNotBlank() }?.let { return it }
|
|
684
|
+
}
|
|
685
|
+
Regex("filename\\s*=\\s*(?:\"((?:[^\"\\\\]|\\\\.)*)\"|([^;]+))", RegexOption.IGNORE_CASE).find(header)?.let { match ->
|
|
686
|
+
val quoted = match.groups[1]?.value?.replace(Regex("\\\\(.)")) { it.groupValues[1] }
|
|
687
|
+
(quoted ?: match.groupValues[2]).trim().takeIf { it.isNotBlank() }?.let { return it }
|
|
688
|
+
}
|
|
689
|
+
val segment = path.substringAfterLast('/')
|
|
690
|
+
return (percentDecoded(segment) ?: segment).ifBlank { "download" }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Percent-decoded as UTF-8, or null when it is not validly encoded. A `+` is a plus, not a space. */
|
|
694
|
+
private fun percentDecoded(value: String): String? =
|
|
695
|
+
runCatching { URLDecoder.decode(value.replace("+", "%2B"), "UTF-8") }.getOrNull()
|
|
696
|
+
|
|
611
697
|
private fun refusedDownload(
|
|
612
698
|
connection: HttpURLConnection,
|
|
613
699
|
status: Int,
|
|
@@ -997,9 +1083,24 @@ internal class CraftNativeCapabilities(
|
|
|
997
1083
|
return true
|
|
998
1084
|
}
|
|
999
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* A file name with no path in it that the disk will take, as UIKit's
|
|
1088
|
+
* `safeName` makes one: no leading dots (so never `..`, the folder
|
|
1089
|
+
* itself), and cut to fit. 255 bytes is what a name may be on disk and
|
|
1090
|
+
* 120 characters is where UIKit cuts; the extension stays, since it is
|
|
1091
|
+
* how a share sheet and an upload know what the file is.
|
|
1092
|
+
*/
|
|
1000
1093
|
private fun safeFileName(raw: String): String {
|
|
1001
|
-
val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
|
|
1002
|
-
|
|
1094
|
+
val name = raw.substringAfterLast('/').substringAfterLast('\\').trim().trimStart('.')
|
|
1095
|
+
val safe = name.replace(Regex("[\\\\/:*?\"<>|\\r\\n\\u0000]"), "_").ifBlank { "file" }
|
|
1096
|
+
fun fits(value: String) = value.codePointCount(0, value.length) <= 120 && value.toByteArray(Charsets.UTF_8).size <= 255
|
|
1097
|
+
if (fits(safe)) return safe
|
|
1098
|
+
val dot = safe.lastIndexOf('.')
|
|
1099
|
+
val extension = if (dot > 0 && safe.length - dot <= 16) safe.substring(dot) else ""
|
|
1100
|
+
var stem = safe.dropLast(extension.length)
|
|
1101
|
+
stem = stem.substring(0, stem.offsetByCodePoints(0, minOf(120, stem.codePointCount(0, stem.length))))
|
|
1102
|
+
while (stem.isNotEmpty() && !fits(stem + extension)) stem = stem.substring(0, stem.offsetByCodePoints(stem.length, -1))
|
|
1103
|
+
return stem + extension
|
|
1003
1104
|
}
|
|
1004
1105
|
|
|
1005
1106
|
private fun jpegName(raw: String): String {
|
|
@@ -1093,10 +1194,18 @@ internal class CraftNativeCapabilities(
|
|
|
1093
1194
|
}
|
|
1094
1195
|
}
|
|
1095
1196
|
|
|
1096
|
-
private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
1197
|
+
private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult = synchronized(CraftNativeStorage.secureLock) {
|
|
1198
|
+
// A write that did not reach the disk is a failure, and nothing is told
|
|
1199
|
+
// it happened: the store's own memory is put back (a failed commit
|
|
1200
|
+
// has already changed it), no isolate hears of it, and the caller is
|
|
1201
|
+
// refused, as UIKit refuses a Keychain delete that failed. Answering
|
|
1202
|
+
// `false` and telling every isolate the token was gone made a failed
|
|
1203
|
+
// sign-out look like a sign-out until the next launch read the disk.
|
|
1097
1204
|
if (method == "clear") {
|
|
1098
1205
|
return try {
|
|
1099
|
-
|
|
1206
|
+
if (!CraftNativeStorage.commit(encryptedSecureStorage) { it.clear() }) return failure("SECURE_STORAGE_ERROR", "Secure storage could not be cleared")
|
|
1207
|
+
storeObserver?.invoke("secure", null, null)
|
|
1208
|
+
success(true)
|
|
1100
1209
|
} catch (error: Exception) {
|
|
1101
1210
|
failure("SECURE_STORAGE_ERROR", error.message ?: "Secure storage could not be cleared")
|
|
1102
1211
|
}
|
|
@@ -1106,14 +1215,18 @@ internal class CraftNativeCapabilities(
|
|
|
1106
1215
|
when (method) {
|
|
1107
1216
|
"set" -> {
|
|
1108
1217
|
if (args.length() != 2 || args.opt(1) !is String) return failure("INVALID_ARGUMENT", "SecureStorage.set needs a key and string value")
|
|
1109
|
-
|
|
1110
|
-
|
|
1218
|
+
val value = args.getString(1)
|
|
1219
|
+
if (CraftNativeStorage.commit(encryptedSecureStorage) { it.putString(key, value) }) {
|
|
1220
|
+
storeObserver?.invoke("secure", key, value)
|
|
1111
1221
|
success(true)
|
|
1112
1222
|
}
|
|
1113
1223
|
else failure("SECURE_STORAGE_ERROR", "Secure storage could not be written")
|
|
1114
1224
|
}
|
|
1115
1225
|
"get" -> success(encryptedSecureStorage.getString(key, null) ?: JSONObject.NULL)
|
|
1116
|
-
"remove" ->
|
|
1226
|
+
"remove" -> if (CraftNativeStorage.commit(encryptedSecureStorage) { it.remove(key) }) {
|
|
1227
|
+
storeObserver?.invoke("secure", key, null)
|
|
1228
|
+
success(true)
|
|
1229
|
+
} else failure("SECURE_STORAGE_ERROR", "Secure storage could not be removed")
|
|
1117
1230
|
else -> failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
|
|
1118
1231
|
}
|
|
1119
1232
|
} catch (error: Exception) {
|