catalyst-core 0.3.0-beta.1 → 0.3.0-beta.2
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/globals.d.ts +7 -0
- package/dist/native/androidProject/app/build.gradle.kts +45 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +86 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +8 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +173 -13
- package/dist/native/androidProject/settings.gradle.kts +10 -0
- package/dist/native/bridge/constants/NativeInterfaces.js +15 -1
- package/dist/native/bridge/hooks/index.js +7 -0
- package/dist/native/bridge/hooks/useAI.js +94 -0
- package/dist/native/buildAndroid/assets.js +1 -1
- package/dist/native/buildAndroid/index.js +32 -0
- package/dist/native/buildIos/assets.js +1 -1
- package/dist/server/expressServer.js +46 -0
- package/dist/server/renderer/handler.jsx +28 -11
- package/dist/vite/vite.config.js +77 -1
- package/mcp_v2/knowledge-base.json +52 -4
- package/mcp_v2/mcp.js +36 -4
- package/mcp_v2/tools/build.js +46 -18
- package/package.json +2 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Injected by webpack DefinePlugin in base.babel.js at build time.
|
|
2
|
+
// Reflects which @catalyst/* packages are present in the app's node_modules.
|
|
3
|
+
declare const __CATALYST_PACKAGES__: {
|
|
4
|
+
readonly ai: boolean
|
|
5
|
+
readonly webAILocal: boolean
|
|
6
|
+
readonly nativeAILocal: boolean
|
|
7
|
+
}
|
|
@@ -60,6 +60,22 @@ fun isNotificationsEnabled(): Boolean {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
fun isAIEnabled(): Boolean {
|
|
64
|
+
return try {
|
|
65
|
+
if (configPath == null) return false
|
|
66
|
+
val configFile = File(configPath!!)
|
|
67
|
+
if (!configFile.exists()) return false
|
|
68
|
+
|
|
69
|
+
val json = JSONObject(configFile.readText())
|
|
70
|
+
if (!json.has("WEBVIEW_CONFIG")) return false
|
|
71
|
+
|
|
72
|
+
val webviewConfig = json.getJSONObject("WEBVIEW_CONFIG")
|
|
73
|
+
webviewConfig.optJSONObject("ai")?.optBoolean("enabled", false) ?: false
|
|
74
|
+
} catch (e: Exception) {
|
|
75
|
+
false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
fun getLocalIpAddress(): String {
|
|
64
80
|
return NetworkInterface.getNetworkInterfaces().toList()
|
|
65
81
|
.flatMap { it.inetAddresses.toList() }
|
|
@@ -151,6 +167,7 @@ android {
|
|
|
151
167
|
|
|
152
168
|
kotlinOptions {
|
|
153
169
|
jvmTarget = "11"
|
|
170
|
+
freeCompilerArgs += "-Xskip-metadata-version-check"
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
buildFeatures {
|
|
@@ -168,8 +185,9 @@ android {
|
|
|
168
185
|
excludes.add("META-INF/io.netty.versions.properties")
|
|
169
186
|
}
|
|
170
187
|
jniLibs {
|
|
171
|
-
//
|
|
172
|
-
useLegacyPackaging
|
|
188
|
+
// libLiteRtClGlAccelerator.so must be extracted to disk for GPU dlopen().
|
|
189
|
+
// This must live in the app module — AGP ignores useLegacyPackaging in library modules.
|
|
190
|
+
useLegacyPackaging = isAIEnabled()
|
|
173
191
|
}
|
|
174
192
|
}
|
|
175
193
|
|
|
@@ -186,7 +204,7 @@ android {
|
|
|
186
204
|
}
|
|
187
205
|
}
|
|
188
206
|
|
|
189
|
-
// Conditional source sets based on
|
|
207
|
+
// Conditional source sets based on feature config
|
|
190
208
|
sourceSets {
|
|
191
209
|
getByName("main") {
|
|
192
210
|
if (isNotificationsEnabled()) {
|
|
@@ -196,6 +214,20 @@ android {
|
|
|
196
214
|
logger.info("SourceSet selected: noFcm (notifications disabled)")
|
|
197
215
|
java.srcDirs("src/noFcm/java")
|
|
198
216
|
}
|
|
217
|
+
// AI has no source-set swap — CatalystAIBridge self-registers via ServiceLoader
|
|
218
|
+
// when @catalyst/cloud-ai is on the classpath.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
configurations.all {
|
|
224
|
+
resolutionStrategy.eachDependency {
|
|
225
|
+
if (requested.group == "org.jetbrains.kotlin" &&
|
|
226
|
+
(requested.name == "kotlin-stdlib" ||
|
|
227
|
+
requested.name == "kotlin-stdlib-jdk7" ||
|
|
228
|
+
requested.name == "kotlin-stdlib-jdk8" ||
|
|
229
|
+
requested.name == "kotlin-reflect")) {
|
|
230
|
+
useVersion("2.0.21")
|
|
199
231
|
}
|
|
200
232
|
}
|
|
201
233
|
}
|
|
@@ -217,7 +249,16 @@ dependencies {
|
|
|
217
249
|
implementation("io.ktor:ktor-server-core:3.0.3")
|
|
218
250
|
implementation("io.ktor:ktor-server-netty:3.0.3")
|
|
219
251
|
implementation("io.ktor:ktor-server-content-negotiation:3.0.3")
|
|
220
|
-
implementation("
|
|
252
|
+
implementation("io.ktor:ktor-server-sse:3.0.3")
|
|
253
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
|
254
|
+
|
|
255
|
+
// Native AI — compileOnly always (for types), implementation only when ai.enabled=true (for runtime bundling)
|
|
256
|
+
if (project.findProject(":catalyst-cloud-ai") != null) {
|
|
257
|
+
compileOnly(project(":catalyst-cloud-ai"))
|
|
258
|
+
if (isAIEnabled()) {
|
|
259
|
+
implementation(project(":catalyst-cloud-ai"))
|
|
260
|
+
}
|
|
261
|
+
}
|
|
221
262
|
|
|
222
263
|
// Security detection dependencies
|
|
223
264
|
implementation("com.scottyab:rootbeer-lib:0.1.1") // Root detection
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt
CHANGED
|
@@ -16,12 +16,15 @@ import androidx.credentials.exceptions.GetCredentialCancellationException
|
|
|
16
16
|
import androidx.credentials.exceptions.GetCredentialException
|
|
17
17
|
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
|
|
18
18
|
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
|
|
19
|
+
// LiteRT imports removed — AI logic lives in catalyst-ai (CatalystAIBridge plugin)
|
|
19
20
|
import io.yourname.androidproject.MainActivity
|
|
20
21
|
import io.yourname.androidproject.utils.*
|
|
21
22
|
import kotlinx.coroutines.*
|
|
22
23
|
import org.json.JSONArray
|
|
23
24
|
import org.json.JSONObject
|
|
25
|
+
import java.io.File
|
|
24
26
|
import java.util.Properties
|
|
27
|
+
import java.util.ServiceLoader
|
|
25
28
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
26
29
|
|
|
27
30
|
private data class FilePickerOptions(
|
|
@@ -151,6 +154,61 @@ class NativeBridge(
|
|
|
151
154
|
|
|
152
155
|
private var networkMonitor: NetworkMonitor? = null
|
|
153
156
|
|
|
157
|
+
// AI plugin — loaded via reflection so NativeBridge.kt compiles with no compile-time dep on
|
|
158
|
+
// :catalyst-ai. When the module is absent the try block returns null gracefully.
|
|
159
|
+
private val aiBridge: Any? = try {
|
|
160
|
+
@Suppress("UNCHECKED_CAST")
|
|
161
|
+
run {
|
|
162
|
+
val bridgeClass = Class.forName("io.catalyst.nativeai.AIBridge")
|
|
163
|
+
val callbacksClass = Class.forName("io.catalyst.nativeai.AIBridgeCallbacks")
|
|
164
|
+
@Suppress("UNCHECKED_CAST")
|
|
165
|
+
val loader = ServiceLoader.load(bridgeClass as Class<Any>)
|
|
166
|
+
val bridge = loader.iterator().takeIf { it.hasNext() }?.next() ?: return@run null
|
|
167
|
+
|
|
168
|
+
val callbacksProxy = java.lang.reflect.Proxy.newProxyInstance(
|
|
169
|
+
bridgeClass.classLoader,
|
|
170
|
+
arrayOf(callbacksClass)
|
|
171
|
+
) { _, method, args ->
|
|
172
|
+
val a = args ?: emptyArray()
|
|
173
|
+
when (method.name) {
|
|
174
|
+
"onReady" -> {
|
|
175
|
+
val payload = org.json.JSONObject().apply {
|
|
176
|
+
put("url", a[0]); put("port", a[1]); put("sessionId", a[2])
|
|
177
|
+
}
|
|
178
|
+
BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_AI_READY, payload)
|
|
179
|
+
}
|
|
180
|
+
"onProgress" -> {
|
|
181
|
+
val payload = org.json.JSONObject().apply {
|
|
182
|
+
put("phase", a[0]); put("percent", a[1])
|
|
183
|
+
put("bytesLoaded", a[2]); put("bytesTotal", a[3]); put("detail", a[4])
|
|
184
|
+
}
|
|
185
|
+
BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_AI_PROGRESS, payload)
|
|
186
|
+
}
|
|
187
|
+
"onLog" -> {
|
|
188
|
+
val payload = org.json.JSONObject().apply { put("message", a[0]) }
|
|
189
|
+
BridgeUtils.notifyWebJson(webView, BridgeUtils.WebEvents.ON_AI_LOG, payload)
|
|
190
|
+
}
|
|
191
|
+
"onError" -> BridgeUtils.notifyWebError(webView, BridgeUtils.WebEvents.ON_AI_ERROR, a[0] as String)
|
|
192
|
+
"isFrameworkServerRunning" -> FrameworkServerUtils.isRunning()
|
|
193
|
+
"getFrameworkServerPort" -> FrameworkServerUtils.getServerPort()
|
|
194
|
+
"getFrameworkServerSessionId" -> FrameworkServerUtils.getSessionId()
|
|
195
|
+
"setNativeAiSupplier" -> {
|
|
196
|
+
@Suppress("UNCHECKED_CAST")
|
|
197
|
+
FrameworkServerUtils.setNativeAiSupplier(
|
|
198
|
+
a[0] as suspend (String, org.json.JSONObject, String?) -> Pair<String, kotlinx.coroutines.flow.Flow<String>>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
"setNativeSystemPrompt" -> FrameworkServerUtils.setNativeSystemPrompt(a[0] as String)
|
|
202
|
+
else -> null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
bridge.javaClass.getMethod("attach", android.app.Activity::class.java, android.webkit.WebView::class.java, callbacksClass)
|
|
207
|
+
.invoke(bridge, mainActivity, webView, callbacksProxy)
|
|
208
|
+
bridge
|
|
209
|
+
}
|
|
210
|
+
} catch (_: Throwable) { null }
|
|
211
|
+
|
|
154
212
|
// Video stream manager
|
|
155
213
|
private var nativeCameraManager: NativeCameraManager? = null
|
|
156
214
|
private var webViewOriginalBackground: Int = android.graphics.Color.WHITE
|
|
@@ -1301,6 +1359,34 @@ class NativeBridge(
|
|
|
1301
1359
|
}
|
|
1302
1360
|
}
|
|
1303
1361
|
|
|
1362
|
+
// -------------------------------------------------------------------------
|
|
1363
|
+
// Native AI bridge methods — delegated to AIBridge plugin (ServiceLoader)
|
|
1364
|
+
// -------------------------------------------------------------------------
|
|
1365
|
+
|
|
1366
|
+
@JavascriptInterface
|
|
1367
|
+
fun isAIAvailable(): Boolean = aiBridge != null
|
|
1368
|
+
|
|
1369
|
+
@JavascriptInterface
|
|
1370
|
+
fun initAI(optionsRaw: String?) {
|
|
1371
|
+
BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_AI_ERROR, "initialize AI") {
|
|
1372
|
+
aiBridge?.javaClass?.getMethod("initAI", String::class.java)?.invoke(aiBridge, optionsRaw)
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
@JavascriptInterface
|
|
1377
|
+
fun generateNative(optionsRaw: String?) {
|
|
1378
|
+
BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_AI_ERROR, "generate with native AI") {
|
|
1379
|
+
aiBridge?.javaClass?.getMethod("generateNative", String::class.java)?.invoke(aiBridge, optionsRaw)
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
@JavascriptInterface
|
|
1384
|
+
fun clearNativeConversation() {
|
|
1385
|
+
BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_AI_ERROR, "clear native conversation") {
|
|
1386
|
+
aiBridge?.javaClass?.getMethod("clearConversation")?.invoke(aiBridge)
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1304
1390
|
/**
|
|
1305
1391
|
* Cleanup method to be called when the bridge is being destroyed
|
|
1306
1392
|
*/
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt
CHANGED
|
@@ -97,6 +97,14 @@ object BridgeUtils {
|
|
|
97
97
|
ON_TRANSITION_CANCELLED("ON_TRANSITION_CANCELLED"),
|
|
98
98
|
ON_TRANSITION_TIMEOUT("ON_TRANSITION_TIMEOUT"),
|
|
99
99
|
|
|
100
|
+
// Native AI events
|
|
101
|
+
ON_AI_READY("ON_AI_READY"),
|
|
102
|
+
ON_AI_ERROR("ON_AI_ERROR"),
|
|
103
|
+
ON_AI_TOKEN("ON_AI_TOKEN"),
|
|
104
|
+
ON_AI_DONE("ON_AI_DONE"),
|
|
105
|
+
ON_AI_PROGRESS("ON_AI_PROGRESS"),
|
|
106
|
+
ON_AI_LOG("ON_AI_LOG"),
|
|
107
|
+
|
|
100
108
|
// Perf telemetry
|
|
101
109
|
CATALYST_PERF_EVENT("CATALYST_PERF_EVENT"),
|
|
102
110
|
}
|
|
@@ -7,15 +7,21 @@ import io.ktor.http.*
|
|
|
7
7
|
import io.ktor.server.application.*
|
|
8
8
|
import io.ktor.server.engine.*
|
|
9
9
|
import io.ktor.server.netty.*
|
|
10
|
+
import io.ktor.server.request.*
|
|
10
11
|
import io.ktor.server.response.*
|
|
11
12
|
import io.ktor.server.routing.*
|
|
13
|
+
import io.ktor.http.ContentType
|
|
14
|
+
import io.ktor.server.response.respondTextWriter
|
|
15
|
+
import io.ktor.server.sse.*
|
|
12
16
|
import kotlinx.coroutines.*
|
|
17
|
+
import kotlinx.coroutines.flow.Flow
|
|
18
|
+
import org.json.JSONObject
|
|
13
19
|
import java.io.File
|
|
14
20
|
import java.io.IOException
|
|
15
21
|
import java.net.InetSocketAddress
|
|
16
22
|
import java.nio.channels.ServerSocketChannel
|
|
17
23
|
import java.security.SecureRandom
|
|
18
|
-
import java.util
|
|
24
|
+
import java.util.UUID
|
|
19
25
|
import java.util.concurrent.ConcurrentHashMap
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -36,6 +42,7 @@ object FrameworkServerUtils {
|
|
|
36
42
|
private const val FRAMEWORK_PORT_RANGE_START = 3000
|
|
37
43
|
private const val FRAMEWORK_PORT_RANGE_END = 3099
|
|
38
44
|
private const val SESSION_TIMEOUT_MS = 30 * 60 * 1000L // 30 minutes
|
|
45
|
+
private const val AI_GENERATE_TIMEOUT_MS = 120 * 1000L // 2 minutes
|
|
39
46
|
|
|
40
47
|
// Server state
|
|
41
48
|
private var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
|
|
@@ -49,7 +56,14 @@ object FrameworkServerUtils {
|
|
|
49
56
|
// File management
|
|
50
57
|
private val servedFiles = ConcurrentHashMap<String, ServedFile>()
|
|
51
58
|
private var cacheDirectory: File? = null
|
|
52
|
-
|
|
59
|
+
|
|
60
|
+
// Native AI — supplier set by NativeBridge before POST /ai/stream is called
|
|
61
|
+
// Returns (conversationId, tokenFlow) so the route can emit conversationId as an SSE event
|
|
62
|
+
private var nativeAiSupplier: (suspend (prompt: String, genConfig: JSONObject, conversationId: String?) -> Pair<String, Flow<String>>)? = null
|
|
63
|
+
|
|
64
|
+
// System prompt injected into every native AI request (built from attachmentComponents at initAI time)
|
|
65
|
+
private var nativeSystemPrompt: String = ""
|
|
66
|
+
|
|
53
67
|
// Coroutine scope for server operations
|
|
54
68
|
private val serverScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
55
69
|
|
|
@@ -60,7 +74,7 @@ object FrameworkServerUtils {
|
|
|
60
74
|
val fileId: String,
|
|
61
75
|
val createdAt: Long = System.currentTimeMillis()
|
|
62
76
|
)
|
|
63
|
-
|
|
77
|
+
|
|
64
78
|
/**
|
|
65
79
|
* Initialize and start the framework server
|
|
66
80
|
* @param context Android context for file operations
|
|
@@ -223,7 +237,21 @@ object FrameworkServerUtils {
|
|
|
223
237
|
* Get current session ID
|
|
224
238
|
*/
|
|
225
239
|
fun getSessionId(): String = sessionId
|
|
226
|
-
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Register the native AI supplier. Called by NativeBridge once LiteRT-LM is ready.
|
|
243
|
+
* The supplier receives the prompt, gen-config, and optional incomingConversationId,
|
|
244
|
+
* and returns (activeConversationId, tokenFlow) so stateful sessions can be chained.
|
|
245
|
+
*/
|
|
246
|
+
fun setNativeAiSupplier(supplier: suspend (prompt: String, genConfig: JSONObject, conversationId: String?) -> Pair<String, Flow<String>>) {
|
|
247
|
+
nativeAiSupplier = supplier
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fun setNativeSystemPrompt(systemPrompt: String) {
|
|
251
|
+
nativeSystemPrompt = systemPrompt
|
|
252
|
+
Log.d(TAG, "System prompt set (${systemPrompt.length} chars)")
|
|
253
|
+
}
|
|
254
|
+
|
|
227
255
|
// Private implementation methods
|
|
228
256
|
|
|
229
257
|
private fun initializeCacheDirectory(context: Context) {
|
|
@@ -291,7 +319,145 @@ object FrameworkServerUtils {
|
|
|
291
319
|
|
|
292
320
|
private fun startKtorServer() {
|
|
293
321
|
server = embeddedServer(Netty, port = serverPort) {
|
|
322
|
+
install(SSE)
|
|
294
323
|
routing {
|
|
324
|
+
// Native AI streaming endpoint — POST body: { "prompt": "...", "genConfig": {...} }
|
|
325
|
+
// Writes SSE manually over chunked response (Ktor SSE plugin only supports GET).
|
|
326
|
+
post("/framework-$sessionId/ai/stream") {
|
|
327
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
328
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
329
|
+
call.response.header(HttpHeaders.ContentType, "text/event-stream")
|
|
330
|
+
call.response.header(HttpHeaders.CacheControl, "no-cache")
|
|
331
|
+
call.response.header("X-Accel-Buffering", "no")
|
|
332
|
+
|
|
333
|
+
val body = try {
|
|
334
|
+
JSONObject(call.receiveText())
|
|
335
|
+
} catch (e: Exception) {
|
|
336
|
+
JSONObject()
|
|
337
|
+
}
|
|
338
|
+
val userPrompt = body.optString("prompt", "")
|
|
339
|
+
val genConfig = body.optJSONObject("genConfig") ?: JSONObject()
|
|
340
|
+
val incomingConvId = body.optString("conversationId", "").takeIf { it.isNotBlank() }
|
|
341
|
+
if (nativeSystemPrompt.isNotBlank()) Log.d(TAG, "Injecting system prompt (${nativeSystemPrompt.length} chars)")
|
|
342
|
+
val prompt = if (nativeSystemPrompt.isNotBlank()) "$nativeSystemPrompt\n\n$userPrompt" else userPrompt
|
|
343
|
+
|
|
344
|
+
call.respondTextWriter(contentType = ContentType.Text.EventStream) {
|
|
345
|
+
val supplier = nativeAiSupplier
|
|
346
|
+
if (supplier == null) {
|
|
347
|
+
write("data: {\"error\":\"Native AI not initialised — call initAI() first\"}\n\n")
|
|
348
|
+
flush()
|
|
349
|
+
} else if (prompt.isBlank()) {
|
|
350
|
+
write("data: {\"error\":\"prompt is empty\"}\n\n")
|
|
351
|
+
flush()
|
|
352
|
+
} else {
|
|
353
|
+
try {
|
|
354
|
+
val (activeConvId, tokenFlow) = supplier(prompt, genConfig, incomingConvId)
|
|
355
|
+
write("data: {\"conversationId\":${JSONObject.quote(activeConvId)}}\n\n")
|
|
356
|
+
flush()
|
|
357
|
+
tokenFlow.collect { token ->
|
|
358
|
+
write("data: {\"token\":${JSONObject.quote(token)}}\n\n")
|
|
359
|
+
flush()
|
|
360
|
+
}
|
|
361
|
+
write("data: {\"done\":true}\n\n")
|
|
362
|
+
flush()
|
|
363
|
+
} catch (e: java.io.IOException) {
|
|
364
|
+
// Client disconnected — channel closed, nothing to write
|
|
365
|
+
Log.d(TAG, "Native AI stream: client disconnected (${e.message})")
|
|
366
|
+
} catch (e: Exception) {
|
|
367
|
+
Log.e(TAG, "Native AI stream error", e)
|
|
368
|
+
try {
|
|
369
|
+
write("data: {\"error\":${JSONObject.quote(e.message ?: "stream error")}}\n\n")
|
|
370
|
+
flush()
|
|
371
|
+
} catch (_: java.io.IOException) {
|
|
372
|
+
// Channel already closed, ignore
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// OPTIONS preflight for ai/stream
|
|
380
|
+
options("/framework-$sessionId/ai/stream") {
|
|
381
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
382
|
+
call.response.header(HttpHeaders.AccessControlAllowMethods, "POST, OPTIONS")
|
|
383
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
384
|
+
call.response.header(HttpHeaders.AccessControlMaxAge, "86400")
|
|
385
|
+
call.respond(HttpStatusCode.OK)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Native AI non-streaming endpoint — POST body: { "prompt": "...", "genConfig": {...} }
|
|
389
|
+
// Drains nativeAiSupplier's tokenFlow fully, then returns a single JSON response.
|
|
390
|
+
post("/framework-$sessionId/ai/generate") {
|
|
391
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
392
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
393
|
+
|
|
394
|
+
val body = try {
|
|
395
|
+
JSONObject(call.receiveText())
|
|
396
|
+
} catch (e: Exception) {
|
|
397
|
+
JSONObject()
|
|
398
|
+
}
|
|
399
|
+
val userPrompt = body.optString("prompt", "")
|
|
400
|
+
val genConfig = body.optJSONObject("genConfig") ?: JSONObject()
|
|
401
|
+
val incomingConvId = body.optString("conversationId", "").takeIf { it.isNotBlank() }
|
|
402
|
+
if (nativeSystemPrompt.isNotBlank()) Log.d(TAG, "Injecting system prompt (${nativeSystemPrompt.length} chars)")
|
|
403
|
+
val prompt = if (nativeSystemPrompt.isNotBlank()) "$nativeSystemPrompt\n\n$userPrompt" else userPrompt
|
|
404
|
+
|
|
405
|
+
val supplier = nativeAiSupplier
|
|
406
|
+
if (supplier == null) {
|
|
407
|
+
call.respondText(
|
|
408
|
+
JSONObject().put("error", "Native AI not initialised — call initAI() first").toString(),
|
|
409
|
+
ContentType.Application.Json,
|
|
410
|
+
HttpStatusCode.ServiceUnavailable
|
|
411
|
+
)
|
|
412
|
+
return@post
|
|
413
|
+
}
|
|
414
|
+
if (prompt.isBlank()) {
|
|
415
|
+
call.respondText(
|
|
416
|
+
JSONObject().put("error", "prompt is empty").toString(),
|
|
417
|
+
ContentType.Application.Json,
|
|
418
|
+
HttpStatusCode.BadRequest
|
|
419
|
+
)
|
|
420
|
+
return@post
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
val (activeConvId, tokenFlow) = supplier(prompt, genConfig, incomingConvId)
|
|
425
|
+
val output = StringBuilder()
|
|
426
|
+
var tokenCount = 0
|
|
427
|
+
withTimeout(AI_GENERATE_TIMEOUT_MS) {
|
|
428
|
+
tokenFlow.collect { token -> output.append(token); tokenCount++ }
|
|
429
|
+
}
|
|
430
|
+
val responseJson = JSONObject()
|
|
431
|
+
.put("output", output.toString())
|
|
432
|
+
.put("conversationId", activeConvId)
|
|
433
|
+
.put("tokenCount", tokenCount)
|
|
434
|
+
call.respondText(responseJson.toString(), ContentType.Application.Json, HttpStatusCode.OK)
|
|
435
|
+
} catch (e: TimeoutCancellationException) {
|
|
436
|
+
Log.e(TAG, "Native AI generate timed out after ${AI_GENERATE_TIMEOUT_MS}ms", e)
|
|
437
|
+
call.respondText(
|
|
438
|
+
JSONObject().put("error", "generate timed out after ${AI_GENERATE_TIMEOUT_MS / 1000}s").toString(),
|
|
439
|
+
ContentType.Application.Json,
|
|
440
|
+
HttpStatusCode.GatewayTimeout
|
|
441
|
+
)
|
|
442
|
+
} catch (e: Exception) {
|
|
443
|
+
Log.e(TAG, "Native AI generate error", e)
|
|
444
|
+
call.respondText(
|
|
445
|
+
JSONObject().put("error", (e.message ?: "generate error")).toString(),
|
|
446
|
+
ContentType.Application.Json,
|
|
447
|
+
HttpStatusCode.InternalServerError
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// OPTIONS preflight for ai/generate
|
|
453
|
+
options("/framework-$sessionId/ai/generate") {
|
|
454
|
+
call.response.header(HttpHeaders.AccessControlAllowOrigin, allowedOrigin)
|
|
455
|
+
call.response.header(HttpHeaders.AccessControlAllowMethods, "POST, OPTIONS")
|
|
456
|
+
call.response.header(HttpHeaders.AccessControlAllowHeaders, "*")
|
|
457
|
+
call.response.header(HttpHeaders.AccessControlMaxAge, "86400")
|
|
458
|
+
call.respond(HttpStatusCode.OK)
|
|
459
|
+
}
|
|
460
|
+
|
|
295
461
|
get("/framework-$sessionId/file-{fileId}") {
|
|
296
462
|
val fileId = call.parameters["fileId"]
|
|
297
463
|
|
|
@@ -385,22 +551,16 @@ object FrameworkServerUtils {
|
|
|
385
551
|
val expiredFiles = servedFiles.filter { (_, servedFile) ->
|
|
386
552
|
currentTime - servedFile.createdAt > SESSION_TIMEOUT_MS
|
|
387
553
|
}
|
|
388
|
-
|
|
389
554
|
if (expiredFiles.isNotEmpty()) {
|
|
390
555
|
Log.d(TAG, "Cleaning up ${expiredFiles.size} expired files")
|
|
391
|
-
|
|
392
|
-
expiredFiles.forEach { (fileId, _) ->
|
|
393
|
-
removeServedFile(fileId)
|
|
394
|
-
}
|
|
556
|
+
expiredFiles.forEach { (fileId, _) -> removeServedFile(fileId) }
|
|
395
557
|
}
|
|
558
|
+
|
|
396
559
|
}
|
|
397
560
|
|
|
398
561
|
private fun cleanupAllFiles() {
|
|
399
562
|
Log.d(TAG, "Cleaning up all served files (${servedFiles.size} files)")
|
|
400
|
-
|
|
401
|
-
servedFiles.keys.toList().forEach { fileId ->
|
|
402
|
-
removeServedFile(fileId)
|
|
403
|
-
}
|
|
563
|
+
servedFiles.keys.toList().forEach { fileId -> removeServedFile(fileId) }
|
|
404
564
|
|
|
405
565
|
// Clean cache directory
|
|
406
566
|
cacheDirectory?.listFiles()?.forEach { file ->
|
|
@@ -33,3 +33,13 @@ dependencyResolutionManagement {
|
|
|
33
33
|
|
|
34
34
|
rootProject.name = "Catalyst Application"
|
|
35
35
|
include(":app")
|
|
36
|
+
|
|
37
|
+
// @catalyst/cloud-ai — include when the package is installed.
|
|
38
|
+
// The npm package ships an /android library module for native AI; wire it here so Gradle picks it up.
|
|
39
|
+
// After running: npm install @catalyst/cloud-ai && npm run sync-packages -- --packages cloud-ai
|
|
40
|
+
// Also set ai.enabled=true in your app config to enable useLegacyPackaging for GPU dlopen().
|
|
41
|
+
val cloudAiDir = File(rootDir, "../node_modules/@catalyst/cloud-ai/android")
|
|
42
|
+
if (cloudAiDir.exists()) {
|
|
43
|
+
include(":catalyst-cloud-ai")
|
|
44
|
+
project(":catalyst-cloud-ai").projectDir = cloudAiDir
|
|
45
|
+
}
|
|
@@ -48,7 +48,10 @@ const NATIVE_COMMANDS = exports.NATIVE_COMMANDS = {
|
|
|
48
48
|
// Security commands
|
|
49
49
|
SET_SCREEN_SECURE: "setScreenSecure",
|
|
50
50
|
GET_SCREEN_SECURE: "getScreenSecure",
|
|
51
|
-
CLEAR_WEB_DATA: "clearWebData"
|
|
51
|
+
CLEAR_WEB_DATA: "clearWebData",
|
|
52
|
+
// Native AI commands
|
|
53
|
+
INIT_AI: "initAI",
|
|
54
|
+
GENERATE_NATIVE: "generateNative"
|
|
52
55
|
};
|
|
53
56
|
|
|
54
57
|
// Callback Interfaces: Native → Web (incoming events/callbacks)
|
|
@@ -105,6 +108,13 @@ const NATIVE_CALLBACKS = exports.NATIVE_CALLBACKS = {
|
|
|
105
108
|
ON_SCREEN_SECURE_ERROR: "ON_SCREEN_SECURE_ERROR",
|
|
106
109
|
ON_WEB_DATA_CLEARED: "ON_WEB_DATA_CLEARED",
|
|
107
110
|
ON_WEB_DATA_CLEAR_ERROR: "ON_WEB_DATA_CLEAR_ERROR",
|
|
111
|
+
// Native AI callbacks
|
|
112
|
+
ON_AI_READY: "ON_AI_READY",
|
|
113
|
+
ON_AI_TOKEN: "ON_AI_TOKEN",
|
|
114
|
+
ON_AI_DONE: "ON_AI_DONE",
|
|
115
|
+
ON_AI_ERROR: "ON_AI_ERROR",
|
|
116
|
+
ON_AI_PROGRESS: "ON_AI_PROGRESS",
|
|
117
|
+
ON_AI_LOG: "ON_AI_LOG",
|
|
108
118
|
// Perf telemetry callbacks (native → web)
|
|
109
119
|
CATALYST_PERF_EVENT: "CATALYST_PERF_EVENT"
|
|
110
120
|
};
|
|
@@ -150,6 +160,10 @@ const INTERFACE_CATEGORIES = exports.INTERFACE_CATEGORIES = {
|
|
|
150
160
|
TRANSITION: {
|
|
151
161
|
commands: [NATIVE_COMMANDS.START_TRANSITION, NATIVE_COMMANDS.COMMIT_TRANSITION, NATIVE_COMMANDS.CANCEL_TRANSITION],
|
|
152
162
|
callbacks: [NATIVE_CALLBACKS.ON_TRANSITION_COMMITTED, NATIVE_CALLBACKS.ON_TRANSITION_CANCELLED, NATIVE_CALLBACKS.ON_TRANSITION_TIMEOUT]
|
|
163
|
+
},
|
|
164
|
+
AI: {
|
|
165
|
+
commands: [NATIVE_COMMANDS.INIT_AI, NATIVE_COMMANDS.GENERATE_NATIVE],
|
|
166
|
+
callbacks: [NATIVE_CALLBACKS.ON_AI_READY, NATIVE_CALLBACKS.ON_AI_TOKEN, NATIVE_CALLBACKS.ON_AI_DONE, NATIVE_CALLBACKS.ON_AI_ERROR, NATIVE_CALLBACKS.ON_AI_PROGRESS, NATIVE_CALLBACKS.ON_AI_LOG]
|
|
153
167
|
}
|
|
154
168
|
};
|
|
155
169
|
|
|
@@ -27,6 +27,12 @@ Object.defineProperty(exports, "sanitizeFilePickerOptions", {
|
|
|
27
27
|
return _useFilePicker.sanitizeFilePickerOptions;
|
|
28
28
|
}
|
|
29
29
|
});
|
|
30
|
+
Object.defineProperty(exports, "useAI", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
get: function () {
|
|
33
|
+
return _useAI.useAI;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
30
36
|
Object.defineProperty(exports, "useCamera", {
|
|
31
37
|
enumerable: true,
|
|
32
38
|
get: function () {
|
|
@@ -111,6 +117,7 @@ Object.defineProperty(exports, "useVideoStream", {
|
|
|
111
117
|
return _useVideoStream.useVideoStream;
|
|
112
118
|
}
|
|
113
119
|
});
|
|
120
|
+
var _useAI = require("./useAI.js");
|
|
114
121
|
var _useGoogleSignIn = require("./useGoogleSignIn.js");
|
|
115
122
|
var _useCamera = require("./useCamera.js");
|
|
116
123
|
var _useIntent = require("./useIntent.js");
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useAI = useAI;
|
|
7
|
+
/* global __CATALYST_PACKAGES__ */
|
|
8
|
+
|
|
9
|
+
function getBrowserConfig() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = process.env.AI_PUBLIC_CONFIG;
|
|
12
|
+
if (!raw) return null;
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return parsed?.browser || null;
|
|
15
|
+
} catch (_) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function resolveMode(provider) {
|
|
20
|
+
if (provider === "transformers") return "local";
|
|
21
|
+
if (provider === "native") return "native";
|
|
22
|
+
return "cloud";
|
|
23
|
+
}
|
|
24
|
+
function isNativeAIAvailable() {
|
|
25
|
+
const nb = typeof window !== "undefined" && window.NativeBridge;
|
|
26
|
+
return nb && typeof nb.isAIAvailable === "function" && nb.isAIAvailable();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// __CATALYST_PACKAGES__ is substituted by webpack's DefinePlugin / Vite's `define` in
|
|
30
|
+
// client bundles only. catalyst-core is externalized (nodeExternals) from the SSR
|
|
31
|
+
// bundle, so on the server this file is loaded directly by Node — including via Vite's
|
|
32
|
+
// ssrLoadModule, which executes real ESM with no bundler transform and no `require`
|
|
33
|
+
// shim. useAI() always discards the SSR result anyway (returns emptyHook() when
|
|
34
|
+
// `window` is undefined), so only ever attempt the client-side `require` — a bare
|
|
35
|
+
// `require()` reached during SSR would throw ReferenceError rather than a catchable
|
|
36
|
+
// MODULE_NOT_FOUND, since this package is "type": "module".
|
|
37
|
+
const _pkg = typeof window !== "undefined" && (typeof __CATALYST_PACKAGES__ === "undefined" || __CATALYST_PACKAGES__.ai) ? (() => {
|
|
38
|
+
try {
|
|
39
|
+
return require(/* webpackIgnore: true */"catalyst-ai");
|
|
40
|
+
} catch (_) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
})() : null;
|
|
44
|
+
|
|
45
|
+
// provider: "transformers" → useWebAI (catalyst-ai)
|
|
46
|
+
// "native" → useNativeAI (catalyst-ai, falls back to useCloudAI if bridge unavailable)
|
|
47
|
+
// anything else → useCloudAI (catalyst-ai, default)
|
|
48
|
+
function useAI(options = {}) {
|
|
49
|
+
const {
|
|
50
|
+
provider
|
|
51
|
+
} = options;
|
|
52
|
+
const config = getBrowserConfig();
|
|
53
|
+
const resolvedProvider = provider || config?.provider;
|
|
54
|
+
const mode = resolveMode(resolvedProvider);
|
|
55
|
+
const cloudResult = _pkg ? _pkg.useCloudAI(options) : emptyHook();
|
|
56
|
+
const webResult = _pkg ? _pkg.useWebAI(options) : emptyHook();
|
|
57
|
+
const nativeResult = _pkg ? _pkg.useNativeAI({
|
|
58
|
+
...options,
|
|
59
|
+
enabled: mode === "native"
|
|
60
|
+
}) : emptyHook();
|
|
61
|
+
if (typeof window === "undefined") return emptyHook();
|
|
62
|
+
if (!_pkg) {
|
|
63
|
+
console.error("\n[catalyst-core] useAI requires catalyst-ai.\n" + "Run: npm install catalyst-ai\n");
|
|
64
|
+
return emptyHook();
|
|
65
|
+
}
|
|
66
|
+
if (mode === "local") return webResult;
|
|
67
|
+
if (mode === "native" && isNativeAIAvailable()) return nativeResult;
|
|
68
|
+
return cloudResult;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Returned when catalyst-ai is missing — keeps hook shape stable so callers don't crash
|
|
72
|
+
function emptyHook() {
|
|
73
|
+
return {
|
|
74
|
+
output: "",
|
|
75
|
+
streaming: false,
|
|
76
|
+
loading: false,
|
|
77
|
+
error: null,
|
|
78
|
+
modelReady: false,
|
|
79
|
+
downloadProgress: null,
|
|
80
|
+
nativeDownloadProgress: null,
|
|
81
|
+
nativeLogs: [],
|
|
82
|
+
metrics: null,
|
|
83
|
+
isLocal: false,
|
|
84
|
+
isNative: false,
|
|
85
|
+
isWeb: false,
|
|
86
|
+
generate: () => {},
|
|
87
|
+
cancel: () => {},
|
|
88
|
+
reset: () => {},
|
|
89
|
+
clearError: () => {},
|
|
90
|
+
conversationId: null,
|
|
91
|
+
getSessionMetrics: () => null,
|
|
92
|
+
resetSessionMetrics: () => {}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -494,7 +494,7 @@ function createAssetsPhase(ctx) {
|
|
|
494
494
|
let manifestContent = fs.readFileSync(manifestPath, "utf8");
|
|
495
495
|
const metadataNames = ["default_notification_channel_id", "default_notification_icon", "default_notification_color", "com.google.firebase.messaging.default_notification_channel_id", "com.google.firebase.messaging.default_notification_icon", "com.google.firebase.messaging.default_notification_sound"];
|
|
496
496
|
for (const metadataName of metadataNames) {
|
|
497
|
-
const metadataRegex = new RegExp(`\\s*<meta-data\\s+[
|
|
497
|
+
const metadataRegex = new RegExp(`\\s*<meta-data\\s+[^>]*?android:name="${metadataName}"[^>]*?\\/>`, "gi");
|
|
498
498
|
manifestContent = manifestContent.replace(metadataRegex, "");
|
|
499
499
|
}
|
|
500
500
|
const serviceRegex = /\s*<!--\s*Push Notification Service\s*-->\s*<service[^>]*android:name="[^"]*PushNotificationUtils"[\s\S]*?<\/service>/gi;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const path = require("path");
|
|
4
|
+
const fs = require("fs");
|
|
4
5
|
const {
|
|
5
6
|
runCommand,
|
|
6
7
|
runInteractiveCommand
|
|
@@ -75,6 +76,35 @@ function createAndroidBuild(config) {
|
|
|
75
76
|
moveApkToOutputPath,
|
|
76
77
|
buildSignedAAB
|
|
77
78
|
} = createBuildPhase(ctx);
|
|
79
|
+
function syncAIPackageIfEnabled(wvConfig, progress) {
|
|
80
|
+
const aiEnabled = wvConfig?.ai?.enabled === true;
|
|
81
|
+
if (!aiEnabled) return;
|
|
82
|
+
const appNodeModules = path.join(process.cwd(), "node_modules");
|
|
83
|
+
const aiPackageSrc = path.join(appNodeModules, "catalyst-ai");
|
|
84
|
+
if (!fs.existsSync(aiPackageSrc)) {
|
|
85
|
+
progress.log("ai.enabled=true but catalyst-ai not found in node_modules — skipping native AI sync", "warning");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const destDir = path.join(pwd, "node_modules", "catalyst-ai");
|
|
89
|
+
fs.mkdirSync(destDir, {
|
|
90
|
+
recursive: true
|
|
91
|
+
});
|
|
92
|
+
copyDirSync(aiPackageSrc, destDir);
|
|
93
|
+
progress.log("catalyst-ai synced to dist/native/node_modules for Gradle", "success");
|
|
94
|
+
}
|
|
95
|
+
function copyDirSync(src, dest) {
|
|
96
|
+
fs.mkdirSync(dest, {
|
|
97
|
+
recursive: true
|
|
98
|
+
});
|
|
99
|
+
for (const entry of fs.readdirSync(src, {
|
|
100
|
+
withFileTypes: true
|
|
101
|
+
})) {
|
|
102
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
103
|
+
const s = path.join(src, entry.name); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
104
|
+
const d = path.join(dest, entry.name); // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
105
|
+
entry.isDirectory() ? copyDirSync(s, d) : fs.copyFileSync(s, d);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
78
108
|
async function buildAndroidApp() {
|
|
79
109
|
let androidConfig = null;
|
|
80
110
|
let targetDevice = null;
|
|
@@ -133,6 +163,7 @@ function createAndroidBuild(config) {
|
|
|
133
163
|
});
|
|
134
164
|
await processNotifications(wvConfig);
|
|
135
165
|
progress.log(`Build optimization: ${buildOptimisation ? "Enabled" : "Disabled"}`, "info");
|
|
166
|
+
syncAIPackageIfEnabled(wvConfig, progress);
|
|
136
167
|
progress.complete("copyAssets");
|
|
137
168
|
let movedApkPath = null;
|
|
138
169
|
if (buildType === "release") {
|
|
@@ -352,6 +383,7 @@ function createAndroidBuild(config) {
|
|
|
352
383
|
log: (message, status = "info") => progress.log(message, status)
|
|
353
384
|
});
|
|
354
385
|
await processNotifications(wvConfig);
|
|
386
|
+
syncAIPackageIfEnabled(wvConfig, progress);
|
|
355
387
|
progress.log("✅ buildAndroidForTesting complete — project ready for gradlew test", "success");
|
|
356
388
|
return {
|
|
357
389
|
success: true
|
|
@@ -123,7 +123,7 @@ module.exports = function createAssetsPhase(ctx) {
|
|
|
123
123
|
|
|
124
124
|
// ─── Notification assets ─────────────────────────────────────────────────
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
function validateNotificationAsset(filePath, assetType) {
|
|
127
127
|
const stats = fs.statSync(filePath);
|
|
128
128
|
const fileSizeKB = stats.size / 1024;
|
|
129
129
|
if (assetType === "icon" && fileSizeKB > 100) progress.log(`Warning: Notification icon ${path.basename(filePath)} is ${fileSizeKB.toFixed(1)}KB (recommended <100KB for optimal performance)`, "warning");else if (assetType === "sound" && fileSizeKB > 1024) progress.log(`Warning: Notification sound ${path.basename(filePath)} is ${fileSizeKB.toFixed(1)}KB (recommended <1MB)`, "warning");
|
|
@@ -12,8 +12,52 @@ const { cyan, yellow, green } = pc
|
|
|
12
12
|
|
|
13
13
|
import { validateMiddleware, safeCall } from "./utils/validator.js"
|
|
14
14
|
import { botDetectionMiddleware } from "./utils/botDetectionMiddleware.js"
|
|
15
|
+
import { cjsRequire } from "./utils/cjsRequire.js"
|
|
15
16
|
const { addMiddlewares } = await import(path.join(process.env.src_path, "server/server.js"))
|
|
16
17
|
|
|
18
|
+
// Mount AI route if catalyst-ai is installed
|
|
19
|
+
function mountAIRouter(app) {
|
|
20
|
+
let aiPackagePath
|
|
21
|
+
try {
|
|
22
|
+
aiPackagePath = cjsRequire.resolve("catalyst-ai/route", {
|
|
23
|
+
paths: [process.env.src_path || process.cwd()],
|
|
24
|
+
})
|
|
25
|
+
} catch (resolveErr) {
|
|
26
|
+
if (resolveErr.code !== "MODULE_NOT_FOUND") {
|
|
27
|
+
console.error("[catalyst-core/ai] Unexpected error resolving catalyst-ai:", resolveErr)
|
|
28
|
+
} else {
|
|
29
|
+
console.debug("[catalyst-core/ai] catalyst-ai not installed — AI routes unavailable")
|
|
30
|
+
}
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const aiRouter = cjsRequire(aiPackagePath)
|
|
36
|
+
let aiConfig = {}
|
|
37
|
+
try {
|
|
38
|
+
const parsedConfig = JSON.parse(process.env.AI_CONFIG || "{}")
|
|
39
|
+
if (parsedConfig && typeof parsedConfig === "object" && !Array.isArray(parsedConfig)) {
|
|
40
|
+
aiConfig = parsedConfig
|
|
41
|
+
} else {
|
|
42
|
+
console.warn("[catalyst-core/ai] AI_CONFIG must be a JSON object, ignoring it")
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.warn(`[catalyst-core/ai] Invalid AI_CONFIG JSON, ignoring: ${e.message}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (aiConfig.enabled === false) {
|
|
49
|
+
console.log("[catalyst-core/ai] AI_CONFIG.enabled is false, skipping AI router")
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const aiBasePath = aiConfig.basePath || "/ai"
|
|
54
|
+
console.log(`[catalyst-core/ai] mounting AI router at ${aiBasePath}`)
|
|
55
|
+
app.use(aiBasePath, aiRouter)
|
|
56
|
+
} catch (mountErr) {
|
|
57
|
+
console.error("[catalyst-core/ai] Failed to mount AI router:", mountErr)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
17
61
|
// OpenTelemetry is opt-in (OTEL_ENABLE) — mirrors server/renderer/handler.jsx.
|
|
18
62
|
// Passthrough no-op middleware when disabled or packages aren't installed.
|
|
19
63
|
let responseFlushMiddleware = () => (_req, _res, next) => next()
|
|
@@ -106,6 +150,8 @@ async function createServer() {
|
|
|
106
150
|
// All the middlewares defined by the user will run here.
|
|
107
151
|
if (validateMiddleware(addMiddlewares)) addMiddlewares(app)
|
|
108
152
|
|
|
153
|
+
mountAIRouter(app)
|
|
154
|
+
|
|
109
155
|
// response.compress + response.flush spans straddle compression — they
|
|
110
156
|
// attribute the time past the `handler` span (gzip/brotli, then egress).
|
|
111
157
|
// MUST be mounted immediately before compression() so its outer res.end
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
generateModulePreloadLinkElements,
|
|
24
24
|
} from "./extract.js"
|
|
25
25
|
import path from "path"
|
|
26
|
+
import { Transform } from "node:stream"
|
|
26
27
|
|
|
27
28
|
import CustomDocument from "@catalyst/template/server/document"
|
|
28
29
|
|
|
@@ -242,13 +243,17 @@ const _renderMarkUp = async (
|
|
|
242
243
|
res.status(status)
|
|
243
244
|
|
|
244
245
|
return new Promise((resolve, reject) => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
246
|
+
// Single completion path: React's pipe() auto-ends `tail`, and
|
|
247
|
+
// `flush()` appends the deferred asset tags before that end
|
|
248
|
+
// signal propagates to `res` via the plain pipe below. This
|
|
249
|
+
// avoids the onShellReady/onAllReady race where React's own
|
|
250
|
+
// stream-end and a manual res.end() compete to close `res`
|
|
251
|
+
// (see issue #320).
|
|
252
|
+
const tail = new Transform({
|
|
253
|
+
transform(chunk, _enc, cb) {
|
|
254
|
+
cb(null, chunk)
|
|
249
255
|
},
|
|
250
|
-
|
|
251
|
-
onAllReady() {
|
|
256
|
+
flush(cb) {
|
|
252
257
|
// Deferred assets — injected after body (non-blocking)
|
|
253
258
|
const deferredAssets = chunkExtractor
|
|
254
259
|
? chunkExtractor.getDeferredAssets()
|
|
@@ -256,10 +261,10 @@ const _renderMarkUp = async (
|
|
|
256
261
|
|
|
257
262
|
// Tell client which components were SSR'd so split() can
|
|
258
263
|
// eagerly import them (prevents Suspense fallback flash)
|
|
259
|
-
|
|
264
|
+
this.push(`<script>window.__CATALYST_IS_BOT__=${isBot ? "true" : "false"};</script>`)
|
|
260
265
|
if (chunkExtractor) {
|
|
261
266
|
const renderedKeys = chunkExtractor.getRenderedComponentKeys()
|
|
262
|
-
|
|
267
|
+
this.push(
|
|
263
268
|
`<script>window.__SSR_RENDERED_COMPONENTS__=new Set(${JSON.stringify(renderedKeys)})</script>`
|
|
264
269
|
)
|
|
265
270
|
}
|
|
@@ -270,13 +275,24 @@ const _renderMarkUp = async (
|
|
|
270
275
|
isBot
|
|
271
276
|
)
|
|
272
277
|
if (newCssPaths.length) {
|
|
273
|
-
|
|
278
|
+
this.push(`<style>${readCssFromDisk(newCssPaths, buildDir)}</style>`)
|
|
274
279
|
}
|
|
275
280
|
if (!isBot) {
|
|
276
|
-
|
|
281
|
+
this.push(generateScriptStrings(deferredAssets.js))
|
|
277
282
|
}
|
|
278
283
|
|
|
279
|
-
|
|
284
|
+
cb()
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
tail.pipe(res)
|
|
288
|
+
|
|
289
|
+
const { pipe } = renderToPipeableStream(<CompleteDocument />, {
|
|
290
|
+
onShellReady() {
|
|
291
|
+
res.setHeader("content-type", "text/html")
|
|
292
|
+
pipe(tail)
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
onAllReady() {
|
|
280
296
|
cleanupSafeArea()
|
|
281
297
|
resolve()
|
|
282
298
|
},
|
|
@@ -285,6 +301,7 @@ const _renderMarkUp = async (
|
|
|
285
301
|
console.error("Error in renderToPipeableStream:", error)
|
|
286
302
|
safeCall(onRenderError, { req, res, store, error })
|
|
287
303
|
cleanupSafeArea()
|
|
304
|
+
tail.destroy(error)
|
|
288
305
|
reject(error)
|
|
289
306
|
},
|
|
290
307
|
})
|
package/dist/vite/vite.config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineConfig, transformWithEsbuild } from "vite"
|
|
2
2
|
import react from "@vitejs/plugin-react"
|
|
3
3
|
import svgr from "vite-plugin-svgr"
|
|
4
|
-
import { builtinModules } from "node:module"
|
|
4
|
+
import { builtinModules, createRequire } from "node:module"
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Treat all .scss imports as CSS modules (webpack compat).
|
|
@@ -131,6 +131,50 @@ const allAliases = { ..._moduleAliases, ...catalyst_moduleAliases }
|
|
|
131
131
|
|
|
132
132
|
import { imageUrl, fontUrl } from "./scssParams.js"
|
|
133
133
|
|
|
134
|
+
// Tailwind v4's zero-config scanner (`@tailwindcss/postcss`) walks up from
|
|
135
|
+
// `dirname(from)` — the directory of the file PostCSS is currently compiling —
|
|
136
|
+
// unless given an explicit `base`. Vite passes each CSS file's own resolved
|
|
137
|
+
// path as `from`, so for catalyst-core's conventional layout (global
|
|
138
|
+
// stylesheet under `src/static/css`, components under `src/js`, no shared
|
|
139
|
+
// parent short of the app root) the scanner never reaches component files and
|
|
140
|
+
// silently drops every class actually used in JSX. Webpack's postcss-loader
|
|
141
|
+
// doesn't populate `from` the same way, which is why this only regresses
|
|
142
|
+
// under Vite. We resolve the app's own postcss.config.js ourselves and patch
|
|
143
|
+
// in `base: process.env.src_path` so the scan root covers the whole app.
|
|
144
|
+
// Only the `{ pluginName: options }` shorthand can be patched this way —
|
|
145
|
+
// array-form configs (`[require(...)()]`) have already closed over their
|
|
146
|
+
// options — so anything else is left alone and Vite falls back to its normal
|
|
147
|
+
// auto-loaded postcss.config.js resolution.
|
|
148
|
+
const resolvePostcssPlugins = () => {
|
|
149
|
+
const configPath = path.join(process.env.src_path, "postcss.config.js")
|
|
150
|
+
if (!fs.existsSync(configPath)) return null
|
|
151
|
+
|
|
152
|
+
const appRequire = createRequire(configPath)
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const userConfig = appRequire(configPath)
|
|
156
|
+
const pluginsConfig = userConfig?.plugins
|
|
157
|
+
|
|
158
|
+
if (!pluginsConfig || Array.isArray(pluginsConfig) || typeof pluginsConfig !== "object") {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return Object.entries(pluginsConfig).map(([name, options]) => {
|
|
163
|
+
const plugin = appRequire(name)
|
|
164
|
+
const finalOptions =
|
|
165
|
+
name === "@tailwindcss/postcss" && (!options || options.base === undefined)
|
|
166
|
+
? { ...options, base: process.env.src_path }
|
|
167
|
+
: options
|
|
168
|
+
return plugin(finalOptions)
|
|
169
|
+
})
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.warn(`Failed to patch postcss config at ${configPath}:`, error.message)
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const postcssPlugins = resolvePostcssPlugins()
|
|
177
|
+
|
|
134
178
|
const alias = () => {
|
|
135
179
|
if (!allAliases || typeof allAliases !== "object") {
|
|
136
180
|
return {}
|
|
@@ -177,6 +221,37 @@ export const getClientEnvVariables = () => {
|
|
|
177
221
|
process.env["PUBLIC_STATIC_ASSET_URL"]
|
|
178
222
|
)
|
|
179
223
|
|
|
224
|
+
// Derive AI_PUBLIC_CONFIG: only the browser block from AI_CONFIG, no API keys
|
|
225
|
+
let aiConfig = {}
|
|
226
|
+
try {
|
|
227
|
+
const parsedConfig = JSON.parse(process.env.AI_CONFIG || "{}")
|
|
228
|
+
if (parsedConfig && typeof parsedConfig === "object" && !Array.isArray(parsedConfig)) {
|
|
229
|
+
aiConfig = parsedConfig
|
|
230
|
+
} else {
|
|
231
|
+
console.warn("[catalyst-core] AI_CONFIG must be a JSON object, ignoring it")
|
|
232
|
+
}
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.warn(`[catalyst-core] Invalid AI_CONFIG JSON, ignoring: ${e.message}`)
|
|
235
|
+
}
|
|
236
|
+
const aiPublicConfig = aiConfig.browser ? { browser: aiConfig.browser } : {}
|
|
237
|
+
envVarDefinitions[`process.env.AI_PUBLIC_CONFIG`] = JSON.stringify(JSON.stringify(aiPublicConfig))
|
|
238
|
+
|
|
239
|
+
// Detect which catalyst-* AI packages are installed for the app, including
|
|
240
|
+
// hoisted/workspace installs — not just a direct app-local node_modules copy.
|
|
241
|
+
// Results are injected as __CATALYST_PACKAGES__.* literals so dead code
|
|
242
|
+
// referencing missing packages can be branched around at runtime.
|
|
243
|
+
const appRequire = createRequire(path.join(process.env.src_path || process.cwd(), "package.json"))
|
|
244
|
+
let aiPackageInstalled = false
|
|
245
|
+
try {
|
|
246
|
+
appRequire.resolve("catalyst-ai")
|
|
247
|
+
aiPackageInstalled = true
|
|
248
|
+
} catch {
|
|
249
|
+
aiPackageInstalled = false
|
|
250
|
+
}
|
|
251
|
+
envVarDefinitions["__CATALYST_PACKAGES__"] = JSON.stringify({
|
|
252
|
+
ai: aiPackageInstalled,
|
|
253
|
+
})
|
|
254
|
+
|
|
180
255
|
return envVarDefinitions
|
|
181
256
|
}
|
|
182
257
|
|
|
@@ -377,6 +452,7 @@ export default defineConfig({
|
|
|
377
452
|
localsConvention: "camelCase",
|
|
378
453
|
generateScopedName: "[name]__[local]___[hash:base64:5]",
|
|
379
454
|
},
|
|
455
|
+
...(postcssPlugins ? { postcss: { plugins: postcssPlugins } } : {}),
|
|
380
456
|
preprocessorOptions: {
|
|
381
457
|
scss: {
|
|
382
458
|
additionalData: (content) =>
|
|
@@ -170,15 +170,63 @@
|
|
|
170
170
|
{
|
|
171
171
|
"section": "build_system",
|
|
172
172
|
"title": "iOS Build Pipeline",
|
|
173
|
-
"content": "
|
|
173
|
+
"content": "npm run buildApp:ios generates ConfigConstants.swift and plugin metadata from WEBVIEW_CONFIG, syncs selected public/ios assets into the package-owned Xcode project, detects a physical device or launches a simulator, cleans Xcode artifacts, compiles with the Debug configuration, and installs and launches the .app. It does not bundle the full Catalyst web client and does not create an App Store archive. The generated project is located under the resolved catalyst-core package at dist/native/iosnativeWebView/iosnativeWebView.xcodeproj.",
|
|
174
174
|
"layer": "Build",
|
|
175
175
|
"source": "static",
|
|
176
|
-
"tags": ["ios", "build"]
|
|
176
|
+
"tags": ["ios", "build", "buildApp:ios", "debug", "xcode"],
|
|
177
|
+
"github_files": [
|
|
178
|
+
"packages/catalyst-core/src/native/buildAppIos.js",
|
|
179
|
+
"packages/catalyst-core/src/native/buildIos/build.js"
|
|
180
|
+
]
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"section": "build_system",
|
|
184
|
+
"title": "Catalyst iOS Production Release Flow (0.2.x and 0.3.x)",
|
|
185
|
+
"content": "Catalyst 0.2.x and 0.3.x use the same iOS production-release flow: (1) deploy the production web application and verify its HTTPS URL, (2) finalize WEBVIEW_CONFIG, bundle ID, app version/build, plugins, permissions, capabilities, icons, splash assets, access-control rules, and privacy declarations, (3) run npm run buildApp:ios from the consuming app root to prepare the package-owned native project and perform its Debug build, (4) open the resolved catalyst-core package's dist/native/iosnativeWebView/iosnativeWebView.xcodeproj, (5) confirm the iosnativeWebView scheme's Archive action uses Release, configure Release signing and capabilities, choose a generic iOS device destination, and run Product -> Archive, (6) Validate App and upload with Distribute App -> TestFlight & App Store, then (7) finish TestFlight testing, App Store Connect metadata, build selection, and App Review submission. buildApp:ios does not create the final App Store artifact.",
|
|
186
|
+
"layer": "Build",
|
|
187
|
+
"source": "static",
|
|
188
|
+
"tags": [
|
|
189
|
+
"ios",
|
|
190
|
+
"release",
|
|
191
|
+
"production",
|
|
192
|
+
"0.2.x",
|
|
193
|
+
"0.3.x",
|
|
194
|
+
"buildApp:ios",
|
|
195
|
+
"xcode",
|
|
196
|
+
"archive",
|
|
197
|
+
"app-store",
|
|
198
|
+
"testflight"
|
|
199
|
+
],
|
|
200
|
+
"github_files": [
|
|
201
|
+
"docs/content/08-Deployment/03-iOS-Production-Release.md",
|
|
202
|
+
"packages/catalyst-core/src/native/iosnativeWebView/iosnativeWebView.xcodeproj/xcshareddata/xcschemes/iosnativeWebView.xcscheme"
|
|
203
|
+
]
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"section": "build_system",
|
|
207
|
+
"title": "Generated iOS Project Signing and Persistence",
|
|
208
|
+
"content": "The generated Xcode project is package-owned state. Its shared xcconfig is regenerated with development-oriented code-signing defaults, and the bundled push entitlement can use aps-environment=development. Before an App Store archive, review the Release team, distribution signing, provisioning, capabilities, and entitlements in Xcode. Run the final npm run buildApp:ios before release-only Xcode changes: later Catalyst generation, package upgrades, or dependency installation can replace or regenerate native config constants, shared Xcode configuration, plist and entitlement content, generated plugin code/resources, selected project-file entries, and assets. Do not treat manual edits inside the generated project as durable app configuration.",
|
|
209
|
+
"layer": "Build",
|
|
210
|
+
"source": "static",
|
|
211
|
+
"tags": [
|
|
212
|
+
"ios",
|
|
213
|
+
"release",
|
|
214
|
+
"signing",
|
|
215
|
+
"provisioning",
|
|
216
|
+
"entitlements",
|
|
217
|
+
"push-notifications",
|
|
218
|
+
"generated-project",
|
|
219
|
+
"regeneration"
|
|
220
|
+
],
|
|
221
|
+
"github_files": [
|
|
222
|
+
"packages/catalyst-core/src/native/buildIos/config.js",
|
|
223
|
+
"packages/catalyst-core/src/native/iosnativeWebView/iosnativeWebView/iosnativeWebView.entitlements"
|
|
224
|
+
]
|
|
177
225
|
},
|
|
178
226
|
{
|
|
179
227
|
"section": "build_system",
|
|
180
228
|
"title": "Legacy 0.2.x Build NPM Scripts",
|
|
181
|
-
"content": "All catalyst npm scripts to add to package.json scripts block (each maps to: catalyst <scriptName>): devBuild \u2014 dev web build. devServe \u2014 dev server. buildApp \u2014 builds native
|
|
229
|
+
"content": "All catalyst npm scripts to add to package.json scripts block (each maps to: catalyst <scriptName>): devBuild \u2014 dev web build. devServe \u2014 dev server. buildApp \u2014 builds native apps. buildApp:android \u2014 Android native app build. buildApp:ios \u2014 iOS native project preparation and Debug build. buildApp:android:release \u2014 Android release build. setupEmulator \u2014 sets up both emulators. setupEmulator:android \u2014 sets up Android AVD. setupEmulator:ios \u2014 sets up iOS simulator. There is no separate buildApp:ios:release flow: run buildApp:ios, then create the production archive from the generated Xcode project's Release Archive action. NOTE: build:android / build:android:release / build:ios do NOT exist in catalyst \u2014 common mistake. Missing any script = that npm run command fails.",
|
|
182
230
|
"layer": "Build",
|
|
183
231
|
"source": "static",
|
|
184
232
|
"tags": ["build", "scripts", "package.json", "native"]
|
|
@@ -434,7 +482,7 @@
|
|
|
434
482
|
{
|
|
435
483
|
"section": "config_structure",
|
|
436
484
|
"title": "WEBVIEW_CONFIG Required Fields",
|
|
437
|
-
"content": "WEBVIEW_CONFIG field reference (from buildAppAndroid.js + buildAppIos.js source):\n\nTOP-LEVEL (both platforms):\n- port (string, required) \u2014 web server port e.g. \"3005\"\n- LOCAL_IP (string, required) \u2014 machine LAN IP e.g. \"192.168.0.11\". NOT localhost \u2014 emulator cannot resolve it\n- appInfo (string, required) \u2014 build identifier e.g. \"android-5Feb2026-v2.1.0\". Missing = iOS build breaks\n- useHttps (boolean, optional) \u2014 default false\n- cachePattern (string, optional) \u2014 comma-separated globs e.g. \"*.css\"\n- accessControl.enabled (boolean, optional) \u2014 default false\n- accessControl.allowedUrls (array, required if enabled) \u2014 URL patterns\n\nANDROID (WEBVIEW_CONFIG.android):\n- sdkPath (string, REQUIRED) \u2014 abs path to Android SDK. Build exits immediately without it\n- emulatorName (string, REQUIRED for debug) \u2014 AVD name. Run: emulator -list-avds\n- appName (string, optional) \u2014 default \"Catalyst Application\"\n- packageName (string, optional) \u2014 e.g. com.company.app. Derived from appName if missing\n- buildType (string, optional) \u2014 \"debug\" or \"release\". Default \"debug\"\n- keystore or keystoreConfig (required for release buildType only)\n- buildOptimisation (boolean, optional) \u2014 default false\n- cachePattern (string, optional)\n\nIOS (WEBVIEW_CONFIG.ios):\n- appBundleId (string, optional) \u2014 default \"com.debug.webview\". Must set for distribution\n- appName (string, optional) \u2014 default \"Catalyst Application\"\n- simulatorName (string, optional) \u2014 auto-detected if missing. e.g. \"iPhone 17 Pro\"\n- buildType (string, optional
|
|
485
|
+
"content": "WEBVIEW_CONFIG field reference (from buildAppAndroid.js + buildAppIos.js source):\n\nTOP-LEVEL (both platforms):\n- port (string, required) \u2014 web server port e.g. \"3005\"\n- LOCAL_IP (string, required) \u2014 machine LAN IP e.g. \"192.168.0.11\". NOT localhost \u2014 emulator cannot resolve it\n- appInfo (string, required) \u2014 build identifier e.g. \"android-5Feb2026-v2.1.0\". Missing = iOS build breaks\n- useHttps (boolean, optional) \u2014 default false\n- cachePattern (string, optional) \u2014 comma-separated globs e.g. \"*.css\"\n- accessControl.enabled (boolean, optional) \u2014 default false\n- accessControl.allowedUrls (array, required if enabled) \u2014 URL patterns\n\nANDROID (WEBVIEW_CONFIG.android):\n- sdkPath (string, REQUIRED) \u2014 abs path to Android SDK. Build exits immediately without it\n- emulatorName (string, REQUIRED for debug) \u2014 AVD name. Run: emulator -list-avds\n- appName (string, optional) \u2014 default \"Catalyst Application\"\n- packageName (string, optional) \u2014 e.g. com.company.app. Derived from appName if missing\n- buildType (string, optional) \u2014 \"debug\" or \"release\". Default \"debug\"\n- keystore or keystoreConfig (required for release buildType only)\n- buildOptimisation (boolean, optional) \u2014 default false\n- cachePattern (string, optional)\n\nIOS (WEBVIEW_CONFIG.ios):\n- appBundleId (string, optional) \u2014 default \"com.debug.webview\". Must set for distribution\n- appName (string, optional) \u2014 default \"Catalyst Application\"\n- simulatorName (string, optional) \u2014 auto-detected if missing. e.g. \"iPhone 17 Pro\"\n- buildType (string, optional) \u2014 may label copied Debug output but does not select the Xcode build configuration or create an App Store archive\n- deviceUDID (string, optional) \u2014 physical device builds only\n- developmentTeam (string, required if deviceUDID set) \u2014 Apple Developer Team ID\n- cachePattern (string, optional)\n\nFor iOS production, run buildApp:ios to prepare the generated project, then archive the iosnativeWebView scheme using its Release Archive action in Xcode.",
|
|
438
486
|
"layer": "Config",
|
|
439
487
|
"source": "static",
|
|
440
488
|
"tags": ["config", "WEBVIEW_CONFIG", "android", "ios", "required", "fields"]
|
package/mcp_v2/mcp.js
CHANGED
|
@@ -62,21 +62,53 @@ if (projectInfo.notInstalled) {
|
|
|
62
62
|
process.exit(1)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
const dbMissing = !fs.existsSync(DB_PATH)
|
|
66
|
+
const dbEmpty = !dbMissing && fs.statSync(DB_PATH).size === 0
|
|
67
|
+
|
|
68
|
+
if (dbMissing || dbEmpty) {
|
|
69
|
+
try {
|
|
70
|
+
require("./setup")
|
|
71
|
+
} catch (setupErr) {
|
|
72
|
+
process.stderr.write(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
jsonrpc: "2.0",
|
|
75
|
+
error: {
|
|
76
|
+
code: -32000,
|
|
77
|
+
message: `context.db ${dbEmpty ? "is empty" : "not found"} at ${DB_PATH}. Auto-setup failed: ${setupErr.message}. Run manually: node ${path.join(MCP_DIR, "setup.js")}`,
|
|
78
|
+
},
|
|
79
|
+
id: null,
|
|
80
|
+
}) + "\n"
|
|
81
|
+
)
|
|
82
|
+
// process.exit(1) instead of exitCode+return: this is top-level script code
|
|
83
|
+
// (not inside a function), and ESLint here rejects top-level return even
|
|
84
|
+
// though Node's CommonJS wrapper allows it. Matches the exit pattern used
|
|
85
|
+
// by the other startup-validation failures above (L49, L62).
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let db
|
|
91
|
+
try {
|
|
92
|
+
db = new Database(DB_PATH, { readonly: false })
|
|
93
|
+
} catch (e) {
|
|
94
|
+
const isVersionMismatch =
|
|
95
|
+
e.code === "ERR_DLOPEN_FAILED" ||
|
|
96
|
+
e.message.includes("compiled against a different Node.js version") ||
|
|
97
|
+
e.message.includes("NODE_MODULE_VERSION")
|
|
66
98
|
process.stderr.write(
|
|
67
99
|
JSON.stringify({
|
|
68
100
|
jsonrpc: "2.0",
|
|
69
101
|
error: {
|
|
70
102
|
code: -32000,
|
|
71
|
-
message:
|
|
103
|
+
message: isVersionMismatch
|
|
104
|
+
? `Native module mismatch for Node ${process.version}. Run: npm rebuild better-sqlite3`
|
|
105
|
+
: `Failed to open context.db: ${e.message}`,
|
|
72
106
|
},
|
|
73
107
|
id: null,
|
|
74
108
|
}) + "\n"
|
|
75
109
|
)
|
|
76
110
|
process.exit(1)
|
|
77
111
|
}
|
|
78
|
-
|
|
79
|
-
const db = new Database(DB_PATH, { readonly: false })
|
|
80
112
|
const CONVERSION_TASKS = JSON.parse(fs.readFileSync(TASKS_PATH, "utf8"))
|
|
81
113
|
|
|
82
114
|
// ── Module init ───────────────────────────────────────────────────────────────
|
package/mcp_v2/tools/build.js
CHANGED
|
@@ -149,19 +149,19 @@ const MASTER_FLOWS = {
|
|
|
149
149
|
step: 1,
|
|
150
150
|
cmd: null,
|
|
151
151
|
label: "Pre-check: config",
|
|
152
|
-
detail:
|
|
152
|
+
detail: "Reads WEBVIEW_CONFIG and WEBVIEW_CONFIG.ios. Confirm the development URL, appBundleId, simulatorName, plugins, and selected public/ios assets.",
|
|
153
153
|
},
|
|
154
154
|
{
|
|
155
155
|
step: 2,
|
|
156
|
-
cmd: "
|
|
157
|
-
label: "
|
|
158
|
-
detail: "
|
|
156
|
+
cmd: "npm run buildApp:ios",
|
|
157
|
+
label: "iOS simulator build",
|
|
158
|
+
detail: "Generates native configuration and plugin code in the package-owned Xcode project, syncs selected assets, compiles with Xcode's Debug configuration, installs the .app, and launches it.",
|
|
159
159
|
},
|
|
160
160
|
{
|
|
161
161
|
step: 3,
|
|
162
|
-
cmd:
|
|
163
|
-
label: "
|
|
164
|
-
detail: "
|
|
162
|
+
cmd: null,
|
|
163
|
+
label: "Remote web application",
|
|
164
|
+
detail: "The iOS shell loads the configured remote URL. buildApp:ios does not bundle the full Catalyst web client, so the target web application must be reachable.",
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
step: 4,
|
|
@@ -175,27 +175,44 @@ const MASTER_FLOWS = {
|
|
|
175
175
|
{
|
|
176
176
|
step: 1,
|
|
177
177
|
cmd: null,
|
|
178
|
+
label: "Deploy the production web application",
|
|
179
|
+
detail: "Deploy the web application first and confirm its production URL is reachable over HTTPS. The iOS shell loads this remote URL; it does not embed the full web client bundle.",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
step: 2,
|
|
183
|
+
cmd: null,
|
|
178
184
|
label: "Pre-check: config",
|
|
179
|
-
detail:
|
|
185
|
+
detail: "Finalize WEBVIEW_CONFIG, appBundleId, app name, version, incremented build number, access-control rules, plugins, permissions, capabilities, icons, splash assets, and privacy declarations.",
|
|
180
186
|
},
|
|
181
|
-
{ step: 2, cmd: "catalyst build", label: "Build web assets", detail: "Production build." },
|
|
182
187
|
{
|
|
183
188
|
step: 3,
|
|
184
189
|
cmd: "npm run buildApp:ios",
|
|
185
|
-
label: "
|
|
186
|
-
detail: "
|
|
190
|
+
label: "Prepare the native project",
|
|
191
|
+
detail: "Run from the consuming Catalyst app root. This generates native configuration and plugin code, syncs selected assets, and performs a Debug native build. It does not create an App Store archive.",
|
|
187
192
|
},
|
|
188
193
|
{
|
|
189
194
|
step: 4,
|
|
190
195
|
cmd: null,
|
|
191
|
-
label: "
|
|
192
|
-
detail: "
|
|
196
|
+
label: "Open the generated Xcode project",
|
|
197
|
+
detail: "Open node_modules/catalyst-core/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj (or the resolved package equivalent). Confirm the iosnativeWebView scheme's Archive action uses Release.",
|
|
193
198
|
},
|
|
194
199
|
{
|
|
195
200
|
step: 5,
|
|
196
201
|
cmd: null,
|
|
197
|
-
label: "
|
|
198
|
-
detail: "
|
|
202
|
+
label: "Configure signing and archive",
|
|
203
|
+
detail: "Select the correct Apple Developer team, distribution signing, capabilities, entitlements, bundle ID, version, and build. Choose a generic iOS device destination, then Product → Archive.",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
step: 6,
|
|
207
|
+
cmd: null,
|
|
208
|
+
label: "Validate and upload",
|
|
209
|
+
detail: "In Xcode Organizer, run Validate App, then Distribute App → TestFlight & App Store and upload the build.",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
step: 7,
|
|
213
|
+
cmd: null,
|
|
214
|
+
label: "Complete the release",
|
|
215
|
+
detail: "Wait for App Store Connect processing, test through TestFlight, complete store metadata and privacy disclosures, select the processed build, and submit it for App Review.",
|
|
199
216
|
},
|
|
200
217
|
],
|
|
201
218
|
}
|
|
@@ -495,6 +512,7 @@ function getProjectContext(root) {
|
|
|
495
512
|
iosBuildType: (wv.ios?.buildType || "debug").toLowerCase(),
|
|
496
513
|
hasKeystore: !!wv.android?.keystoreConfig,
|
|
497
514
|
hasGoogleSignIn: !!wv.googleSignIn?.enabled,
|
|
515
|
+
hasNotifications: !!wv.notifications?.enabled,
|
|
498
516
|
hasEcosystem: fileExists("ecosystem.config.js"),
|
|
499
517
|
port: wv.port,
|
|
500
518
|
useHttps: !!wv.useHttps,
|
|
@@ -618,7 +636,7 @@ function handle_get_build_flow({ platform, mode, symptom } = {}) {
|
|
|
618
636
|
)
|
|
619
637
|
}
|
|
620
638
|
|
|
621
|
-
const isRelease = m === "release"
|
|
639
|
+
const isRelease = m === "release"
|
|
622
640
|
flow_key = isRelease ? "ios_release" : "ios_debug"
|
|
623
641
|
|
|
624
642
|
if (!ctx.wv.ios?.appBundleId) {
|
|
@@ -634,13 +652,23 @@ function handle_get_build_flow({ platform, mode, symptom } = {}) {
|
|
|
634
652
|
"googleSignIn.enabled=true but iosClientId may be missing — check WEBVIEW_CONFIG.googleSignIn.iosClientId."
|
|
635
653
|
)
|
|
636
654
|
}
|
|
637
|
-
if (isRelease && ctx.
|
|
655
|
+
if (isRelease && !ctx.useHttps) {
|
|
638
656
|
warnings.push(
|
|
639
|
-
|
|
657
|
+
"WEBVIEW_CONFIG.useHttps is not enabled — the App Store flow requires a reachable HTTPS production web application."
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
if (isRelease && ctx.hasNotifications) {
|
|
661
|
+
warnings.push(
|
|
662
|
+
"Push notifications are enabled — review the generated aps-environment entitlement and Release provisioning before archiving."
|
|
640
663
|
)
|
|
641
664
|
}
|
|
642
665
|
|
|
643
666
|
notes.push(`buildType detected: ${ctx.wv.ios?.buildType || "not set (defaults to debug)"}`)
|
|
667
|
+
if (isRelease) {
|
|
668
|
+
notes.push(
|
|
669
|
+
"ios.buildType does not select the App Store build configuration; buildApp:ios still performs a Debug build. Archive the generated project with the scheme's Release action."
|
|
670
|
+
)
|
|
671
|
+
}
|
|
644
672
|
if (ctx.wv.ios?.appBundleId) notes.push(`appBundleId: ${ctx.wv.ios.appBundleId}`)
|
|
645
673
|
if (ctx.wv.ios?.simulatorName) notes.push(`simulatorName: ${ctx.wv.ios.simulatorName}`)
|
|
646
674
|
if (ctx.hasGoogleSignIn) notes.push("googleSignIn: enabled")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "catalyst-core",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.jsx",
|
|
6
6
|
"description": "Web framework that provides great performance out of the box",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"lint": "eslint .",
|
|
32
32
|
"lint-staged": "lint-staged",
|
|
33
33
|
"prettify": "prettier . --write",
|
|
34
|
-
"prepare": "
|
|
34
|
+
"prepare": "node ../../scripts/safe-rimraf.js ./dist && cp -r ./src ./dist && babel src/native --out-dir dist/native --config-file ./babel.native.config.cjs --ignore '**/.build/**' && babel src/sentry.js --out-file dist/sentry.cjs --config-file ./babel.native.config.cjs",
|
|
35
35
|
"prepublishOnly": "npm run prepare",
|
|
36
36
|
"test:fixture": "sh ../../scripts/test-catalyst-core.sh"
|
|
37
37
|
},
|