craft-native 0.0.187 → 0.0.189
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/templates/CraftNative.kt.template +34 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +92 -9
- package/dist/android/templates/CraftNativeShell.kt.template +14 -2
- package/dist/android/templates/MainActivityNative.kt.template +312 -24
- package/dist/browser/dialogs.d.ts +20 -0
- package/dist/browser/storage.d.ts +31 -0
- package/dist/browser.d.ts +22 -0
- package/dist/browser.js +705 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHybrid.swift +36 -4
- package/dist/ios/templates/CraftNativeActions.swift +16 -4
- package/dist/ios/templates/CraftNativeFiles.swift +131 -25
- package/dist/ios/templates/CraftNativeScreen.swift +454 -3
- package/dist/macos/templates/native_layout.zig +299 -6
- package/package.json +9 -1
|
@@ -341,6 +341,18 @@ object CraftNative {
|
|
|
341
341
|
nodeData: FloatArray,
|
|
342
342
|
treeMeta: FloatArray,
|
|
343
343
|
): FloatArray?
|
|
344
|
+
private external fun nativeLayoutTreeV2(
|
|
345
|
+
width: Float,
|
|
346
|
+
height: Float,
|
|
347
|
+
nodeData: FloatArray,
|
|
348
|
+
treeMeta: FloatArray,
|
|
349
|
+
): FloatArray?
|
|
350
|
+
private external fun nativeLayoutTreeV3(
|
|
351
|
+
width: Float,
|
|
352
|
+
height: Float,
|
|
353
|
+
nodeData: FloatArray,
|
|
354
|
+
treeMeta: FloatArray,
|
|
355
|
+
): FloatArray?
|
|
344
356
|
private external fun nativeClipboardRead(activity: Activity): String?
|
|
345
357
|
private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
|
|
346
358
|
private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
|
|
@@ -601,6 +613,28 @@ object CraftNative {
|
|
|
601
613
|
}
|
|
602
614
|
}
|
|
603
615
|
|
|
616
|
+
/** Resolve a reverse-aware tree in Zig, or null for Kotlin fallback. */
|
|
617
|
+
fun layoutTreeV2(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
|
|
618
|
+
if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
|
|
619
|
+
return try {
|
|
620
|
+
nativeLayoutTreeV2(width, height, nodeData, treeMeta)
|
|
621
|
+
?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
|
|
622
|
+
} catch (e: UnsatisfiedLinkError) {
|
|
623
|
+
null
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Resolve a wrap-reverse-aware tree in Zig, or null for Kotlin fallback. */
|
|
628
|
+
fun layoutTreeV3(width: Float, height: Float, nodeData: FloatArray, treeMeta: FloatArray): FloatArray? {
|
|
629
|
+
if (!isAvailable || nodeData.isEmpty() || treeMeta.isEmpty()) return null
|
|
630
|
+
return try {
|
|
631
|
+
nativeLayoutTreeV3(width, height, nodeData, treeMeta)
|
|
632
|
+
?.takeIf { it.size >= treeMeta.size / 2 * 4 && it.all { value -> value.isFinite() } }
|
|
633
|
+
} catch (e: UnsatisfiedLinkError) {
|
|
634
|
+
null
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
604
638
|
/**
|
|
605
639
|
* The clipboard's text, or null to use the Kotlin implementation.
|
|
606
640
|
*
|
|
@@ -104,6 +104,7 @@ internal class CraftNativeCapabilities(
|
|
|
104
104
|
val maxPixels: Int,
|
|
105
105
|
val quality: Float,
|
|
106
106
|
val completion: (CraftNativeCapabilityResult) -> Unit,
|
|
107
|
+
val videoLimit: Long = 0,
|
|
107
108
|
)
|
|
108
109
|
|
|
109
110
|
init {
|
|
@@ -166,6 +167,7 @@ internal class CraftNativeCapabilities(
|
|
|
166
167
|
}
|
|
167
168
|
"Files" -> when (method) {
|
|
168
169
|
"pickImage" -> pickImage(requestToken, args, completion)
|
|
170
|
+
"pickVideo" -> pickVideo(requestToken, args, completion)
|
|
169
171
|
"download", "write", "remove" -> networkExecutor.execute {
|
|
170
172
|
complete(requestToken, files(requestToken, method, args), completion)
|
|
171
173
|
}
|
|
@@ -238,21 +240,28 @@ internal class CraftNativeCapabilities(
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
if (body != null || multipart is FileResult.Multipart) doOutput = true
|
|
241
|
-
if (multipart is FileResult.Multipart)
|
|
243
|
+
if (multipart is FileResult.Multipart) {
|
|
244
|
+
setRequestProperty("Content-Type", "multipart/form-data; boundary=${multipart.boundary}")
|
|
245
|
+
setFixedLengthStreamingMode(multipart.body.length())
|
|
246
|
+
readTimeout = 600_000
|
|
247
|
+
}
|
|
242
248
|
}
|
|
243
249
|
} catch (error: IllegalArgumentException) {
|
|
250
|
+
if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
|
|
244
251
|
return failure("INVALID_ARGUMENT", error.message ?: "fetch request is invalid")
|
|
245
252
|
} catch (error: Exception) {
|
|
253
|
+
if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
|
|
246
254
|
return failure("NETWORK_ERROR", error.message ?: "Network request failed")
|
|
247
255
|
}
|
|
248
256
|
fetchConnections[requestToken] = connection
|
|
249
257
|
if (cancelled.contains(requestToken)) {
|
|
250
258
|
fetchConnections.remove(requestToken)?.disconnect()
|
|
259
|
+
if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
|
|
251
260
|
return failure("CANCELLED", "Network request was cancelled")
|
|
252
261
|
}
|
|
253
262
|
return try {
|
|
254
263
|
if (body != null) connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
|
255
|
-
if (multipart is FileResult.Multipart) connection.outputStream.use {
|
|
264
|
+
if (multipart is FileResult.Multipart) connection.outputStream.use { output -> multipart.body.inputStream().use { it.copyTo(output, 64 * 1024) } }
|
|
256
265
|
val status = connection.responseCode
|
|
257
266
|
val input = if (status >= 400) connection.errorStream else connection.inputStream
|
|
258
267
|
val output = ByteArrayOutputStream()
|
|
@@ -283,17 +292,21 @@ internal class CraftNativeCapabilities(
|
|
|
283
292
|
failure("NETWORK_ERROR", error.message ?: "Network request failed")
|
|
284
293
|
} finally {
|
|
285
294
|
fetchConnections.remove(requestToken)?.disconnect()
|
|
295
|
+
if (multipart is FileResult.Multipart) multipart.body.parentFile?.deleteRecursively()
|
|
286
296
|
}
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
private sealed interface FileResult {
|
|
290
|
-
data class Multipart(val body:
|
|
300
|
+
data class Multipart(val body: File, val boundary: String) : FileResult
|
|
291
301
|
data class Failure(val code: String, val message: String) : FileResult
|
|
292
302
|
}
|
|
293
303
|
|
|
294
304
|
private fun multipart(parts: JSONArray): FileResult {
|
|
295
305
|
val boundary = "craft-${UUID.randomUUID()}"
|
|
296
|
-
val
|
|
306
|
+
val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve("upload.multipart")
|
|
307
|
+
var kept = false
|
|
308
|
+
try {
|
|
309
|
+
FileOutputStream(file).use { body ->
|
|
297
310
|
fun append(value: String) { body.write(value.toByteArray(Charsets.UTF_8)) }
|
|
298
311
|
for (index in 0 until parts.length()) {
|
|
299
312
|
val part = parts.optJSONObject(index) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form part must be an object")
|
|
@@ -303,14 +316,11 @@ internal class CraftNativeCapabilities(
|
|
|
303
316
|
val path = part.optString("path")
|
|
304
317
|
if (path.isNotBlank()) {
|
|
305
318
|
val file = ownedFile(path) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form file must be one craft.files made, and still there")
|
|
306
|
-
val bytes = runCatching { file.readBytes() }.getOrElse {
|
|
307
|
-
return FileResult.Failure("INVALID_ARGUMENT", "A form file could not be read")
|
|
308
|
-
}
|
|
309
319
|
val filename = safeFileName(part.optString("filename").ifBlank { file.name })
|
|
310
320
|
val type = part.optString("type").ifBlank { URLConnection.guessContentTypeFromName(filename) ?: "application/octet-stream" }
|
|
311
321
|
append("Content-Disposition: form-data; name=\"${quoted(name)}\"; filename=\"${quoted(filename)}\"\r\n")
|
|
312
322
|
append("Content-Type: ${headerValue(type)}\r\n\r\n")
|
|
313
|
-
|
|
323
|
+
file.inputStream().use { it.copyTo(body, 64 * 1024) }
|
|
314
324
|
append("\r\n")
|
|
315
325
|
} else {
|
|
316
326
|
append("Content-Disposition: form-data; name=\"${quoted(name)}\"\r\n\r\n")
|
|
@@ -319,7 +329,14 @@ internal class CraftNativeCapabilities(
|
|
|
319
329
|
}
|
|
320
330
|
}
|
|
321
331
|
append("--$boundary--\r\n")
|
|
322
|
-
|
|
332
|
+
}
|
|
333
|
+
kept = true
|
|
334
|
+
return FileResult.Multipart(file, boundary)
|
|
335
|
+
} catch (error: Exception) {
|
|
336
|
+
return FileResult.Failure("WRITE_ERROR", error.message ?: "A form file could not be read")
|
|
337
|
+
} finally {
|
|
338
|
+
if (!kept) file.parentFile?.deleteRecursively()
|
|
339
|
+
}
|
|
323
340
|
}
|
|
324
341
|
|
|
325
342
|
private fun files(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
|
|
@@ -417,6 +434,64 @@ internal class CraftNativeCapabilities(
|
|
|
417
434
|
return success(JSONObject().put("ok", false).put("status", status).put("body", body.toString(Charsets.UTF_8.name())))
|
|
418
435
|
}
|
|
419
436
|
|
|
437
|
+
private fun pickVideo(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
|
|
438
|
+
mainHandler.post {
|
|
439
|
+
val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
|
|
440
|
+
if (pendingFilePick != null) {
|
|
441
|
+
complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
|
|
442
|
+
return@post
|
|
443
|
+
}
|
|
444
|
+
val options = args.optJSONObject(0) ?: JSONObject()
|
|
445
|
+
val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
|
|
446
|
+
pendingFilePick = PendingFilePick(requestToken, "video", 0, 1f, completion, limit)
|
|
447
|
+
val photos = options.optString("source", "library") != "files" && Build.VERSION.SDK_INT >= 33
|
|
448
|
+
val intent = Intent(if (photos) MediaStore.ACTION_PICK_IMAGES else Intent.ACTION_OPEN_DOCUMENT).apply {
|
|
449
|
+
type = "video/*"
|
|
450
|
+
if (!photos) addCategory(Intent.CATEGORY_OPENABLE)
|
|
451
|
+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
452
|
+
}
|
|
453
|
+
try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) }
|
|
454
|
+
catch (error: Exception) {
|
|
455
|
+
pendingFilePick = null
|
|
456
|
+
complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Video picker could not be opened"), completion)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Copy with a bound even when a cloud provider does not report its size. */
|
|
462
|
+
private fun copyVideo(uri: Uri, limit: Long): CraftNativeCapabilityResult {
|
|
463
|
+
var target: File? = null
|
|
464
|
+
return try {
|
|
465
|
+
val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
|
466
|
+
if (it.moveToFirst()) it.getString(0) else null
|
|
467
|
+
} ?: "video.mp4"
|
|
468
|
+
val types = mapOf("mp4" to "video/mp4", "m4v" to "video/x-m4v", "mov" to "video/quicktime", "webm" to "video/webm")
|
|
469
|
+
val type = types[name.substringAfterLast('.', "").lowercase()]
|
|
470
|
+
?: return failure("INVALID_VIDEO", "Choose an MP4, MOV or WebM video.")
|
|
471
|
+
val destination = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
|
|
472
|
+
target = destination
|
|
473
|
+
val input = context.contentResolver.openInputStream(uri) ?: throw IllegalArgumentException("The video could not be read")
|
|
474
|
+
var size = 0L
|
|
475
|
+
input.use { source ->
|
|
476
|
+
destination.outputStream().use { output ->
|
|
477
|
+
val buffer = ByteArray(64 * 1024)
|
|
478
|
+
while (true) {
|
|
479
|
+
val count = source.read(buffer)
|
|
480
|
+
if (count < 0) break
|
|
481
|
+
size += count
|
|
482
|
+
require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." }
|
|
483
|
+
output.write(buffer, 0, count)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
require(size > 0) { "Choose a nonempty video." }
|
|
488
|
+
success(descriptor(destination, type))
|
|
489
|
+
} catch (error: Exception) {
|
|
490
|
+
target?.parentFile?.deleteRecursively()
|
|
491
|
+
failure("READ_ERROR", error.message ?: "The video could not be read")
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
420
495
|
private fun pickImage(
|
|
421
496
|
requestToken: String,
|
|
422
497
|
args: JSONArray,
|
|
@@ -465,6 +540,14 @@ internal class CraftNativeCapabilities(
|
|
|
465
540
|
complete(pending.requestToken, success(JSONObject.NULL), pending.completion)
|
|
466
541
|
return true
|
|
467
542
|
}
|
|
543
|
+
if (pending.source == "video") {
|
|
544
|
+
networkExecutor.execute {
|
|
545
|
+
val result = data?.data?.let { copyVideo(it, pending.videoLimit) }
|
|
546
|
+
?: failure("PICKER_FAILED", "The selected video could not be read")
|
|
547
|
+
complete(pending.requestToken, result, pending.completion)
|
|
548
|
+
}
|
|
549
|
+
return true
|
|
550
|
+
}
|
|
468
551
|
val descriptor = if (pending.source == "camera") {
|
|
469
552
|
val bitmap = data?.extras?.get("data") as? Bitmap
|
|
470
553
|
bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
|
|
@@ -580,6 +580,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
580
580
|
private var selectedTab = 0
|
|
581
581
|
private var swipeTracking = false
|
|
582
582
|
private var swipeIntercepted = false
|
|
583
|
+
private var horizontalScrollDeferred = false
|
|
583
584
|
private var swipeDownX = 0f
|
|
584
585
|
private var swipeDownY = 0f
|
|
585
586
|
private val swipeSlop = ViewConfiguration.get(context).scaledTouchSlop
|
|
@@ -590,6 +591,8 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
590
591
|
var onTabSwipeStart: ((Float, Float, Float) -> Boolean)? = null
|
|
591
592
|
var onTabSwipeMove: ((Float) -> Unit)? = null
|
|
592
593
|
var onTabSwipeEnd: ((Float, Boolean) -> Unit)? = null
|
|
594
|
+
/** Lets a native horizontal scroller own the gesture from its first down. */
|
|
595
|
+
var onTabSwipeShouldDefer: ((Float, Float) -> Boolean)? = null
|
|
593
596
|
var tabSwipeEnabled = true
|
|
594
597
|
|
|
595
598
|
init {
|
|
@@ -654,7 +657,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
654
657
|
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
|
655
658
|
when (event.actionMasked) {
|
|
656
659
|
MotionEvent.ACTION_DOWN -> {
|
|
657
|
-
|
|
660
|
+
val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
|
|
661
|
+
horizontalScrollDeferred = overHorizontalScroll
|
|
662
|
+
if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
|
|
663
|
+
swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
|
|
658
664
|
swipeIntercepted = false
|
|
659
665
|
swipeDownX = event.x
|
|
660
666
|
swipeDownY = event.y
|
|
@@ -668,7 +674,13 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
|
|
|
668
674
|
return swipeIntercepted
|
|
669
675
|
}
|
|
670
676
|
}
|
|
671
|
-
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP ->
|
|
677
|
+
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
|
|
678
|
+
swipeTracking = false
|
|
679
|
+
if (horizontalScrollDeferred) {
|
|
680
|
+
horizontalScrollDeferred = false
|
|
681
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
672
684
|
}
|
|
673
685
|
return swipeIntercepted
|
|
674
686
|
}
|