catalyst-core 1.0.0 → 1.0.1-canary.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +15 -20
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +41 -9
- package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
- package/dist/native/bridge/hooks/aiProviders.js +27 -0
- package/dist/native/bridge/hooks/useAI.js +9 -8
- package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
- package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
- package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
- package/dist/native/buildAppIos.js +8 -2
- package/dist/native/buildIos/plugins.js +3 -1
- package/dist/native/internalPluginUtils.js +78 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
- package/dist/native/pluginComposerIos.js +5 -1
- package/dist/otel.js +34 -1
- package/dist/server/expressServer.js +1 -0
- package/dist/server/renderer/handler.jsx +23 -0
- package/dist/vite/vite.config.client.js +1 -1
- package/dist/vite/vite.config.js +0 -4
- package/dist/web-router/components/MetaTag.jsx +6 -13
- package/dist/web-router/components/MetaTag.test.jsx +40 -33
- package/mcp_v2/conversion-tasks.json +10 -9
- package/mcp_v2/knowledge-base.json +34 -4
- package/mcp_v2/test/config.test.ts +52 -0
- package/mcp_v2/tools/config.d.ts +18 -0
- package/mcp_v2/tools/config.js +40 -0
- package/mcp_v2/tools/conversion.js +2 -2
- package/package.json +6 -10
- package/test/hooks/aiProviders.test.ts +33 -0
- package/test/hooks/useSafeNavigate.test.ts +21 -0
- package/test/native/aiPluginComposition.test.js +161 -0
- package/test/server/handler.test.ts +27 -0
|
@@ -5,7 +5,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.useNativeTransition = void 0;
|
|
7
7
|
var _react = require("react");
|
|
8
|
-
var
|
|
8
|
+
var _useSafeNavigate = require("./useSafeNavigate.js");
|
|
9
9
|
var _NativeBridge = _interopRequireDefault(require("../utils/NativeBridge.js"));
|
|
10
10
|
var _NativeInterfaces = require("../constants/NativeInterfaces.js");
|
|
11
11
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -40,7 +40,7 @@ const MIN_TIMEOUT = 800;
|
|
|
40
40
|
* @param defaults - Default transition options applied to every navigate call
|
|
41
41
|
*/
|
|
42
42
|
const useNativeTransition = (defaults = {}) => {
|
|
43
|
-
const router = (0,
|
|
43
|
+
const router = (0, _useSafeNavigate.useSafeNavigate)();
|
|
44
44
|
const isNative = typeof window !== "undefined" && _NativeBridge.default.isAvailable();
|
|
45
45
|
const [transitioning, setTransitioning] = (0, _react.useState)(false);
|
|
46
46
|
const overlayRef = (0, _react.useRef)(null);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useNavigate } from "react-router";
|
|
2
|
+
/**
|
|
3
|
+
* useNavigate() that is safe to call while server rendering.
|
|
4
|
+
*
|
|
5
|
+
* Server rendering never navigates, so it skips useNavigate there. In dev SSR src/native is compiled to
|
|
6
|
+
* CommonJS while the server's StaticRouter is ESM, which can load a second copy of react-router with no
|
|
7
|
+
* Router context; useNavigate would then throw and take the request, and the dev server, down. `window`
|
|
8
|
+
* is fixed per environment, so the hook order is stable.
|
|
9
|
+
*/
|
|
10
|
+
export declare const useSafeNavigate: () => ReturnType<typeof useNavigate>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useSafeNavigate = void 0;
|
|
7
|
+
var _reactRouter = require("react-router");
|
|
8
|
+
// Stand-in for useNavigate() during server rendering, where nothing can navigate.
|
|
9
|
+
const ssrNavigate = () => {};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* useNavigate() that is safe to call while server rendering.
|
|
13
|
+
*
|
|
14
|
+
* Server rendering never navigates, so it skips useNavigate there. In dev SSR src/native is compiled to
|
|
15
|
+
* CommonJS while the server's StaticRouter is ESM, which can load a second copy of react-router with no
|
|
16
|
+
* Router context; useNavigate would then throw and take the request, and the dev server, down. `window`
|
|
17
|
+
* is fixed per environment, so the hook order is stable.
|
|
18
|
+
*/
|
|
19
|
+
const useSafeNavigate = () =>
|
|
20
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
21
|
+
typeof window === "undefined" ? ssrNavigate : (0, _reactRouter.useNavigate)();
|
|
22
|
+
exports.useSafeNavigate = useSafeNavigate;
|
|
@@ -10,7 +10,8 @@ const {
|
|
|
10
10
|
} = require("./pluginComposerIos.js");
|
|
11
11
|
const {
|
|
12
12
|
resolveInternalPluginsRoot,
|
|
13
|
-
resolvePluginConfig
|
|
13
|
+
resolvePluginConfig,
|
|
14
|
+
resolveAIPluginSource
|
|
14
15
|
} = require("./internalPluginUtils.js");
|
|
15
16
|
const {
|
|
16
17
|
formatBuildError
|
|
@@ -36,9 +37,14 @@ async function main() {
|
|
|
36
37
|
} = build;
|
|
37
38
|
try {
|
|
38
39
|
progress.log("Starting build process...", "info");
|
|
39
|
-
const
|
|
40
|
+
const aiPlugin = resolveAIPluginSource(WEBVIEW_CONFIG, process.cwd(), (message, status = "info") => progress.log(message, status));
|
|
41
|
+
const pluginConfig = {
|
|
42
|
+
...resolvePluginConfig(WEBVIEW_CONFIG),
|
|
43
|
+
...aiPlugin.toggles
|
|
44
|
+
};
|
|
40
45
|
const pluginComposition = composeIosPlugins({
|
|
41
46
|
corePluginsRoot: resolveInternalPluginsRoot(catalystCorePath),
|
|
47
|
+
externalPluginRoots: aiPlugin.roots,
|
|
42
48
|
iosProjectPath: PROJECT_DIR,
|
|
43
49
|
pluginConfig,
|
|
44
50
|
log: (message, status = "info") => progress.log(message, status)
|
|
@@ -38,7 +38,9 @@ function formatSwiftPackageRequirement(dependency) {
|
|
|
38
38
|
throw new Error(`Unsupported iOS package requirement type: ${dependency.requirement.type}`);
|
|
39
39
|
}
|
|
40
40
|
function formatSwiftPackageEntries(dependencies) {
|
|
41
|
-
return dependencies.map(d =>
|
|
41
|
+
return dependencies.map(d =>
|
|
42
|
+
// Local packages (requirement.type "path") are addressed by directory, not URL + version.
|
|
43
|
+
d.requirement.type === "path" ? ` .package(path: ${JSON.stringify(d.url)})` : ` .package(url: "${d.url}", ${formatSwiftPackageRequirement(d)})`);
|
|
42
44
|
}
|
|
43
45
|
function formatSwiftProductEntries(dependencies) {
|
|
44
46
|
return dependencies.flatMap(d => d.products.map(p => ` .product(name: "${p}", package: "${d.package}")`));
|
|
@@ -64,6 +64,45 @@ function readIosUrlSchemes(value, fieldName, sourcePath) {
|
|
|
64
64
|
};
|
|
65
65
|
});
|
|
66
66
|
}
|
|
67
|
+
function readLocalIosDependency(entry, entryField, sourcePath) {
|
|
68
|
+
const pluginDir = path.dirname(sourcePath);
|
|
69
|
+
const relativePath = mustBeNonEmptyString(entry.path, `${entryField}.path`, sourcePath);
|
|
70
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - containment within pluginDir is enforced immediately below.
|
|
71
|
+
const resolved = path.resolve(pluginDir, relativePath);
|
|
72
|
+
if (resolved !== pluginDir && !resolved.startsWith(`${pluginDir}${path.sep}`)) {
|
|
73
|
+
throw new Error(`'${entryField}.path' must stay within the plugin directory: ${relativePath} in ${sourcePath}`);
|
|
74
|
+
}
|
|
75
|
+
if (!isDir(resolved)) {
|
|
76
|
+
throw new Error(`'${entryField}.path' does not exist: ${relativePath} in ${sourcePath}`);
|
|
77
|
+
}
|
|
78
|
+
// The lexical check above does not see symlinks: re-check containment on canonical paths so a link
|
|
79
|
+
// inside the plugin cannot point the Swift package outside it, and return the canonical path.
|
|
80
|
+
const realPluginDir = fs.realpathSync(pluginDir);
|
|
81
|
+
const realResolved = fs.realpathSync(resolved);
|
|
82
|
+
if (realResolved !== realPluginDir && !realResolved.startsWith(`${realPluginDir}${path.sep}`)) {
|
|
83
|
+
throw new Error(`'${entryField}.path' resolves outside the plugin directory: ${relativePath} in ${sourcePath}`);
|
|
84
|
+
}
|
|
85
|
+
if (entry.from != null || entry.exact != null || entry.url != null) {
|
|
86
|
+
throw new Error(`'${entryField}' with 'path' must not also set 'url', 'from' or 'exact' in ${sourcePath}`);
|
|
87
|
+
}
|
|
88
|
+
const products = readStringArray(entry.products, `${entryField}.products`, sourcePath, {
|
|
89
|
+
required: true,
|
|
90
|
+
nonEmpty: true
|
|
91
|
+
});
|
|
92
|
+
if (new Set(products).size !== products.length) {
|
|
93
|
+
throw new Error(`Duplicate product(s) found in '${entryField}.products' in ${sourcePath}`);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
// The absolute path doubles as the identity key the composer merges dependencies on.
|
|
97
|
+
url: realResolved,
|
|
98
|
+
package: entry.package == null ? path.basename(realResolved) : mustBeNonEmptyString(entry.package, `${entryField}.package`, sourcePath),
|
|
99
|
+
products,
|
|
100
|
+
requirement: {
|
|
101
|
+
type: "path",
|
|
102
|
+
version: "local"
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
67
106
|
function readIosDependencies(value, fieldName, sourcePath) {
|
|
68
107
|
if (value == null) {
|
|
69
108
|
return [];
|
|
@@ -76,6 +115,13 @@ function readIosDependencies(value, fieldName, sourcePath) {
|
|
|
76
115
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
77
116
|
throw new Error(`'${entryField}' must be an object in ${sourcePath}`);
|
|
78
117
|
}
|
|
118
|
+
|
|
119
|
+
// A dependency is either a remote package ({ url, from|exact }) or a Swift package shipped inside
|
|
120
|
+
// the plugin itself ({ path }, relative to the plugin directory). Local packages let a plugin
|
|
121
|
+
// pin a vendored copy instead of cloning a large or LFS-backed upstream repo.
|
|
122
|
+
if (entry.path != null) {
|
|
123
|
+
return readLocalIosDependency(entry, entryField, sourcePath);
|
|
124
|
+
}
|
|
79
125
|
const url = mustBeNonEmptyString(entry.url, `${entryField}.url`, sourcePath);
|
|
80
126
|
const packageIdentity = entry.package == null ? derivePackageIdentityFromUrl(url, `${entryField}.url`, sourcePath) : mustBeNonEmptyString(entry.package, `${entryField}.package`, sourcePath);
|
|
81
127
|
const products = readStringArray(entry.products, `${entryField}.products`, sourcePath, {
|
|
@@ -224,7 +270,39 @@ function resolvePluginConfig(WEBVIEW_CONFIG) {
|
|
|
224
270
|
}
|
|
225
271
|
return pluginConfig;
|
|
226
272
|
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* catalyst-ai ships its native iOS module as a plugin (plugins/ai in the npm package). It is
|
|
276
|
+
* composed only when the app opts in with WEBVIEW_CONFIG.ai.enabled, mirroring Android's
|
|
277
|
+
* syncAIPackageIfEnabled. Returns the extra plugin roots plus the toggle to merge into the
|
|
278
|
+
* plugin config; when the package is missing it warns and leaves the build untouched.
|
|
279
|
+
*/
|
|
280
|
+
function resolveAIPluginSource(WEBVIEW_CONFIG, projectRoot, log = () => {}) {
|
|
281
|
+
if (WEBVIEW_CONFIG?.ai?.enabled !== true) {
|
|
282
|
+
return {
|
|
283
|
+
roots: [],
|
|
284
|
+
toggles: {}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal - fixed package-relative path under the app's node_modules.
|
|
289
|
+
const aiPluginsRoot = path.join(projectRoot, "node_modules", "catalyst-ai", "plugins");
|
|
290
|
+
if (!isDir(aiPluginsRoot)) {
|
|
291
|
+
log("ai.enabled=true but catalyst-ai not found in node_modules — skipping native AI plugin", "warning");
|
|
292
|
+
return {
|
|
293
|
+
roots: [],
|
|
294
|
+
toggles: {}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
roots: [aiPluginsRoot],
|
|
299
|
+
toggles: {
|
|
300
|
+
ai: true
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
227
304
|
module.exports = {
|
|
305
|
+
resolveAIPluginSource,
|
|
228
306
|
discoverInternalPlugins,
|
|
229
307
|
parsePluginManifest,
|
|
230
308
|
resolvePluginConfig,
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
//
|
|
2
|
+
// FrameworkServerUtils+NativeAI.swift
|
|
3
|
+
// iosnativeWebView
|
|
4
|
+
//
|
|
5
|
+
// Native-AI routes (/ai/stream, /ai/generate) and their supplier API, split out of FrameworkServerUtils.swift
|
|
6
|
+
// to keep that file within the lint limits. Kotlin equivalent: the AI routes in startKtorServer().
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import Foundation
|
|
10
|
+
import Network
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "FrameworkServer")
|
|
14
|
+
|
|
15
|
+
extension FrameworkServerUtils {
|
|
16
|
+
// MARK: - Native AI API
|
|
17
|
+
|
|
18
|
+
/// Installs the engine that answers `POST /ai/stream` and `POST /ai/generate`. Each request calls the supplier
|
|
19
|
+
/// with the (system-prompt-prefixed) prompt, the request's `genConfig` and its conversation id. While it is
|
|
20
|
+
/// `nil` (the default, and after passing `nil`) both routes answer "not ready" (`AI-005`) instead of running.
|
|
21
|
+
/// Set by the AI plugin once its engine has loaded.
|
|
22
|
+
public func setNativeAiSupplier(_ supplier: NativeAISupplier?) {
|
|
23
|
+
aiLock.lock(); defer { aiLock.unlock() }
|
|
24
|
+
nativeAiSupplier = supplier
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Sets the system prompt the server prepends (as `"<prompt>\n\n<user prompt>"`) to every AI request before it
|
|
28
|
+
/// reaches the supplier. Pass an empty string to prepend nothing, for engines that keep their instructions in
|
|
29
|
+
/// their own session. Read per request, so changes apply to the next one.
|
|
30
|
+
public func setNativeSystemPrompt(_ systemPrompt: String) {
|
|
31
|
+
aiLock.lock(); defer { aiLock.unlock() }
|
|
32
|
+
nativeSystemPrompt = systemPrompt
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// URL JS posts to for streaming; scheme follows whatever the listener actually runs (HTTP/HTTPS),
|
|
36
|
+
/// built the same way as served-file URLs.
|
|
37
|
+
public func nativeAIStreamURL() -> String? {
|
|
38
|
+
guard isServerRunning else { return nil }
|
|
39
|
+
let scheme = isHTTPS ? "https" : "http"
|
|
40
|
+
return "\(scheme)://localhost:\(serverPort)/framework-\(sessionId)/ai/stream"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
// MARK: - Native AI routes
|
|
45
|
+
// Kotlin equivalent: the post("/ai/stream"), post("/ai/generate") and options(...) routes in
|
|
46
|
+
// startKtorServer(). The engine behind the supplier lives in catalyst-ai, not here.
|
|
47
|
+
|
|
48
|
+
private func aiSnapshot() -> (supplier: NativeAISupplier?, systemPrompt: String) {
|
|
49
|
+
aiLock.lock(); defer { aiLock.unlock() }
|
|
50
|
+
return (nativeAiSupplier, nativeSystemPrompt)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// The server prepends the engine-provided system prompt, exactly like Android.
|
|
54
|
+
/// Engines that manage their own instructions simply never call setNativeSystemPrompt.
|
|
55
|
+
private func composePrompt(_ request: NativeAIRequest, systemPrompt: String) -> String {
|
|
56
|
+
systemPrompt.isEmpty ? request.prompt : "\(systemPrompt)\n\n\(request.prompt)"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func sendAIPreflight(on connection: NWConnection) {
|
|
60
|
+
sendHTTPResponse(on: connection, statusCode: 200, body: Data(), headers: [
|
|
61
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
62
|
+
"Access-Control-Max-Age": "86400",
|
|
63
|
+
])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func cancelAIStream(for connection: NWConnection) {
|
|
67
|
+
requestStateLock.lock()
|
|
68
|
+
let task = aiStreamTasks.removeValue(forKey: ObjectIdentifier(connection))
|
|
69
|
+
requestStateLock.unlock()
|
|
70
|
+
task?.cancel()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// A client that stops reading must not keep the model running: if a write is not accepted within this long,
|
|
74
|
+
/// the connection is dropped, which fails the send, ends the stream and cancels generation upstream.
|
|
75
|
+
static let aiWriteTimeout: TimeInterval = 30
|
|
76
|
+
|
|
77
|
+
private func sendRaw(_ data: Data, on connection: NWConnection) async -> Bool {
|
|
78
|
+
await withCheckedContinuation { continuation in
|
|
79
|
+
let deadline = DispatchWorkItem { connection.cancel() }
|
|
80
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + Self.aiWriteTimeout, execute: deadline)
|
|
81
|
+
connection.send(content: data, completion: .contentProcessed { error in
|
|
82
|
+
deadline.cancel()
|
|
83
|
+
if let error = error {
|
|
84
|
+
logger.debug("AI stream send failed: \(error.localizedDescription)")
|
|
85
|
+
}
|
|
86
|
+
continuation.resume(returning: error == nil)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private func sseHeaders() -> Data {
|
|
92
|
+
var head = "HTTP/1.1 200 OK\r\n"
|
|
93
|
+
head += "Access-Control-Allow-Origin: \(allowedOrigin)\r\n"
|
|
94
|
+
head += "Access-Control-Allow-Methods: POST, OPTIONS\r\n"
|
|
95
|
+
head += "Access-Control-Allow-Headers: *\r\n"
|
|
96
|
+
head += "Content-Type: text/event-stream\r\n"
|
|
97
|
+
head += "Cache-Control: no-cache\r\n"
|
|
98
|
+
head += "X-Accel-Buffering: no\r\n"
|
|
99
|
+
head += "Connection: close\r\n"
|
|
100
|
+
head += "\r\n"
|
|
101
|
+
return Data(head.utf8)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func startAIStream(_ request: NativeAIRequest, on connection: NWConnection) {
|
|
105
|
+
cancelConnectionTimeout(for: connection)
|
|
106
|
+
let key = ObjectIdentifier(connection)
|
|
107
|
+
|
|
108
|
+
// Held across Task creation so the task's own cleanup cannot run before it is registered.
|
|
109
|
+
requestStateLock.lock()
|
|
110
|
+
aiStreamTasks[key] = Task { [weak self] in
|
|
111
|
+
guard let self = self else { connection.cancel(); return }
|
|
112
|
+
await self.runAIStream(request, on: connection)
|
|
113
|
+
self.cancelAIStream(for: connection)
|
|
114
|
+
connection.cancel()
|
|
115
|
+
}
|
|
116
|
+
requestStateLock.unlock()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func runAIStream(_ request: NativeAIRequest, on connection: NWConnection) async {
|
|
120
|
+
guard await sendRaw(sseHeaders(), on: connection) else { return }
|
|
121
|
+
|
|
122
|
+
let (supplier, systemPrompt) = aiSnapshot()
|
|
123
|
+
let prompt = composePrompt(request, systemPrompt: systemPrompt)
|
|
124
|
+
|
|
125
|
+
guard let supplier = supplier else {
|
|
126
|
+
_ = await sendRaw(NativeAISSE.error("Native AI not initialised — call initAI() first", code: NativeAIErrorCode.streamNotReady), on: connection)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
guard !request.prompt.isEmpty else {
|
|
130
|
+
_ = await sendRaw(NativeAISSE.error("prompt is empty", code: NativeAIErrorCode.invalidRequestBody), on: connection)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
do {
|
|
135
|
+
let stream = try await supplier(prompt, request.genConfig, request.conversationId)
|
|
136
|
+
guard await sendRaw(NativeAISSE.conversationId(stream.conversationId), on: connection) else { return }
|
|
137
|
+
for try await token in stream.tokens {
|
|
138
|
+
// A failed send means the client disconnected; leaving the loop terminates the
|
|
139
|
+
// token stream, which is what stops generation upstream.
|
|
140
|
+
guard await sendRaw(NativeAISSE.token(token), on: connection) else { return }
|
|
141
|
+
}
|
|
142
|
+
_ = await sendRaw(NativeAISSE.done(), on: connection)
|
|
143
|
+
} catch is CancellationError {
|
|
144
|
+
logger.debug("Native AI stream cancelled (client disconnected)")
|
|
145
|
+
} catch {
|
|
146
|
+
let code = (error as? NativeAIError)?.code ?? NativeAIErrorCode.requestFailed
|
|
147
|
+
logger.error("Native AI stream error [\(code)] conversation=\(request.conversationId ?? "<new>"): \(error.localizedDescription)")
|
|
148
|
+
_ = await sendRaw(NativeAISSE.error(error.localizedDescription, code: code), on: connection)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private static let aiGenerateTimeout: TimeInterval = 120
|
|
153
|
+
|
|
154
|
+
/// Drains the token stream fully, then answers with one JSON body: { output, conversationId, tokenCount }.
|
|
155
|
+
func runAIGenerate(_ request: NativeAIRequest, on connection: NWConnection) {
|
|
156
|
+
cancelConnectionTimeout(for: connection)
|
|
157
|
+
let key = ObjectIdentifier(connection)
|
|
158
|
+
|
|
159
|
+
requestStateLock.lock()
|
|
160
|
+
aiStreamTasks[key] = Task { [weak self] in
|
|
161
|
+
guard let self = self else { connection.cancel(); return }
|
|
162
|
+
await self.respondAIGenerate(request, on: connection)
|
|
163
|
+
self.cancelAIStream(for: connection)
|
|
164
|
+
}
|
|
165
|
+
requestStateLock.unlock()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func respondAIGenerate(_ request: NativeAIRequest, on connection: NWConnection) async {
|
|
169
|
+
func fail(_ status: Int, _ message: String, _ code: String) {
|
|
170
|
+
let body = (try? JSONSerialization.data(withJSONObject: ["error": message, "code": code])) ?? Data()
|
|
171
|
+
sendHTTPResponse(on: connection, statusCode: status, body: body, headers: ["Content-Type": "application/json"])
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let (supplier, systemPrompt) = aiSnapshot()
|
|
175
|
+
guard let supplier = supplier else {
|
|
176
|
+
fail(503, "Native AI not initialised — call initAI() first", NativeAIErrorCode.streamNotReady)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
guard !request.prompt.isEmpty else {
|
|
180
|
+
fail(400, "prompt is empty", NativeAIErrorCode.invalidRequestBody)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let prompt = composePrompt(request, systemPrompt: systemPrompt)
|
|
185
|
+
do {
|
|
186
|
+
let result: (output: String, conversationId: String, tokenCount: Int) = try await withThrowingTaskGroup(of: (String, String, Int)?.self) { group in
|
|
187
|
+
group.addTask {
|
|
188
|
+
let stream = try await supplier(prompt, request.genConfig, request.conversationId)
|
|
189
|
+
var output = ""
|
|
190
|
+
var count = 0
|
|
191
|
+
for try await token in stream.tokens { output += token; count += 1 }
|
|
192
|
+
return (output, stream.conversationId, count)
|
|
193
|
+
}
|
|
194
|
+
group.addTask {
|
|
195
|
+
try await Task.sleep(nanoseconds: UInt64(Self.aiGenerateTimeout * 1_000_000_000))
|
|
196
|
+
return nil
|
|
197
|
+
}
|
|
198
|
+
defer { group.cancelAll() }
|
|
199
|
+
guard let first = try await group.next(), let value = first else {
|
|
200
|
+
throw NativeAIError(message: "generate timed out after \(Int(Self.aiGenerateTimeout))s")
|
|
201
|
+
}
|
|
202
|
+
return value
|
|
203
|
+
}
|
|
204
|
+
let body = try JSONSerialization.data(withJSONObject: [
|
|
205
|
+
"output": result.output,
|
|
206
|
+
"conversationId": result.conversationId,
|
|
207
|
+
"tokenCount": result.tokenCount,
|
|
208
|
+
])
|
|
209
|
+
sendHTTPResponse(on: connection, statusCode: 200, body: body, headers: ["Content-Type": "application/json"])
|
|
210
|
+
} catch is CancellationError {
|
|
211
|
+
connection.cancel()
|
|
212
|
+
} catch let error as NativeAIError where error.message.hasPrefix("generate timed out") {
|
|
213
|
+
fail(504, error.message, error.code)
|
|
214
|
+
} catch {
|
|
215
|
+
let code = (error as? NativeAIError)?.code ?? NativeAIErrorCode.requestFailed
|
|
216
|
+
logger.error("Native AI generate error [\(code)] conversation=\(request.conversationId ?? "<new>"): \(error.localizedDescription)")
|
|
217
|
+
fail(500, error.localizedDescription, code)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
//
|
|
2
|
+
// FrameworkServerUtils+Requests.swift
|
|
3
|
+
// iosnativeWebView
|
|
4
|
+
//
|
|
5
|
+
// HTTP request assembly (headers + Content-Length body across reads) and routing for the framework server,
|
|
6
|
+
// split out of FrameworkServerUtils.swift to keep that type within the lint limits.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
import Foundation
|
|
10
|
+
import Network
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app", category: "FrameworkServer")
|
|
14
|
+
|
|
15
|
+
extension FrameworkServerUtils {
|
|
16
|
+
/// Completion body of receiveHTTPRequest. Internal so tests can drive the
|
|
17
|
+
/// error branch directly; a real receive error is timing-dependent.
|
|
18
|
+
///
|
|
19
|
+
/// A POST's headers and body can arrive in separate reads, so bytes are
|
|
20
|
+
/// buffered per connection until the headers end (CRLFCRLF) and Content-Length
|
|
21
|
+
/// bytes of body are present. A GET has no body and dispatches immediately.
|
|
22
|
+
func handleReceivedData(_ data: Data?, isComplete: Bool, error: NWError?, on connection: NWConnection) {
|
|
23
|
+
if let error = error {
|
|
24
|
+
discardPendingRequest(for: connection)
|
|
25
|
+
handleReceiveError(error, on: connection)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
guard let data = data else {
|
|
30
|
+
discardPendingRequest(for: connection)
|
|
31
|
+
sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let key = ObjectIdentifier(connection)
|
|
36
|
+
// Append to the connection's own buffer in place: `old + new` would copy every byte received so far on
|
|
37
|
+
// each 8 KB read.
|
|
38
|
+
requestStateLock.lock()
|
|
39
|
+
var buffer = pendingRequests.removeValue(forKey: key) ?? Data()
|
|
40
|
+
buffer.append(data)
|
|
41
|
+
requestStateLock.unlock()
|
|
42
|
+
|
|
43
|
+
let terminator = Data("\r\n\r\n".utf8)
|
|
44
|
+
guard let headerEnd = buffer.range(of: terminator) else {
|
|
45
|
+
if buffer.count > Self.maxHeaderBytes {
|
|
46
|
+
discardPendingRequest(for: connection)
|
|
47
|
+
sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
|
|
48
|
+
} else if isComplete {
|
|
49
|
+
// Client closed mid-headers: fall through to the legacy single-read behaviour.
|
|
50
|
+
discardPendingRequest(for: connection)
|
|
51
|
+
dispatchRequest(headerData: buffer, body: Data(), isComplete: isComplete, on: connection)
|
|
52
|
+
} else {
|
|
53
|
+
requestStateLock.lock(); pendingRequests[key] = buffer; requestStateLock.unlock()
|
|
54
|
+
receiveHTTPRequest(on: connection)
|
|
55
|
+
}
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let headerData = buffer[buffer.startIndex..<headerEnd.lowerBound]
|
|
60
|
+
let body = buffer[headerEnd.upperBound...]
|
|
61
|
+
guard let expected = Self.contentLength(inHeaders: headerData) else {
|
|
62
|
+
discardPendingRequest(for: connection)
|
|
63
|
+
sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if expected > Self.maxBodyBytes {
|
|
68
|
+
discardPendingRequest(for: connection)
|
|
69
|
+
sendHTTPResponse(on: connection, statusCode: 413, body: "Payload Too Large")
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if body.count < expected {
|
|
73
|
+
if isComplete {
|
|
74
|
+
// The client closed the connection before sending the declared body: reject rather than
|
|
75
|
+
// handle a truncated request.
|
|
76
|
+
discardPendingRequest(for: connection)
|
|
77
|
+
sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
requestStateLock.lock(); pendingRequests[key] = buffer; requestStateLock.unlock()
|
|
81
|
+
receiveHTTPRequest(on: connection)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
discardPendingRequest(for: connection)
|
|
86
|
+
dispatchRequest(headerData: Data(headerData), body: Data(body.prefix(max(expected, 0))), isComplete: isComplete, on: connection)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func discardPendingRequest(for connection: NWConnection) {
|
|
90
|
+
requestStateLock.lock()
|
|
91
|
+
pendingRequests.removeValue(forKey: ObjectIdentifier(connection))
|
|
92
|
+
requestStateLock.unlock()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// The declared body length: 0 when there is no Content-Length header, nil when it is malformed or negative
|
|
96
|
+
/// (the request is then rejected instead of being read as an empty body).
|
|
97
|
+
static func contentLength(inHeaders headerData: Data) -> Int? {
|
|
98
|
+
guard let text = String(data: headerData, encoding: .utf8) else { return nil }
|
|
99
|
+
for line in text.components(separatedBy: "\r\n") {
|
|
100
|
+
let parts = line.split(separator: ":", maxSplits: 1)
|
|
101
|
+
if parts.count == 2, parts[0].trimmingCharacters(in: .whitespaces).lowercased() == "content-length" {
|
|
102
|
+
guard let length = Int(parts[1].trimmingCharacters(in: .whitespaces)), length >= 0 else { return nil }
|
|
103
|
+
return length
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// True when the peer is on this device. The listener is not bound to loopback only (that predates the AI
|
|
110
|
+
/// routes and also serves files), so the AI routes — which run the on-device model — check the peer themselves.
|
|
111
|
+
static func isLoopbackPeer(_ endpoint: NWEndpoint) -> Bool {
|
|
112
|
+
guard case let .hostPort(host, _) = endpoint else { return false }
|
|
113
|
+
switch host {
|
|
114
|
+
case .ipv4(let address):
|
|
115
|
+
return address.isLoopback
|
|
116
|
+
case .ipv6(let address):
|
|
117
|
+
return address.isLoopback || address.asIPv4?.isLoopback == true
|
|
118
|
+
case .name(let name, _):
|
|
119
|
+
return name == "localhost"
|
|
120
|
+
@unknown default:
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func dispatchRequest(headerData: Data, body: Data, isComplete: Bool, on connection: NWConnection) {
|
|
126
|
+
guard let requestString = String(data: headerData, encoding: .utf8) else {
|
|
127
|
+
sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let keepOpen = processHTTPRequest(requestString, body: body, on: connection)
|
|
132
|
+
|
|
133
|
+
// A client half-closing after sending its request must not kill an SSE stream we are still writing.
|
|
134
|
+
if isComplete && !keepOpen {
|
|
135
|
+
connection.cancel()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Returns true when the connection is now owned by a long-lived AI stream and must stay open.
|
|
140
|
+
@discardableResult
|
|
141
|
+
func processHTTPRequest(_ requestString: String, body: Data = Data(), on connection: NWConnection) -> Bool {
|
|
142
|
+
let lines = requestString.components(separatedBy: "\r\n")
|
|
143
|
+
guard let requestLine = lines.first else {
|
|
144
|
+
sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.badRequest, body: "Bad Request")
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let components = requestLine.components(separatedBy: " ")
|
|
149
|
+
guard components.count >= 2 else {
|
|
150
|
+
sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let method = components[0]
|
|
155
|
+
let aiPrefix = "/framework-\(self.sessionId)/ai/"
|
|
156
|
+
let isAIRoute = components[1] == aiPrefix + "stream" || components[1] == aiPrefix + "generate"
|
|
157
|
+
|
|
158
|
+
if isAIRoute {
|
|
159
|
+
guard Self.isLoopbackPeer(connection.endpoint) else {
|
|
160
|
+
logger.warning("Rejected AI request from a non-loopback peer")
|
|
161
|
+
sendHTTPResponse(on: connection, statusCode: 403, body: "Forbidden")
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
switch method {
|
|
165
|
+
case "OPTIONS":
|
|
166
|
+
sendAIPreflight(on: connection)
|
|
167
|
+
return false
|
|
168
|
+
case "POST":
|
|
169
|
+
if components[1] == aiPrefix + "stream" {
|
|
170
|
+
startAIStream(NativeAIRequest(body: body), on: connection)
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
runAIGenerate(NativeAIRequest(body: body), on: connection)
|
|
174
|
+
return true
|
|
175
|
+
default:
|
|
176
|
+
sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
guard method == "GET" else {
|
|
182
|
+
sendHTTPResponse(on: connection, statusCode: 405, body: "Method Not Allowed")
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let path = components[1]
|
|
187
|
+
|
|
188
|
+
// Handle status endpoint
|
|
189
|
+
if path == "/framework-\(self.sessionId)/status" {
|
|
190
|
+
fileQueue.sync {
|
|
191
|
+
let statusResponse = """
|
|
192
|
+
{
|
|
193
|
+
"status": "running",
|
|
194
|
+
"sessionId": "\(self.sessionId)",
|
|
195
|
+
"port": \(self.serverPort),
|
|
196
|
+
"servedFiles": \(self.servedFiles.count)
|
|
197
|
+
}
|
|
198
|
+
"""
|
|
199
|
+
sendHTTPResponse(on: connection, statusCode: 200, body: statusResponse, contentType: "application/json")
|
|
200
|
+
}
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Handle file requests
|
|
205
|
+
if path.hasPrefix("/framework-\(self.sessionId)/file-") {
|
|
206
|
+
let fileId = String(path.dropFirst("/framework-\(self.sessionId)/file-".count))
|
|
207
|
+
serveFile(fileId: fileId, on: connection)
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Invalid route
|
|
212
|
+
sendHTTPResponse(on: connection, statusCode: CatalystConstants.ErrorCodes.fileNotFound, body: "Not Found")
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
}
|