craft-native 0.0.105 → 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.
- package/dist/android/src/index.d.ts +2 -0
- package/dist/android/src/index.js +33 -6
- package/dist/android/templates/MainActivityNative.kt.template +390 -0
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/window.d.ts +6 -11
- package/dist/cli.js +79 -1480
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +4 -0
- package/dist/ios/src/index.js +32 -3
- package/dist/ios/templates/CraftApp.swift +20 -92
- package/dist/ios/templates/CraftNativeActions.swift +93 -0
- package/dist/ios/templates/CraftNativeScreen.swift +381 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
585
|
-
|
|
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
|
|
package/dist/api/window.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
171
|
-
|
|
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
|