catalyst-core 1.0.0 → 1.0.1-canary.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.
Files changed (40) hide show
  1. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +18 -21
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
  8. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +60 -11
  10. package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
  11. package/dist/native/bridge/hooks/aiProviders.js +27 -0
  12. package/dist/native/bridge/hooks/useAI.js +9 -8
  13. package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
  14. package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
  15. package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
  16. package/dist/native/buildAppIos.js +8 -2
  17. package/dist/native/buildIos/plugins.js +3 -1
  18. package/dist/native/internalPluginUtils.js +78 -0
  19. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
  20. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
  21. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
  22. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
  23. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
  24. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
  25. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
  26. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
  27. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
  29. package/dist/native/pluginComposerIos.js +5 -1
  30. package/dist/server/expressServer.js +1 -0
  31. package/mcp_v2/conversion-tasks.json +10 -9
  32. package/mcp_v2/knowledge-base.json +34 -4
  33. package/mcp_v2/test/config.test.ts +52 -0
  34. package/mcp_v2/tools/config.d.ts +18 -0
  35. package/mcp_v2/tools/config.js +40 -0
  36. package/mcp_v2/tools/conversion.js +2 -2
  37. package/package.json +7 -6
  38. package/test/hooks/aiProviders.test.ts +33 -0
  39. package/test/hooks/useSafeNavigate.test.ts +21 -0
  40. package/test/native/aiPluginComposition.test.js +161 -0
@@ -697,7 +697,7 @@
697
697
  {
698
698
  "section": "native_hooks",
699
699
  "title": "useSafeArea \u2014 return shape",
700
- "content": "useSafeArea() returns: { top: number, right: number, bottom: number, left: number, isNative: boolean, isWeb: boolean, webFallbackActive, webFallbackDisabled, setWebFallback } \u2014 inset values in pixels. isNative=true when running in Android/iOS WebView. isWeb=!isNative. On native: live updates via WebBridge ON_SAFE_AREA_INSETS_UPDATED. No isNative guard needed \u2014 safe to use on both platforms.\n\nWEB FALLBACK: Reads CSS env() variables \u2014 env(safe-area-inset-top/right/bottom/left) \u2014 parsed via a hidden DOM element. Falls back to 0 if env() vars are not set (desktop browsers). Manages webFallback state locally (thin-state hook).",
700
+ "content": "useSafeArea() returns: { top: number, right: number, bottom: number, left: number, isNative: boolean, isWeb: boolean, webFallbackActive, webFallbackDisabled, setWebFallback } \u2014 inset values in CSS pixels on both platforms; all 0 when WEBVIEW_CONFIG.edgeToEdge.enabled is false (the default). isNative=true when running in Android/iOS WebView. isWeb=!isNative. On native: live updates via WebBridge ON_SAFE_AREA_INSETS_UPDATED. No isNative guard needed \u2014 safe to use on both platforms.\n\nWEB FALLBACK: Reads CSS env() variables \u2014 env(safe-area-inset-top/right/bottom/left) \u2014 parsed via a hidden DOM element. Falls back to 0 if env() vars are not set (desktop browsers). Manages webFallback state locally (thin-state hook).",
701
701
  "layer": "Runtime",
702
702
  "source": "static",
703
703
  "tags": ["native-hooks", "useSafeArea", "return-shape", "web-fallback"]
@@ -1303,7 +1303,7 @@
1303
1303
  {
1304
1304
  "section": "routing",
1305
1305
  "title": "Catalyst 0.3.x Integrated Router",
1306
- "content": "Catalyst 0.3.x removes the @tata1mg/router package dependency. As of 1.0.0 (the API freeze), catalyst-core does NOT re-export React Router. Its root entry exports exactly eight names: RouterDataProvider, useCurrentRouteData, useRouterData, MetaTag, split, hydrationReady, Head, Body. There is no default export (split was previously also the default). React Router is a peerDependency (^7.18.2): install react-router in the application and import RouterProvider, Outlet, Link, useParams, useNavigate, useLocation, Navigate and every other router name from 'react-router' directly. Metadata utilities (getMetaData, mergeHeadElements, deleteHeadTagsByDataAttribute), RouterContext, serverDataFetcher and useNavigateWithTransition are no longer public. Existing route arrays and preparedRoutes structure remain unchanged. Keeping both router packages can create duplicate runtime contracts and invalid React element errors.",
1306
+ "content": "Catalyst 0.3.x removes the @tata1mg/router package dependency. As of 1.0.0 (the API freeze), catalyst-core does NOT re-export React Router. Its root entry exports exactly eight names: RouterDataProvider, useCurrentRouteData, useRouterData, MetaTag, split, hydrationReady, Head, Body. There is no default export (split was previously also the default). React Router is a peerDependency (^7.18.2): install react-router in the application and import Outlet, Link, useParams, useNavigate, useLocation, Navigate and every other router name from 'react-router' directly. Import RouterProvider in client/index.js from 'react-router/dom', which passes React DOM's flushSync to the router. Metadata utilities (getMetaData, mergeHeadElements, deleteHeadTagsByDataAttribute), RouterContext, serverDataFetcher and useNavigateWithTransition are no longer public. Existing route arrays and preparedRoutes structure remain unchanged. Keeping both router packages can create duplicate runtime contracts and invalid React element errors.",
1307
1307
  "layer": "Runtime",
1308
1308
  "source": "static",
1309
1309
  "tags": [
@@ -1377,7 +1377,7 @@
1377
1377
  {
1378
1378
  "section": "version_migration",
1379
1379
  "title": "Upgrading catalyst-core 0.3.x to 1.0.0",
1380
- "content": "Upgrade path for an application on catalyst-core 0.3.0-beta.5. Older applications first move to 0.3.x (see 'Legacy 0.2.x Migration Phase 0' and 'Catalyst 0.3.x React Runtime Contract'). 1.0.0 is the API freeze; there is no 0.4.0 release. Do the steps in order.\n\n1. DEPENDENCIES: set catalyst-core to 1.0.0. Add react-router ^7.18.2 as an application dependency: it is now a peer dependency and the server throws at startup if it is missing or outside that range. Keep react and react-dom pinned to 19.0.0. TypeScript applications also need @types/react ^19.0.0.\n\n2. NODE: run node -v. catalyst-core 1.0.0 requires >=22.12; on Node 20, importing catalyst-core/hooks or catalyst-core/WebBridge fails with ERR_REQUIRE_ESM. If the runtime is older, stop and tell the user; do not change the project's Node pin yourself.\n\n3. IMPORTS: the root entry exports exactly RouterDataProvider, useCurrentRouteData, useRouterData, MetaTag, split, hydrationReady, Head, Body. Move every React Router name (Link, Outlet, RouterProvider, useParams, useNavigate, useLocation, Navigate, ...) from 'catalyst-core' to 'react-router'. Replace `import split from 'catalyst-core'` with `import { split } from 'catalyst-core'`; there is no default export. Replace imports from 'catalyst-core/document' with Head and Body from 'catalyst-core'. 'catalyst-core/caching' is removed with no replacement. RouterContext, serverDataFetcher, mergeHeadElements, deleteHeadTagsByDataAttribute, getMetaData, useNavigateWithTransition and sanitizeFilePickerOptions (catalyst-core/hooks) are internal with no replacement: if the application uses one, stop and tell the user instead of inventing a substitute.\n\n4. BUILD AND START: run the build, then start the server once. The build catches removed imports (they fail instead of resolving to undefined); startup catches the react-router range and the Node version.\n\n5. HOOK ERROR FIELDS (not caught by the build): hook errors are now CatalystError instances. Rename reads of error.action to error.suggestedAction and error.nativeError to error.cause; error.recoverable and error.timestamp no longer exist. error.code values are registry codes (RUNTIME-NATIVE-xxx) instead of names like PERMISSION_DENIED, so replace string-literal comparisons with ERROR_CODES from catalyst-core/errors (for example ERROR_CODES.RUNTIME_NATIVE_PERMISSION_DENIED). See 'CatalystError shape (1.0.0)'.\n\n6. OPTIONAL, NON-BREAKING: rename deprecated hook alias keys to the canonical ones (see 'Native hooks \u2014 aliases and deprecations'); aliases keep working until 2.0.\n\nUnchanged: route arrays, preparedRoutes, server hooks, config/config.json keys and module aliases. Full guide: docs/content/12-Migration/02-Version-Upgrades.md.",
1380
+ "content": "Upgrade path for an application on catalyst-core 0.3.x to 1.0.0, the stable release. Read the installed catalyst-core version from package.json first. 0.3.0-beta.5 upgrades directly. 0.3.0-beta.1 to 0.3.0-beta.4 used react-router-dom 6, so those also cross React Router 6 to 7 (step 1). Older applications first move to 0.3.x (see 'Legacy 0.2.x Migration Phase 0' and 'Catalyst 0.3.x React Runtime Contract'). 1.0.0 is the API freeze; there is no 0.4.0 release. Do the steps in order.\n\n1. DEPENDENCIES: set catalyst-core to 1.0.0. Add react-router ^7.18.2 as an application dependency: it is now a peer dependency and the server exits at startup if it is missing or outside that range. Keep react and react-dom pinned to 19.0.0. TypeScript applications also need @types/react ^19.0.0. catalyst-core no longer installs react-router-dom, so change any import from 'react-router-dom' to 'react-router', except RouterProvider, which comes from 'react-router/dom' (step 3).\n\n2. NODE: run node -v. catalyst-core 1.0.0 requires >=22.12; on Node 20, importing catalyst-core/hooks or catalyst-core/WebBridge fails with ERR_REQUIRE_ESM. If the runtime is older, stop and tell the user; do not change the project's Node pin yourself.\n\n3. IMPORTS: the root entry exports exactly RouterDataProvider, useCurrentRouteData, useRouterData, MetaTag, split, hydrationReady, Head, Body. Move every React Router name (Link, Outlet, useParams, useNavigate, useLocation, Navigate, ...) from 'catalyst-core' to 'react-router'. In client/index.js import RouterProvider from 'react-router/dom', not 'react-router': only the /dom export passes React DOM's flushSync to the router, and the wrong one renders without any error. Replace `import split from 'catalyst-core'` with `import { split } from 'catalyst-core'`; there is no default export. Replace imports from 'catalyst-core/document' with Head and Body from 'catalyst-core'. 'catalyst-core/caching' is removed with no replacement. RouterContext, serverDataFetcher, mergeHeadElements, deleteHeadTagsByDataAttribute, getMetaData, useNavigateWithTransition and sanitizeFilePickerOptions (catalyst-core/hooks) are internal with no replacement: if the application uses one, stop and tell the user instead of inventing a substitute.\n\n4. CONFIG: catalyst start, build and serve now run a preflight and exit before starting when config/config.json lacks NODE_SERVER_HOSTNAME, NODE_SERVER_PORT, BUILD_OUTPUT_PATH, PUBLIC_STATIC_ASSET_PATH, PUBLIC_STATIC_ASSET_URL or CLIENT_ENV_VARIABLES, or when package.json _moduleAliases lacks @api, @containers, @server, @config, @css or @routes. Apps created from the templates already have all of them. WEBPACK_DEV_SERVER_HOSTNAME, WEBPACK_DEV_SERVER_PORT and ANALYZE_BUNDLE are no longer read and can be removed.\n\n5. BUILD AND START: run the build, then start the server once. The build catches removed imports (they fail instead of resolving to undefined); startup catches the react-router range and the preflight. Nothing checks the Node version at startup; an old Node fails only when hooks or WebBridge is imported (step 2).\n\n6. HOOK ERROR FIELDS (not caught by the build): hook errors are now CatalystError instances. Rename reads of error.action to error.suggestedAction and error.nativeError to error.cause; error.recoverable and error.timestamp no longer exist. error.code values are registry codes (RUNTIME-NATIVE-xxx) instead of names like PERMISSION_DENIED, so replace string-literal comparisons with ERROR_CODES from catalyst-core/errors (for example ERROR_CODES.RUNTIME_NATIVE_PERMISSION_DENIED). error.category is now 'RUNTIME-NATIVE' instead of PERMISSION, NETWORK, FILE_SYSTEM and the other old values. JSON.stringify(error) no longer includes the message; log error.message. See 'CatalystError shape (1.0.0)'.\n\n7. OPTIONAL, NON-BREAKING: rename deprecated hook alias keys to the canonical ones (see 'Native hooks \u2014 aliases and deprecations'); aliases keep working until 2.0.\n\nUnchanged: route arrays, preparedRoutes, server/index.js, server/server.js and server/document.js contracts, server hooks, and WEBVIEW_CONFIG. Full guide: docs/content/12-Migration/02-Version-Upgrades.md.",
1381
1381
  "layer": "Framework",
1382
1382
  "source": "static",
1383
1383
  "tags": [
@@ -1393,7 +1393,13 @@
1393
1393
  "react-router",
1394
1394
  "peer-dependency",
1395
1395
  "peerDependency",
1396
- "node"
1396
+ "node",
1397
+ "react-router-dom",
1398
+ "preflight",
1399
+ "module-aliases",
1400
+ "stable",
1401
+ "latest",
1402
+ "0.3.0-beta.5"
1397
1403
  ],
1398
1404
  "github_files": ["docs/content/12-Migration/02-Version-Upgrades.md"]
1399
1405
  },
@@ -1427,5 +1433,29 @@
1427
1433
  "packages/catalyst-core/src/errors/index.js",
1428
1434
  "packages/catalyst-core/src/native/bridge/errors.ts"
1429
1435
  ]
1436
+ },
1437
+ {
1438
+ "section": "native_hooks",
1439
+ "title": "On-Device AI (catalyst-ai) — Setup & Engines",
1440
+ "content": "catalyst-ai runs LLMs ON THE DEVICE inside the native app (no API key, works offline after model download) alongside its cloud providers (openai, gemini). Two on-device engines: (1) LiteRT-LM running Gemma 4 E2B (default model 'gemma-4-E2B', ~2.6 GB downloaded once on first use; Android and iOS; iOS needs iPhone 13 Pro or newer / 6 GB RAM). (2) Apple Foundation Models (iOS 26+ on Apple Intelligence devices only; no download; ~4,000 tokens of context shared by system prompt, history and reply). SETUP: npm install catalyst-ai; in config/config.json set WEBVIEW_CONFIG.ai = { \"enabled\": true } (native plugin is composed ONLY when ai.enabled is true — installing the package alone adds nothing to the build; if ai.enabled but catalyst-ai is missing from node_modules the build warns and skips the plugin); rebuild the native app (catalyst build / run android|ios). iOS adds the LiteRT-LM Swift package (~120 MB, vendored wrapper, no Git LFS) and the com.apple.developer.kernel.increased-memory-limit entitlement — that capability must be allowed for the App ID on a physical device or signing fails. iOS simulator needs an Apple Silicon Mac (no Intel slice); Foundation Models does not work in a simulator older than the host macOS.",
1441
+ "layer": "Runtime",
1442
+ "source": "static",
1443
+ "tags": "ai,catalyst-ai,native ai,on-device,gemma,gemma 4,e2b,litert,litert-lm,foundation models,apple intelligence,useAI,useNativeAI"
1444
+ },
1445
+ {
1446
+ "section": "native_hooks",
1447
+ "title": "On-Device AI — Choosing Gemma 4 E2B or Apple Foundation Models",
1448
+ "content": "Pick the engine per hook or app-wide. PER HOOK: useAI({ provider: 'litert', model: 'gemma-4-E2B' }) pins Gemma 4 E2B via LiteRT-LM; useAI({ provider: 'foundation-models' }) pins Apple Foundation Models (iOS only); useAI({ provider: 'native' }) = auto. useNativeAI accepts { engine: 'auto'|'litert'|'foundation-models', model, modelPath }. APP-WIDE in config/config.json: \"AI_CONFIG\": { \"enabled\": true, \"browser\": { \"provider\": \"foundation-models\" } } (provider 'native' + engine 'litert' is equivalent to provider 'litert'). AUTO POLICY (default): LiteRT-LM if its model is already downloaded -> Apple Foundation Models if available -> LiteRT-LM (downloads the model first). 'auto' never starts the 2.6 GB download on its own just because Foundation Models is available; to force Gemma on such a device pass engine/provider 'litert'. modelPath loads a custom LiteRT-LM model file. The ON_AI_READY payload includes 'engine' so UI can show which is active; download progress arrives as nativeDownloadProgress. Streaming: LiteRT-LM emits deltas; Foundation Models snapshots are converted to deltas by the framework.",
1449
+ "layer": "Runtime",
1450
+ "source": "static",
1451
+ "tags": "ai,catalyst-ai,native ai,on-device,gemma,gemma 4,e2b,litert,litert-lm,foundation models,apple intelligence,useAI,useNativeAI"
1452
+ },
1453
+ {
1454
+ "section": "bridge_architecture",
1455
+ "title": "On-Device AI — Architecture & Security",
1456
+ "content": "iOS: the framework server exposes POST /ai/stream (SSE), /ai/generate and a preflight route that the JS nativeTransport (catalyst-ai/src/nativeTransport.js) calls; the Swift side is the CatalystAI plugin (plugins/ai: AIBridge, CatalystAIBridge, NativeBridgeAI, FoundationModelsAI) discovered through the CatalystPlugin composer (pluginComposerIos.js) from node_modules/catalyst-ai/plugins; events return via WebBridge ON_AI_* and window.CatalystPlugins is injected for synchronous detection. Android: NativeBridgeAI.kt in the catalyst-ai android module, synced when ai.enabled. The AI routes reject non-loopback peers. Cloud routes (/ai/:provider/generate|stream) have NO built-in auth or rate limiting — add them via addMiddlewares in server/server.js before exposing beyond a trusted network. Known deferred hardening: plugin-bridge origin check, loopback-only listener bind, resumable model downloads.",
1457
+ "layer": "Bridge",
1458
+ "source": "static",
1459
+ "tags": "ai,catalyst-ai,native ai,on-device,gemma,gemma 4,e2b,litert,litert-lm,foundation models,apple intelligence,useAI,useNativeAI"
1430
1460
  }
1431
1461
  ]
@@ -0,0 +1,52 @@
1
+ // check_config coverage for the on-device AI (catalyst-ai) settings.
2
+ const fs = require("fs")
3
+ const os = require("os")
4
+ const path = require("path")
5
+ const { handle_check_config }: typeof import("../tools/config.js") = require("../tools/config.js")
6
+
7
+ function makeProject(ai: unknown, aiConfig: unknown, installAi: boolean): string {
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-cfg-"))
9
+ fs.mkdirSync(path.join(root, "config"))
10
+ const config = { WEBVIEW_CONFIG: { LOCAL_IP: "192.168.0.11", ai }, AI_CONFIG: aiConfig }
11
+ fs.writeFileSync(path.join(root, "config", "config.json"), JSON.stringify(config))
12
+ if (installAi) {
13
+ fs.mkdirSync(path.join(root, "node_modules", "catalyst-ai"), { recursive: true })
14
+ fs.writeFileSync(path.join(root, "node_modules", "catalyst-ai", "package.json"), "{}")
15
+ }
16
+ return root
17
+ }
18
+
19
+ const aiFields = (r: ReturnType<typeof handle_check_config>) =>
20
+ [...r.issues, ...r.warnings].filter((f) => /ai/i.test(f.field))
21
+
22
+ describe("check_config: on-device AI", () => {
23
+ it("errors when ai.enabled but catalyst-ai is not installed", () => {
24
+ const r = handle_check_config({ project_path: makeProject({ enabled: true }, {}, false) })
25
+ expect(r.issues.some((i) => i.field === "WEBVIEW_CONFIG.ai")).toBe(true)
26
+ })
27
+
28
+ it("passes when ai.enabled and catalyst-ai is installed", () => {
29
+ const r = handle_check_config({ project_path: makeProject({ enabled: true }, {}, true) })
30
+ expect(r.passed.some((p) => p.field === "WEBVIEW_CONFIG.ai")).toBe(true)
31
+ expect(r.issues.some((i) => i.field === "WEBVIEW_CONFIG.ai")).toBe(false)
32
+ })
33
+
34
+ it("rejects an unknown engine", () => {
35
+ const r = handle_check_config({
36
+ project_path: makeProject({ enabled: true }, { browser: { engine: "gpt" } }, true),
37
+ })
38
+ expect(r.issues.some((i) => i.field === "AI_CONFIG.browser.engine")).toBe(true)
39
+ })
40
+
41
+ it("warns when an on-device provider is set but ai.enabled is off", () => {
42
+ const r = handle_check_config({
43
+ project_path: makeProject(undefined, { browser: { provider: "litert" } }, true),
44
+ })
45
+ expect(r.warnings.some((w) => w.field === "WEBVIEW_CONFIG.ai.enabled")).toBe(true)
46
+ })
47
+
48
+ it("stays silent for apps that do not use AI", () => {
49
+ const r = handle_check_config({ project_path: makeProject(undefined, {}, false) })
50
+ expect(aiFields(r)).toEqual([])
51
+ })
52
+ })
@@ -0,0 +1,18 @@
1
+ // Hand-written type declaration for config.js (plain CJS, see issue #420);
2
+ // used only by test/config.test.ts. Keep in sync by hand.
3
+
4
+ export interface ConfigFinding {
5
+ field: string
6
+ severity?: string
7
+ message?: string
8
+ note?: string
9
+ }
10
+
11
+ export interface CheckConfigResult {
12
+ issues: ConfigFinding[]
13
+ warnings: ConfigFinding[]
14
+ passed: ConfigFinding[]
15
+ }
16
+
17
+ export function init(projectInfo: { dir: string }): void
18
+ export function handle_check_config(args?: { project_path?: string; platform?: string }): CheckConfigResult
@@ -434,6 +434,46 @@ function handle_check_config({ project_path, platform = "both" } = {}) {
434
434
  }
435
435
  }
436
436
 
437
+ // ── ai (optional, on-device AI via catalyst-ai) ───────────────────────────
438
+ if (wc.ai && wc.ai.enabled) {
439
+ const aiPkg = path.join(root, "node_modules", "catalyst-ai", "package.json")
440
+ if (!fs.existsSync(aiPkg)) {
441
+ issues.push({
442
+ field: "WEBVIEW_CONFIG.ai",
443
+ severity: "error",
444
+ message:
445
+ "ai.enabled=true but catalyst-ai is not installed. The native AI plugin is silently skipped and on-device AI will not work. Run: npm install catalyst-ai",
446
+ })
447
+ } else {
448
+ passed.push({ field: "WEBVIEW_CONFIG.ai", note: "enabled=true and catalyst-ai installed" })
449
+ }
450
+ const browser = (config.AI_CONFIG && config.AI_CONFIG.browser) || {}
451
+ const engine = browser.engine
452
+ if (engine !== undefined && !["auto", "litert", "foundation-models"].includes(engine)) {
453
+ issues.push({
454
+ field: "AI_CONFIG.browser.engine",
455
+ severity: "error",
456
+ message: `Unknown engine "${engine}". Use "auto", "litert" (Gemma 4 E2B) or "foundation-models" (Apple, iOS 26+).`,
457
+ })
458
+ }
459
+ if (browser.provider === "foundation-models" && platform === "android") {
460
+ warnings.push({
461
+ field: "AI_CONFIG.browser.provider",
462
+ message:
463
+ "foundation-models is iOS-only (iOS 26+, Apple Intelligence devices). Android needs provider 'litert' or 'native'.",
464
+ })
465
+ }
466
+ } else if (
467
+ config.AI_CONFIG &&
468
+ config.AI_CONFIG.browser &&
469
+ /^(native|litert|foundation-models)$/.test(config.AI_CONFIG.browser.provider || "")
470
+ ) {
471
+ warnings.push({
472
+ field: "WEBVIEW_CONFIG.ai.enabled",
473
+ message: `AI_CONFIG.browser.provider is "${config.AI_CONFIG.browser.provider}" (on-device) but WEBVIEW_CONFIG.ai.enabled is not true — the native AI module is not built into the app.`,
474
+ })
475
+ }
476
+
437
477
  return _buildResult({ root, configPath, platform, config, issues, warnings, passed })
438
478
  }
439
479
 
@@ -92,7 +92,7 @@ function handle_get_conversion_status({ project_path, include_not_applicable = f
92
92
  review_context: {
93
93
  question: "Which catalyst-core version is this project targeting?",
94
94
  what_correct_looks_like:
95
- "0.2.x uses @tata1mg/router; 0.3.x+ removes it and imports Catalyst router APIs from catalyst-core.",
95
+ "0.2.x uses @tata1mg/router; 0.3.x removes it and imports router APIs from catalyst-core; 1.0.0+ imports router names from react-router.",
96
96
  what_gap_looks_like:
97
97
  "Router dependencies and imports are mixed across runtime generations.",
98
98
  },
@@ -111,7 +111,7 @@ function handle_get_conversion_status({ project_path, include_not_applicable = f
111
111
  status: "gap",
112
112
  native_risk:
113
113
  "Catalyst 0.3.x owns the router implementation. Keeping @tata1mg/router can install a second routing runtime and produce incompatible React elements.",
114
- reason: "Remove @tata1mg/router and import router APIs from catalyst-core",
114
+ reason: "Remove @tata1mg/router and import router APIs from catalyst-core on 0.3.x, or from react-router on 1.0.0+",
115
115
  }
116
116
  return { status: "completed", note: "Current 0.3.x integrated router contract detected." }
117
117
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catalyst-core",
3
- "version": "1.0.0",
3
+ "version": "1.0.1-canary.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.jsx",
6
6
  "description": "Web framework that provides great performance out of the box",
@@ -57,7 +57,7 @@
57
57
  "test:unit": "node --test \"test/**/*.test.js\"",
58
58
  "typecheck": "tsc --noEmit",
59
59
  "prettify": "prettier . --write",
60
- "prepare": "node ../../scripts/safe-rimraf.js ./dist && node scripts/build-dist.mjs && cp -r ./src/native ./dist/native && babel src/native --out-dir dist/native --config-file ./babel.native.config.cjs --extensions '.js,.jsx,.ts,.tsx' --ignore '**/.build/**' && node scripts/strip-ts-sources.mjs ./dist/native && babel src/sentry.js --out-file dist/sentry.cjs --config-file ./babel.native.config.cjs && tsc -p tsconfig.declarations.json && node ./mcp_v2/scripts/copyErrorsIndex.cjs",
60
+ "prepare": "node scripts/safe-rimraf.cjs ./dist && node scripts/build-dist.mjs && cp -r ./src/native ./dist/native && babel src/native --out-dir dist/native --config-file ./babel.native.config.cjs --extensions '.js,.jsx,.ts,.tsx' --ignore '**/.build/**' && node scripts/strip-ts-sources.mjs ./dist/native && babel src/sentry.js --out-file dist/sentry.cjs --config-file ./babel.native.config.cjs && tsc -p tsconfig.declarations.json && node ./mcp_v2/scripts/copyErrorsIndex.cjs",
61
61
  "prepublishOnly": "npm run prepare",
62
62
  "test": "npm run test:types && vitest run",
63
63
  "test:watch": "vitest",
@@ -72,9 +72,9 @@
72
72
  "@vitejs/plugin-react": "^4.4.1",
73
73
  "ansis": "^4.3.1",
74
74
  "body-parser": "^1.20.2",
75
- "compression": "^1.7.4",
75
+ "compression": "^1.8.2",
76
76
  "cookie-parser": "^1.4.6",
77
- "express": "^4.18.2",
77
+ "express": "^4.22.3",
78
78
  "express-static-gzip": "^2.1.7",
79
79
  "react": "19.0.0",
80
80
  "react-dom": "19.0.0",
@@ -114,7 +114,7 @@
114
114
  "@types/node": "^22.20.1",
115
115
  "@types/react": "^19.2.18",
116
116
  "@types/react-dom": "^19.2.5",
117
- "@vitest/coverage-v8": "4.1.9",
117
+ "@vitest/coverage-v8": "4.1.11",
118
118
  "eslint": "^8.26.0",
119
119
  "eslint-plugin-babel": "^5.3.1",
120
120
  "eslint-plugin-react": "^7.34.1",
@@ -126,8 +126,9 @@
126
126
  "jsdom": "30.0.1",
127
127
  "lint-staged": "^15.2.2",
128
128
  "prettier": "^3.2.5",
129
+ "react-router": "^7.18.2",
129
130
  "typescript": "^7.0.2",
130
- "vitest": "4.1.9"
131
+ "vitest": "4.1.11"
131
132
  },
132
133
  "lint-staged": {
133
134
  "*.{js,jsx}": [
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { resolveMode, resolveNativeEngine } from "../../src/native/bridge/hooks/aiProviders"
3
+
4
+ describe("useAI provider resolution", () => {
5
+ it("maps on-device provider names to native mode", () => {
6
+ for (const provider of ["native", "litert", "foundation-models"]) {
7
+ expect(resolveMode(provider)).toBe("native")
8
+ }
9
+ })
10
+
11
+ it("keeps the other modes unchanged", () => {
12
+ expect(resolveMode("transformers")).toBe("local")
13
+ expect(resolveMode("openai")).toBe("cloud")
14
+ expect(resolveMode("gemini")).toBe("cloud")
15
+ expect(resolveMode(undefined)).toBe("cloud")
16
+ })
17
+
18
+ it("does not treat Object prototype keys as providers", () => {
19
+ expect(resolveMode("toString")).toBe("cloud")
20
+ expect(resolveMode("constructor")).toBe("cloud")
21
+ })
22
+
23
+ it("pins the engine from the provider alias", () => {
24
+ expect(resolveNativeEngine("litert", undefined, undefined)).toBe("litert")
25
+ expect(resolveNativeEngine("foundation-models", undefined, undefined)).toBe("foundation-models")
26
+ })
27
+
28
+ it("'native' leaves the choice to the platform unless config or an option names an engine", () => {
29
+ expect(resolveNativeEngine("native", undefined, undefined)).toBeUndefined()
30
+ expect(resolveNativeEngine("native", undefined, { engine: "foundation-models" })).toBe("foundation-models")
31
+ expect(resolveNativeEngine("native", "litert", { engine: "foundation-models" })).toBe("litert")
32
+ })
33
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { createElement } from "react"
3
+ import { renderToString } from "react-dom/server"
4
+ import { useSafeNavigate } from "../../src/native/bridge/hooks/useSafeNavigate"
5
+
6
+ // The node test environment has no `window`, i.e. it is a server render with no Router in scope —
7
+ // exactly the dev-SSR situation where react-router's useNavigate used to throw.
8
+ describe("useSafeNavigate during server rendering", () => {
9
+ it("renders without a Router and returns a callable navigate", () => {
10
+ expect(typeof window).toBe("undefined")
11
+ let navigate: any
12
+ const Probe = () => {
13
+ navigate = useSafeNavigate()
14
+ return createElement("div", null, "ok")
15
+ }
16
+
17
+ expect(renderToString(createElement(Probe))).toContain("ok")
18
+ expect(typeof navigate).toBe("function")
19
+ expect(() => navigate("/somewhere")).not.toThrow()
20
+ })
21
+ })
@@ -0,0 +1,161 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+ import fs from "fs"
4
+ import os from "os"
5
+ import path from "path"
6
+ import { createRequire } from "module"
7
+ import { fileURLToPath } from "url"
8
+
9
+ // src/native is a CommonJS subtree (see its package.json); load it with require from this ESM test.
10
+ const require = createRequire(import.meta.url)
11
+ const { composeIosPlugins } = require("../../src/native/pluginComposerIos.js")
12
+ const { resolveAIPluginSource, resolveInternalPluginsRoot } = require("../../src/native/internalPluginUtils.js")
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url))
15
+ const repoRoot = path.resolve(here, "../../../..")
16
+ const coreRoot = path.resolve(here, "../..")
17
+ const aiPluginsRoot = path.join(repoRoot, "packages", "catalyst-ai", "plugins")
18
+
19
+ function tempDir() {
20
+ return fs.mkdtempSync(path.join(os.tmpdir(), "catalyst-ai-compose-"))
21
+ }
22
+
23
+ // A fake app: <project>/node_modules/catalyst-ai/plugins -> the real in-repo plugin directory.
24
+ function fakeAppWithCatalystAI() {
25
+ const project = tempDir()
26
+ const pkgDir = path.join(project, "node_modules", "catalyst-ai")
27
+ fs.mkdirSync(pkgDir, { recursive: true })
28
+ fs.symlinkSync(aiPluginsRoot, path.join(pkgDir, "plugins"))
29
+ return project
30
+ }
31
+
32
+ test("resolveAIPluginSource: nothing happens unless ai.enabled is true", () => {
33
+ const project = fakeAppWithCatalystAI()
34
+ for (const config of [{}, { ai: {} }, { ai: { enabled: false } }, undefined]) {
35
+ assert.deepEqual(resolveAIPluginSource(config, project), { roots: [], toggles: {} })
36
+ }
37
+ })
38
+
39
+ test("resolveAIPluginSource: enabled + package present -> plugin root and ai toggle", () => {
40
+ const project = fakeAppWithCatalystAI()
41
+ const { roots, toggles } = resolveAIPluginSource({ ai: { enabled: true } }, project)
42
+ assert.deepEqual(roots, [path.join(project, "node_modules", "catalyst-ai", "plugins")])
43
+ assert.deepEqual(toggles, { ai: true })
44
+ })
45
+
46
+ test("resolveAIPluginSource: enabled but catalyst-ai missing -> warns and skips, build continues", () => {
47
+ const logs = []
48
+ const result = resolveAIPluginSource({ ai: { enabled: true } }, tempDir(), (message, status) =>
49
+ logs.push([status, message])
50
+ )
51
+ assert.deepEqual(result, { roots: [], toggles: {} })
52
+ assert.equal(logs.length, 1)
53
+ assert.equal(logs[0][0], "warning")
54
+ assert.match(logs[0][1], /catalyst-ai not found/)
55
+ })
56
+
57
+ test("composeIosPlugins: ai plugin contributes sources, registry entry, LiteRT-LM dependency and entitlement", () => {
58
+ const iosProjectPath = tempDir()
59
+ const composition = composeIosPlugins({
60
+ corePluginsRoot: resolveInternalPluginsRoot(coreRoot),
61
+ externalPluginRoots: [aiPluginsRoot],
62
+ iosProjectPath,
63
+ pluginConfig: { ai: true },
64
+ log: () => {},
65
+ })
66
+
67
+ assert.equal(composition.pluginCount, 1)
68
+ assert.deepEqual(
69
+ composition.iosDependencies.map((d) => ({
70
+ path: d.url,
71
+ package: d.package,
72
+ products: d.products,
73
+ requirement: d.requirement,
74
+ })),
75
+ [
76
+ {
77
+ path: path.join(aiPluginsRoot, "ai", "LiteRTLM"),
78
+ package: "LiteRTLM",
79
+ products: ["LiteRTLM"],
80
+ requirement: { type: "path", version: "local" },
81
+ },
82
+ ]
83
+ )
84
+ assert.deepEqual(composition.entitlements, { "com.apple.developer.kernel.increased-memory-limit": true })
85
+
86
+ const index = fs.readFileSync(
87
+ path.join(iosProjectPath, "Sources", "Core", "Plugins", "GeneratedPluginIndex.swift"),
88
+ "utf8"
89
+ )
90
+ assert.match(index, /"io\.catalyst\.ai": \{ CatalystAIPlugin\(\) \}/)
91
+ assert.match(index, /"io\.catalyst\.ai": Set\(\["clearConversation", "initAI"\]\)/)
92
+
93
+ const copied = path.join(iosProjectPath, "Sources", "Core", "Plugins", "Internal", "io_catalyst_ai")
94
+ for (const file of [
95
+ "CatalystAIPlugin.swift",
96
+ "CatalystAIBridge.swift",
97
+ "NativeBridgeAI.swift",
98
+ "FoundationModelsAI.swift",
99
+ "AIBridge.swift",
100
+ path.join("Support", "StreamDelta.swift"),
101
+ ]) {
102
+ assert.ok(fs.existsSync(path.join(copied, file)), `${file} should be copied into the app`)
103
+ }
104
+ })
105
+
106
+ test("composeIosPlugins: ai plugin is NOT composed when the ai toggle is off", () => {
107
+ const iosProjectPath = tempDir()
108
+ const composition = composeIosPlugins({
109
+ corePluginsRoot: resolveInternalPluginsRoot(coreRoot),
110
+ externalPluginRoots: [aiPluginsRoot],
111
+ iosProjectPath,
112
+ pluginConfig: {},
113
+ log: () => {},
114
+ })
115
+ assert.equal(composition.pluginCount, 0)
116
+ assert.deepEqual(composition.iosDependencies, [])
117
+ assert.deepEqual(composition.entitlements, {})
118
+ })
119
+
120
+ test("local-path iOS dependencies: must stay inside the plugin dir and exist", () => {
121
+ const dir = tempDir()
122
+ const write = (dependency) => {
123
+ const pluginDir = fs.mkdtempSync(path.join(dir, "p-"))
124
+ fs.mkdirSync(path.join(pluginDir, "Local"))
125
+ fs.mkdirSync(path.join(pluginDir, "ios"))
126
+ fs.writeFileSync(
127
+ path.join(pluginDir, "manifest.json"),
128
+ JSON.stringify({
129
+ id: "io.test.local",
130
+ configKey: "local",
131
+ version: "1.0.0",
132
+ displayName: "Local",
133
+ description: "x",
134
+ category: "test",
135
+ platforms: ["ios"],
136
+ commands: ["go"],
137
+ ios: { className: "LocalPlugin", dependencies: [dependency] },
138
+ })
139
+ )
140
+ return pluginDir
141
+ }
142
+ const { parsePluginManifest } = require("../../src/native/internalPluginUtils.js")
143
+
144
+ const ok = parsePluginManifest(write({ path: "Local", products: ["Local"] }))
145
+ assert.equal(ok.ios.dependencies[0].requirement.type, "path")
146
+ assert.equal(ok.ios.dependencies[0].package, "Local")
147
+
148
+ assert.throws(() => parsePluginManifest(write({ path: "../outside", products: ["X"] })), /must stay within the plugin directory/)
149
+ assert.throws(() => parsePluginManifest(write({ path: "Missing", products: ["X"] })), /does not exist/)
150
+
151
+ // A symlink inside the plugin that points outside it must be rejected (lexically it looks contained).
152
+ const escapeDir = write({ path: "Local", products: ["X"] })
153
+ const outside = fs.mkdtempSync(path.join(dir, "outside-"))
154
+ fs.symlinkSync(outside, path.join(escapeDir, "Link"))
155
+ const manifestPath = path.join(escapeDir, "manifest.json")
156
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"))
157
+ manifest.ios.dependencies = [{ path: "Link", products: ["X"] }]
158
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest))
159
+ assert.throws(() => parsePluginManifest(escapeDir), /resolves outside the plugin directory/)
160
+ assert.throws(() => parsePluginManifest(write({ path: "Local", from: "1.0.0", products: ["X"] })), /must not also set/)
161
+ })