craft-native 0.0.104 → 0.0.107

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.
@@ -8,6 +8,7 @@ export declare const ANDROID_MIN_SDK = 26;
8
8
  export interface CraftAndroidConfig {
9
9
  appName: string;
10
10
  packageName: string;
11
+ renderer?: 'web' | 'native';
11
12
  version?: string;
12
13
  versionCode?: number;
13
14
  darkMode?: boolean;
@@ -51,6 +52,7 @@ export interface InitOptions {
51
52
  }
52
53
  export interface BuildOptions {
53
54
  htmlPath?: string;
55
+ nativeBundlePath?: string;
54
56
  devServer?: string;
55
57
  output: string;
56
58
  release?: boolean;
@@ -112,6 +112,7 @@ var KOTLIN_KEYWORDS = new Set([
112
112
  ]);
113
113
  var ANDROID_MIN_SDK = 26;
114
114
  var DEFAULT_CONFIG = {
115
+ renderer: "web",
115
116
  version: "1.0.0",
116
117
  versionCode: 1,
117
118
  darkMode: true,
@@ -212,6 +213,9 @@ function normalizeAndroidNetworkConfig(config) {
212
213
  function validateAndroidConfig(config) {
213
214
  if (!config.appName.trim())
214
215
  throw new Error("Android app name must not be empty");
216
+ if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
217
+ throw new Error(`Unknown Android renderer: ${config.renderer}. Expected web or native.`);
218
+ }
215
219
  const packageSegments = config.packageName.split(".");
216
220
  if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
217
221
  throw new Error(`Invalid Android package name: ${config.packageName}`);
@@ -408,7 +412,7 @@ async function init(options) {
408
412
  if (config.googleServicesFile) {
409
413
  cpSync(config.googleServicesFile, join(output, "app/google-services.json"));
410
414
  }
411
- const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, "MainActivity.kt.template"), "utf-8");
415
+ const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
412
416
  const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
413
417
  writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
414
418
  const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
@@ -455,7 +459,7 @@ async function init(options) {
455
459
  writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
456
460
  const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
457
461
  const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
458
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "");
462
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.javascriptengine:javascriptengine:1.1.0")' : "");
459
463
  writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
460
464
  const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
461
465
  writeFileSync(join(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
@@ -519,7 +523,12 @@ zipStorePath=wrapper/dists
519
523
  </resources>
520
524
  `;
521
525
  writeFileSync(join(output, "app/src/main/res/values/themes.xml"), themesXml);
522
- const activityMainXml = `<?xml version="1.0" encoding="utf-8"?>
526
+ const activityMainXml = config.renderer === "native" ? `<?xml version="1.0" encoding="utf-8"?>
527
+ <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
528
+ android:id="@+id/native_root"
529
+ android:layout_width="match_parent"
530
+ android:layout_height="match_parent" />
531
+ ` : `<?xml version="1.0" encoding="utf-8"?>
523
532
  <androidx.coordinatorlayout.widget.CoordinatorLayout
524
533
  xmlns:android="http://schemas.android.com/apk/res/android"
525
534
  android:layout_width="match_parent"
@@ -581,13 +590,18 @@ zipStorePath=wrapper/dists
581
590
  console.log("");
582
591
  console.log("Next steps:");
583
592
  console.log(` 1. cd ${output}`);
584
- console.log(" 2. Add your web content to app/src/main/assets/index.html");
585
- console.log(" 3. Run: craft android build");
593
+ if (config.renderer === "native") {
594
+ console.log(" 2. Compile your screen: stx-native compile Screen.stx --format bundle --output screen.js");
595
+ console.log(" 3. Run: craft android build --native-bundle screen.js");
596
+ } else {
597
+ console.log(" 2. Add your web content to app/src/main/assets/index.html");
598
+ console.log(" 3. Run: craft android build");
599
+ }
586
600
  console.log(" 4. Run: craft android open");
587
601
  console.log("");
588
602
  }
589
603
  async function build(options) {
590
- const { htmlPath, devServer, output, release, compile = true } = options;
604
+ const { htmlPath, nativeBundlePath, devServer, output, release, compile = true } = options;
591
605
  console.log(`
592
606
  \uD83D\uDCE6 Building Craft Android project...`);
593
607
  const configPath = join(output, "craft.config.json");
@@ -595,6 +609,19 @@ async function build(options) {
595
609
  throw new Error(`No craft.config.json found in ${output}. Run 'craft android init' first.`);
596
610
  }
597
611
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
612
+ validateAndroidConfig(config);
613
+ if (config.renderer === "native") {
614
+ if (htmlPath || devServer)
615
+ throw new Error("A native Android screen cannot use --html-path or --dev-server.");
616
+ const destination = join(output, "app/src/main/assets/native-screen.js");
617
+ if (nativeBundlePath && resolve(nativeBundlePath) !== resolve(destination))
618
+ cpSync(nativeBundlePath, destination);
619
+ if (!existsSync(destination)) {
620
+ throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with stx-native first.");
621
+ }
622
+ } else if (nativeBundlePath) {
623
+ throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
624
+ }
598
625
  if (existsSync(join(output, "app/src/main/jniLibs"))) {
599
626
  const runtimeDir = resolveRuntimeDir(options.runtimeDir);
600
627
  if (runtimeDir) {
@@ -0,0 +1,390 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.ClipData
4
+ import android.content.ClipboardManager
5
+ import android.content.Context
6
+ import android.graphics.Color
7
+ import android.os.Build
8
+ import android.os.Bundle
9
+ import android.os.VibrationEffect
10
+ import android.os.Vibrator
11
+ import android.text.Editable
12
+ import android.text.TextWatcher
13
+ import android.util.Log
14
+ import android.view.View
15
+ import android.view.ViewGroup
16
+ import android.widget.Button
17
+ import android.widget.EditText
18
+ import android.widget.FrameLayout
19
+ import android.widget.LinearLayout
20
+ import android.widget.TextView
21
+ import androidx.appcompat.app.AppCompatActivity
22
+ import androidx.core.content.ContextCompat
23
+ import androidx.javascriptengine.JavaScriptIsolate
24
+ import androidx.javascriptengine.JavaScriptConsoleCallback
25
+ import androidx.javascriptengine.JavaScriptSandbox
26
+ import androidx.javascriptengine.Message
27
+ import androidx.javascriptengine.MessagePort
28
+ import androidx.javascriptengine.MessagePortClient
29
+ import org.json.JSONArray
30
+ import org.json.JSONObject
31
+ import java.util.UUID
32
+
33
+ /** WebView-free host for a compiled stx-native route bundle. */
34
+ class MainActivity : AppCompatActivity() {
35
+ private class Screen(
36
+ val route: String?,
37
+ val params: JSONObject,
38
+ val view: LinearLayout,
39
+ val isolate: JavaScriptIsolate,
40
+ val port: MessagePort?
41
+ ) {
42
+ val drafts = mutableMapOf<String, String>()
43
+ val controls = mutableMapOf<String, View>()
44
+ val handlers = mutableMapOf<View, String>()
45
+ var active = true
46
+
47
+ fun close() {
48
+ active = false
49
+ port?.close()
50
+ isolate.close()
51
+ }
52
+ }
53
+
54
+ private lateinit var root: FrameLayout
55
+ private var sandbox: JavaScriptSandbox? = null
56
+ private val screens = mutableListOf<Screen>()
57
+ private var bundle = ""
58
+ private var hapticsEnabled = false
59
+ private var defaultTextColor = Color.WHITE
60
+ private var useMessagePorts = false
61
+
62
+ override fun onCreate(savedInstanceState: Bundle?) {
63
+ super.onCreate(savedInstanceState)
64
+ setContentView(R.layout.activity_main)
65
+ root = findViewById(R.id.native_root)
66
+ val config = runCatching {
67
+ assets.open("craft.config.json").bufferedReader().use {
68
+ JSONObject(it.readText())
69
+ }
70
+ }.getOrDefault(JSONObject())
71
+ hapticsEnabled = config.optBoolean("enableHaptics", false)
72
+ val background = color(config.optString("backgroundColor", "#1a1a2e"), Color.BLACK)
73
+ root.setBackgroundColor(background)
74
+ defaultTextColor = if (Color.luminance(background) < 0.5) Color.WHITE else Color.BLACK
75
+ bundle = runCatching {
76
+ assets.open("native-screen.js").bufferedReader().use { it.readText() }
77
+ }.getOrElse {
78
+ showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")
79
+ return
80
+ }
81
+ if (!JavaScriptSandbox.isSupported()) {
82
+ showError("Native STX requires Android JavaScriptSandbox, which is unavailable on this device.")
83
+ return
84
+ }
85
+ val connection = JavaScriptSandbox.createConnectedInstanceAsync(this)
86
+ connection.addListener({
87
+ try {
88
+ val engine = connection.get()
89
+ if (isDestroyed) {
90
+ engine.close()
91
+ return@addListener
92
+ }
93
+ useMessagePorts = engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)
94
+ && engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)
95
+ if (!useMessagePorts && !engine.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) {
96
+ engine.close()
97
+ showError("Native STX requires JavaScript message ports or console messaging, unavailable on this device.")
98
+ return@addListener
99
+ }
100
+ Log.i(TAG, "Native STX bridge: ${if (useMessagePorts) "message ports" else "console callback"}")
101
+ sandbox = engine
102
+ openScreen(null)
103
+ } catch (error: Exception) {
104
+ Log.e(TAG, "JavaScriptSandbox connection failed", error)
105
+ showError("Native STX JavaScript engine could not start: ${error.message}")
106
+ }
107
+ }, ContextCompat.getMainExecutor(this))
108
+ }
109
+
110
+ private fun openScreen(route: String?, params: JSONObject = JSONObject()) {
111
+ val engine = sandbox ?: return
112
+ val isolate = engine.createIsolate()
113
+ val view = LinearLayout(this).apply {
114
+ orientation = LinearLayout.VERTICAL
115
+ layoutParams = FrameLayout.LayoutParams(-1, -1)
116
+ }
117
+ var screen: Screen? = null
118
+ val executor = ContextCompat.getMainExecutor(this)
119
+ val token = UUID.randomUUID().toString()
120
+ val prefix = "CRAFT_STX_$token:"
121
+ val port = if (useMessagePorts) {
122
+ isolate.createMessageChannel("craft", executor, MessagePortClient { message ->
123
+ val current = screen ?: return@MessagePortClient
124
+ if (current.active && message.type == Message.TYPE_STRING) receive(current, message.string)
125
+ })
126
+ } else {
127
+ isolate.setConsoleCallback(executor, JavaScriptConsoleCallback { entry ->
128
+ val current = screen ?: return@JavaScriptConsoleCallback
129
+ if (current.active && entry.message.startsWith(prefix)) {
130
+ receive(current, entry.message.removePrefix(prefix))
131
+ }
132
+ })
133
+ null
134
+ }
135
+ val activeScreen = Screen(route, params, view, isolate, port)
136
+ screen = activeScreen
137
+ screens.add(activeScreen)
138
+ show(activeScreen)
139
+ val routePrelude = """
140
+ globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
141
+ globalThis.__stxNativeParams = ${params};
142
+ """.trimIndent()
143
+ val prelude = if (useMessagePorts) """
144
+ (async function() {
145
+ const port = await android.getNamedPort('craft');
146
+ $routePrelude
147
+ globalThis.__stxNativeBridge = {
148
+ postMessage: function(value) { port.postMessage(value); },
149
+ onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
150
+ };
151
+ })()
152
+ """.trimIndent() else """
153
+ $routePrelude
154
+ globalThis.__stxNativeBridge = {
155
+ postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
156
+ onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
157
+ };
158
+ """.trimIndent()
159
+ val setup = isolate.evaluateJavaScriptAsync(prelude)
160
+ setup.addListener({
161
+ if (!activeScreen.active) return@addListener
162
+ try {
163
+ setup.get()
164
+ val load = isolate.evaluateJavaScriptAsync(bundle)
165
+ load.addListener({
166
+ try { load.get() } catch (error: Exception) {
167
+ Log.e(TAG, "Native STX bundle failed", error)
168
+ if (activeScreen.active) showError("Native STX bundle failed: ${error.message}")
169
+ }
170
+ }, executor)
171
+ } catch (error: Exception) {
172
+ Log.e(TAG, "Native STX bridge failed", error)
173
+ showError("Native STX bridge failed: ${error.message}")
174
+ }
175
+ }, executor)
176
+ }
177
+
178
+ private fun show(screen: Screen) {
179
+ root.removeAllViews()
180
+ (screen.view.parent as? ViewGroup)?.removeView(screen.view)
181
+ root.addView(screen.view)
182
+ }
183
+
184
+ private fun receive(screen: Screen, raw: String) {
185
+ val message = runCatching { JSONObject(raw) }.getOrNull() ?: return
186
+ val payload = message.optJSONObject("payload") ?: JSONObject()
187
+ when (message.optString("type")) {
188
+ "RENDER" -> payload.optJSONObject("document")?.let { render(screen, it) }
189
+ "NAVIGATE", "NAVIGATE_REPLACE" -> {
190
+ if (screens.lastOrNull() !== screen) return
191
+ val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
192
+ if (message.optString("type") == "NAVIGATE_REPLACE") {
193
+ screens.removeAt(screens.lastIndex).close()
194
+ }
195
+ openScreen(route, payload.optJSONObject("params") ?: JSONObject())
196
+ }
197
+ "NAVIGATE_BACK" -> if (screens.lastOrNull() === screen) back()
198
+ "API_REQUEST" -> answerAPI(screen, message, payload)
199
+ }
200
+ }
201
+
202
+ private fun render(screen: Screen, document: JSONObject) {
203
+ // Keep live controls (especially the focused EditText) across renders.
204
+ val next = mutableMapOf<String, View>()
205
+ val tree = makeView(screen, document, "root", next)
206
+ if (screen.view.getChildAt(0) !== tree) {
207
+ (tree.parent as? ViewGroup)?.removeView(tree)
208
+ screen.view.removeAllViews()
209
+ screen.view.addView(tree)
210
+ }
211
+ screen.controls.clear()
212
+ screen.controls.putAll(next)
213
+ screen.handlers.keys.retainAll(next.values.toSet())
214
+ }
215
+
216
+ private fun makeView(screen: Screen, node: JSONObject, path: String, next: MutableMap<String, View>): View {
217
+ val type = node.optString("type", "View")
218
+ val props = node.optJSONObject("props") ?: JSONObject()
219
+ val style = node.optJSONObject("style") ?: JSONObject()
220
+ val events = node.optJSONObject("events") ?: JSONObject()
221
+ val children = node.optJSONArray("children") ?: JSONArray()
222
+ val explicitKey = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
223
+ .firstOrNull { it.isNotBlank() }
224
+ val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" } ?: path
225
+ val previous = screen.controls[identity]
226
+ val control = when (type) {
227
+ "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
228
+ text = childText(children)
229
+ setTextColor(color(style.optString("color"), defaultTextColor))
230
+ if (style.has("fontSize")) textSize = style.optDouble("fontSize").toFloat()
231
+ }
232
+ "Button" -> (previous as? Button ?: Button(this)).apply {
233
+ text = props.optString("title").ifBlank { childText(children) }
234
+ val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
235
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
236
+ setOnClickListener {
237
+ val current = screen.handlers[this] ?: return@setOnClickListener
238
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
239
+ }
240
+ }
241
+ "TextInput" -> (previous as? EditText ?: EditText(this)).apply {
242
+ hint = props.optString("placeholder")
243
+ if (props.has("value") && text.toString() != props.optString("value")) setText(props.optString("value"))
244
+ else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
245
+ val handler = events.optString("onChange").ifBlank { events.optString("onChangeText") }
246
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
247
+ if (this !== previous) addTextChangedListener(object : TextWatcher {
248
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
249
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
250
+ val updated = s?.toString().orEmpty()
251
+ screen.drafts[identity] = updated
252
+ val current = screen.handlers[this@apply] ?: return
253
+ send(screen, "EVENT", JSONObject()
254
+ .put("handlerName", current)
255
+ .put("nativeEvent", JSONObject().put("text", updated)))
256
+ }
257
+ override fun afterTextChanged(s: Editable?) {}
258
+ })
259
+ }
260
+ else -> (previous as? LinearLayout ?: LinearLayout(this)).apply {
261
+ orientation = if (style.optString("flexDirection") == "row")
262
+ LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
263
+ val padding = style.optDouble("padding", 0.0)
264
+ setPadding(
265
+ dp(style.optDouble("paddingLeft", padding)),
266
+ dp(style.optDouble("paddingTop", padding)),
267
+ dp(style.optDouble("paddingRight", padding)),
268
+ dp(style.optDouble("paddingBottom", padding))
269
+ )
270
+ val desired = mutableListOf<View>()
271
+ for (index in 0 until children.length()) {
272
+ val child = children.opt(index)
273
+ val childView = when (child) {
274
+ is JSONObject -> makeView(screen, child, "$path.$index", next)
275
+ is String -> TextView(this@MainActivity).apply {
276
+ text = child
277
+ setTextColor(defaultTextColor)
278
+ }
279
+ else -> null
280
+ }
281
+ if (childView != null) desired.add(childView)
282
+ }
283
+ val desiredSet = desired.toSet()
284
+ for ((index, childView) in desired.withIndex()) {
285
+ if (getChildAt(index) === childView) continue
286
+ if (index < childCount && getChildAt(index) !in desiredSet) removeViewAt(index)
287
+ (childView.parent as? ViewGroup)?.removeView(childView)
288
+ addView(childView, index)
289
+ }
290
+ while (childCount > desired.size) removeViewAt(childCount - 1)
291
+ }
292
+ }
293
+ control.contentDescription = explicitKey ?: path
294
+ next[identity] = control
295
+ if (style.has("backgroundColor")) control.setBackgroundColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
296
+ control.layoutParams = LinearLayout.LayoutParams(
297
+ if (style.has("width")) dp(style.optDouble("width")) else ViewGroup.LayoutParams.MATCH_PARENT,
298
+ if (style.has("height")) dp(style.optDouble("height")) else ViewGroup.LayoutParams.WRAP_CONTENT
299
+ )
300
+ return control
301
+ }
302
+
303
+ private fun childText(children: JSONArray): String = buildString {
304
+ for (index in 0 until children.length()) {
305
+ val value = children.opt(index)
306
+ if (value is String) append(value)
307
+ }
308
+ }
309
+
310
+ private fun dp(value: Double) = (value * resources.displayMetrics.density).toInt()
311
+ private fun color(raw: String, fallback: Int) = runCatching { Color.parseColor(raw) }.getOrDefault(fallback)
312
+
313
+ private fun answerAPI(screen: Screen, message: JSONObject, payload: JSONObject) {
314
+ val id = message.optString("id")
315
+ if (id.isBlank()) return
316
+ val args = payload.optJSONArray("args") ?: JSONArray()
317
+ val response = JSONObject().put("requestId", id)
318
+ val result = when (payload.optString("module") to payload.optString("method")) {
319
+ "Device" to "getInfo" -> response.put("data", JSONObject()
320
+ .put("platform", "android")
321
+ .put("osVersion", Build.VERSION.RELEASE)
322
+ .put("model", Build.MODEL)
323
+ .put("manufacturer", Build.MANUFACTURER))
324
+ "Clipboard" to "write" -> {
325
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
326
+ clipboard.setPrimaryClip(ClipData.newPlainText("", args.optString(0)))
327
+ response.put("data", JSONObject().put("ok", true))
328
+ }
329
+ "Clipboard" to "read" -> {
330
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
331
+ response.put("data", clipboard.primaryClip?.getItemAt(0)?.coerceToText(this)?.toString() ?: "")
332
+ }
333
+ "Haptics" to "impact" -> {
334
+ if (!hapticsEnabled) response.put("code", "CAPABILITY_DISABLED")
335
+ .put("message", "Haptics are disabled")
336
+ else {
337
+ val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
338
+ vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
339
+ response.put("data", JSONObject().put("ok", true))
340
+ }
341
+ }
342
+ else -> response.put("code", "UNKNOWN_ACTION").put("message", "Unsupported native API")
343
+ }
344
+ send(screen, if (result.has("code")) "API_ERROR" else "API_RESPONSE", result, id)
345
+ }
346
+
347
+ private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
348
+ if (!screen.active) return
349
+ val message = JSONObject().put("type", type).put("payload", payload)
350
+ if (correlationId != null) message.put("correlationId", correlationId)
351
+ if (screen.port != null) {
352
+ screen.port.postMessage(Message.createStringMessage(message.toString()))
353
+ } else {
354
+ val script = "globalThis.__stxNativeCallback && globalThis.__stxNativeCallback(${JSONObject.quote(message.toString())})"
355
+ screen.isolate.evaluateJavaScriptAsync(script)
356
+ }
357
+ }
358
+
359
+ private fun back() {
360
+ if (screens.size > 1) {
361
+ screens.removeAt(screens.lastIndex).close()
362
+ show(screens.last())
363
+ } else {
364
+ finish()
365
+ }
366
+ }
367
+
368
+ @Deprecated("Uses the activity back callback for native route navigation")
369
+ override fun onBackPressed() = back()
370
+
371
+ private fun showError(message: String) {
372
+ root.removeAllViews()
373
+ root.addView(TextView(this).apply {
374
+ text = message
375
+ setTextColor(Color.RED)
376
+ textSize = 16f
377
+ setPadding(24, 24, 24, 24)
378
+ })
379
+ Log.e(TAG, message)
380
+ }
381
+
382
+ override fun onDestroy() {
383
+ screens.forEach { it.close() }
384
+ screens.clear()
385
+ sandbox?.close()
386
+ super.onDestroy()
387
+ }
388
+
389
+ companion object { private const val TAG = "CraftNativeAndroid" }
390
+ }
@@ -59,6 +59,7 @@ dependencies {
59
59
  implementation("androidx.appcompat:appcompat:1.6.1")
60
60
  implementation("androidx.fragment:fragment-ktx:1.6.2")
61
61
  implementation("androidx.webkit:webkit:1.12.1")
62
+ {{JAVASCRIPT_ENGINE_DEPENDENCY}}
62
63
  implementation("com.google.android.material:material:1.11.0")
63
64
  implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
64
65
 
@@ -140,9 +140,9 @@ export interface WindowCreateOptions {
140
140
  vibrancy?: 'appearance-based' | 'light' | 'dark' | 'titlebar' | 'selection' | 'menu' | 'popover' | 'sidebar' | 'header' | 'sheet' | 'window' | 'hud' | 'fullscreen-ui' | 'tooltip' | 'content' | 'under-window' | 'under-page';
141
141
  /** Background material (Windows 11) */
142
142
  backgroundMaterial?: 'auto' | 'none' | 'mica' | 'acrylic' | 'tabbed';
143
- /** Parent window ID */
143
+ /** Native parent window: `main` means this page's own window, or use a named handle owned by this page. */
144
144
  parent?: string;
145
- /** Whether this is a modal window */
145
+ /** Requires `parent`; blocks only that parent while visible, not every app window. */
146
146
  modal?: boolean;
147
147
  /** HTML content to load */
148
148
  html?: string;
@@ -167,9 +167,8 @@ export interface WindowEventMap {
167
167
  'restore': void;
168
168
  'resize': WindowSize;
169
169
  'move': WindowPosition;
170
- 'close': {
171
- preventDefault: () => void;
172
- };
170
+ /** Notification after native closing begins; this event cannot cancel it. */
171
+ 'close': void;
173
172
  'closed': void;
174
173
  'enter-fullscreen': void;
175
174
  'leave-fullscreen': void;
@@ -404,17 +403,13 @@ export declare class Window {
404
403
  * Enable/disable window shadow (macOS)
405
404
  */
406
405
  setHasShadow(hasShadow: boolean): Promise<void>;
407
- /**
408
- * Set background material (Windows 11)
409
- */
406
+ /** Reserved for Windows 11 material support; current desktop hosts reject this action. */
410
407
  setBackgroundMaterial(material: WindowCreateOptions['backgroundMaterial']): Promise<void>;
411
408
  /**
412
409
  * Flash window in taskbar (Windows)
413
410
  */
414
411
  flashFrame(flash: boolean): Promise<void>;
415
- /**
416
- * Set taskbar overlay icon (Windows)
417
- */
412
+ /** Reserved for Windows taskbar overlay support; current desktop hosts reject this action. */
418
413
  setOverlayIcon(icon: string | null, description?: string): Promise<void>;
419
414
  /**
420
415
  * Load HTML content