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.
@@ -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
- // Enable 16KB page size alignment for all native libraries
172
- useLegacyPackaging = false
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 notifications config
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("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
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
@@ -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
  */
@@ -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+[\\s\\S]*?android:name="${metadataName}"[\\s\\S]*?\\/>`, "gi");
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
- async function validateNotificationAsset(filePath, assetType) {
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
- const { pipe } = renderToPipeableStream(<CompleteDocument />, {
246
- onShellReady() {
247
- res.setHeader("content-type", "text/html")
248
- pipe(res)
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
- res.write(`<script>window.__CATALYST_IS_BOT__=${isBot ? "true" : "false"};</script>`)
264
+ this.push(`<script>window.__CATALYST_IS_BOT__=${isBot ? "true" : "false"};</script>`)
260
265
  if (chunkExtractor) {
261
266
  const renderedKeys = chunkExtractor.getRenderedComponentKeys()
262
- res.write(
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
- res.write(`<style>${readCssFromDisk(newCssPaths, buildDir)}</style>`)
278
+ this.push(`<style>${readCssFromDisk(newCssPaths, buildDir)}</style>`)
274
279
  }
275
280
  if (!isBot) {
276
- res.write(generateScriptStrings(deferredAssets.js))
281
+ this.push(generateScriptStrings(deferredAssets.js))
277
282
  }
278
283
 
279
- res.end()
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
  })
@@ -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": "1) Generate ConfigConstants.swift from WEBVIEW_CONFIG 2) Launch simulator 3) Clean Xcode project 4) Compile with Xcode 5) Install app + launch. Located in: src/native/iosnativeWebView/",
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 app (both platforms). buildApp:android \u2014 Android native app build. buildApp:ios \u2014 iOS native app build. buildApp:android:release \u2014 Android release build. buildApp:ios:release \u2014 iOS release build. setupEmulator \u2014 sets up both emulators. setupEmulator:android \u2014 sets up Android AVD. setupEmulator:ios \u2014 sets up iOS simulator. NOTE: build:android / build:android:release / build:ios do NOT exist in catalyst \u2014 common mistake. The correct commands are buildApp:android and buildApp:ios. Missing any script = that npm run command fails.",
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, CASE-SENSITIVE) \u2014 \"Debug\" or \"Release\". NOT lowercase \u2014 common mistake\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)",
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
- if (!fs.existsSync(DB_PATH)) {
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: `context.db not found at ${DB_PATH}. Run setup first: node ${path.join(MCP_DIR, "setup.js")}`,
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 ───────────────────────────────────────────────────────────────
@@ -149,19 +149,19 @@ const MASTER_FLOWS = {
149
149
  step: 1,
150
150
  cmd: null,
151
151
  label: "Pre-check: config",
152
- detail: 'Reads WEBVIEW_CONFIG.ios. Requires: appBundleId, simulatorName, buildType (default "debug").',
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: "catalyst build",
157
- label: "Build web assets",
158
- detail: "Compiles web app. iOS build embeds these.",
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: "npm run buildApp:ios",
163
- label: "iOS simulator build",
164
- detail: "Runs buildAppIos.js: generates ConfigConstants.swift from config (bundleId, URL, port, protocol), launches simulator (simulatorName), cleans Xcode artifacts, compiles with xcodebuild, installs .app, launches.",
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: 'buildType must be "Release" (capital R — case-sensitive). appBundleId required. If googleSignIn enabled, GoogleService-Info.plist must be present.',
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: "iOS release build",
186
- detail: "Same script — build type is driven by WEBVIEW_CONFIG.ios.buildType. Generates ConfigConstants.swift with production URL and bundleId. Xcode compiles Release scheme.",
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: "Archive + export IPA",
192
- detail: "Post-build: archive via Xcode Organizer or xcodebuild -exportArchive. Requires Apple distribution certificate + provisioning profile configured in Xcode.",
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: "App Store submission",
198
- detail: "Upload IPA via Transporter or Xcode Organizer. TestFlight for beta before release.",
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" || ctx.iosBuildType === "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.iosBuildType !== "release") {
655
+ if (isRelease && !ctx.useHttps) {
638
656
  warnings.push(
639
- 'ios.buildType is not "Release" (case-sensitive). Release builds require exactly "Release" — not "release".'
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.1",
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": "rm -rf ./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",
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
  },