create-apexjs 0.6.6 → 0.6.8
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/package.json +1 -1
- package/templates/default/AGENTS.md +6 -6
- package/templates/default/layouts/default.alpine +48 -19
- package/templates/features/data/db/index.ts +25 -24
- package/templates/mobile/android/README.md +53 -0
- package/templates/mobile/android/app/build.gradle.kts +30 -0
- package/templates/mobile/android/app/src/main/AndroidManifest.xml +24 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexBridge.kt +59 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexDbStore.kt +31 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexEngine.kt +66 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexInterceptor.kt +83 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/MainActivity.kt +96 -0
- package/templates/mobile/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
- package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
- package/templates/mobile/android/app/src/main/res/values/strings.xml +4 -0
- package/templates/mobile/android/app/src/main/res/values/themes.xml +11 -0
- package/templates/mobile/android/build.gradle.kts +4 -0
- package/templates/mobile/android/gradle.properties +3 -0
- package/templates/mobile/android/play_store_512.png +0 -0
- package/templates/mobile/android/settings.gradle.kts +4 -0
- package/templates/mobile/apex-bridge.js +22 -0
- package/templates/mobile/gen-mobile-assets.mjs +101 -0
- package/templates/mobile/ios/ApexApp.swift +106 -0
- package/templates/mobile/ios/ApexDbStore.swift +74 -0
- package/templates/mobile/ios/ApexEngine.swift +253 -0
- package/templates/mobile/ios/ApexSchemeHandler.swift +349 -0
- package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
- package/templates/mobile/ios/Assets.xcassets/LaunchBackground.colorset/Contents.json +20 -0
- package/templates/mobile/ios/Info.plist +60 -0
- package/templates/mobile/ios/README.md +153 -0
- package/templates/mobile/ios/Tests/ApexEngineTests.swift +74 -0
- package/templates/mobile/ios/project.yml +75 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// ApexEngine.swift — iOS embedded JS engine for the Apex on-device server bundle.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors android/ApexEngine.kt, but built on **JavaScriptCore** (`JSContext`) instead of
|
|
4
|
+
// androidx.javascriptengine.
|
|
5
|
+
//
|
|
6
|
+
// ── Why this diverges from Android ────────────────────────────────────────────────────────
|
|
7
|
+
// • Android uses Google's androidx.javascriptengine — an OUT-OF-PROCESS, WebView-backed JS
|
|
8
|
+
// sandbox. Connecting to it is asynchronous (`createConnectedInstanceAsync(...).await()`),
|
|
9
|
+
// which is why the Kotlin factory is a `suspend fun`.
|
|
10
|
+
// • iOS uses JavaScriptCore's `JSContext`, which is IN-PROCESS and synchronous to create and
|
|
11
|
+
// evaluate. So this initializer is a plain (throwing) init — no async boot handshake needed.
|
|
12
|
+
// • JSContext ALSO supports host callbacks (Swift closures exposed as JS functions + an
|
|
13
|
+
// exception handler). Android's sandbox does not; that's the capability we exploit both here
|
|
14
|
+
// (console/exception bridging) and in the scheme handler (reading POST bodies directly).
|
|
15
|
+
//
|
|
16
|
+
// Load order inside the context (identical contract to Android):
|
|
17
|
+
// 1) restore persisted DB snapshot into `globalThis.__APEX_DB_SNAPSHOT__` (if any),
|
|
18
|
+
// 2) evaluate `server.mjs` (from `apex build --mobile` — an IIFE that sets globalThis.APEX),
|
|
19
|
+
// 3) evaluate `apex-bridge.js` (defines globalThis.__apexHandle(json) -> Promise<json>).
|
|
20
|
+
//
|
|
21
|
+
// JavaScriptCore is a system framework (`import JavaScriptCore`) — no external dependency, the
|
|
22
|
+
// iOS analogue of Android needing the javascriptengine + coroutines-guava artifacts.
|
|
23
|
+
|
|
24
|
+
import Foundation
|
|
25
|
+
import JavaScriptCore
|
|
26
|
+
|
|
27
|
+
final class ApexEngine {
|
|
28
|
+
|
|
29
|
+
/// `JSContext` and every JSValue derived from it are NOT thread-safe, so all engine work is
|
|
30
|
+
/// funnelled through one dedicated thread. That thread has a LARGE stack on purpose:
|
|
31
|
+
/// JavaScriptCore ties its JS-recursion limit to the native stack, and a normal DispatchQueue
|
|
32
|
+
/// worker's ~512 KB stack overflows heavy JS ("Maximum call stack size exceeded") — notably the
|
|
33
|
+
/// asm.js SQLite. 16 MB is plenty and matches what a WKWebView's JS thread gets.
|
|
34
|
+
private let worker: ApexEngineThread
|
|
35
|
+
private let context: JSContext
|
|
36
|
+
|
|
37
|
+
// MARK: - Boot
|
|
38
|
+
|
|
39
|
+
/// Build the engine: create the JSContext, wire host globals JSCore lacks, restore the DB
|
|
40
|
+
/// snapshot, then evaluate the server bundle + bridge. Synchronous (see file header for why
|
|
41
|
+
/// this is not async like the Kotlin version). Throws if the bundle resources are missing.
|
|
42
|
+
init(snapshot: String?, bundle: Bundle = .main) throws {
|
|
43
|
+
self.worker = ApexEngineThread(stackSize: 16 * 1024 * 1024)
|
|
44
|
+
|
|
45
|
+
// Read the bundle resources on the calling thread (plain file I/O).
|
|
46
|
+
let serverJS = try ApexEngine.bundledSource(resource: "server", ext: "mjs", bundle: bundle)
|
|
47
|
+
let bridgeJS = try ApexEngine.bundledSource(resource: "apex-bridge", ext: "js", bundle: bundle)
|
|
48
|
+
|
|
49
|
+
// Everything that touches the context happens on the high-stack worker thread.
|
|
50
|
+
let created: JSContext? = worker.sync {
|
|
51
|
+
guard let context = JSContext() else { return nil }
|
|
52
|
+
|
|
53
|
+
// Surface JS errors instead of failing silently. JSCore has no default exception handler.
|
|
54
|
+
context.exceptionHandler = { _, exception in
|
|
55
|
+
let message = exception?.toString() ?? "unknown"
|
|
56
|
+
let stack = exception?.objectForKeyedSubscript("stack")?.toString() ?? ""
|
|
57
|
+
print("[ApexJS] uncaught JS exception: \(message)\n\(stack)")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// `console`: the mobile SHIM installs a NO-OP console via `globalThis.console || {…}`; JSCore
|
|
61
|
+
// has none, so we set a REAL one first and the shim keeps ours. The only extra global we add
|
|
62
|
+
// — everything else (Buffer/TextEncoder/URL/Request/Response/Headers/fetch/timers) is in the
|
|
63
|
+
// bundle's own shim, so we do NOT re-shim it (see NATIVE_SHELL.md).
|
|
64
|
+
ApexEngine.installConsole(into: context)
|
|
65
|
+
|
|
66
|
+
// 1) Restore a persisted DB snapshot (base64 of a prior db.export()) BEFORE the bundle boots.
|
|
67
|
+
if let snapshot, !snapshot.isEmpty {
|
|
68
|
+
context.setObject(snapshot, forKeyedSubscript: "__APEX_DB_SNAPSHOT__" as NSString)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2) + 3) Evaluate the self-contained server bundle (sets globalThis.APEX) then the bridge.
|
|
72
|
+
context.evaluateScript(serverJS, withSourceURL: URL(string: "apex-internal://server.mjs"))
|
|
73
|
+
context.evaluateScript(bridgeJS, withSourceURL: URL(string: "apex-internal://apex-bridge.js"))
|
|
74
|
+
|
|
75
|
+
let ready = context.evaluateScript("typeof globalThis.__apexHandle === 'function'")?.toBool() ?? false
|
|
76
|
+
if !ready { print("[ApexJS] WARNING: __apexHandle not defined after loading bundle") }
|
|
77
|
+
return context
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
guard let context = created else { throw ApexEngineError.contextCreationFailed }
|
|
81
|
+
self.context = context
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// MARK: - Request handling
|
|
85
|
+
|
|
86
|
+
/// Handle one request. `requestJSON` = {"url","method","headers","body"}.
|
|
87
|
+
/// Returns {"status","headers","body"} as JSON.
|
|
88
|
+
///
|
|
89
|
+
/// `__apexHandle` returns a JS `Promise<string>`. JavaScriptCore has no built-in async/await
|
|
90
|
+
/// bridge, so we resolve the promise by attaching `.then(onFulfilled, onRejected)` where both
|
|
91
|
+
/// handlers are Swift closures exposed as JS functions — the JSContext host-callback capability
|
|
92
|
+
/// Android's sandbox lacks. The continuation is resumed exactly once.
|
|
93
|
+
func handle(_ requestJSON: String) async -> String {
|
|
94
|
+
await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
|
|
95
|
+
worker.async { [context] in
|
|
96
|
+
guard
|
|
97
|
+
let handleFn = context.objectForKeyedSubscript("__apexHandle"),
|
|
98
|
+
!handleFn.isUndefined, !handleFn.isNull
|
|
99
|
+
else {
|
|
100
|
+
continuation.resume(returning: ApexEngine.errorResponseJSON("__apexHandle is not defined"))
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
guard let promise = handleFn.call(withArguments: [requestJSON]), promise.isObject else {
|
|
105
|
+
continuation.resume(returning: ApexEngine.errorResponseJSON("__apexHandle did not return a Promise"))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Guard single resumption. Safe as a plain var: every access happens on the worker thread
|
|
110
|
+
// (the .then callbacks fire during microtask drain on that same thread).
|
|
111
|
+
var resumed = false
|
|
112
|
+
let finish: (String) -> Void = { value in
|
|
113
|
+
if resumed { return }
|
|
114
|
+
resumed = true
|
|
115
|
+
continuation.resume(returning: value)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let onFulfilled: @convention(block) (JSValue?) -> Void = { value in
|
|
119
|
+
finish(value?.toString() ?? "")
|
|
120
|
+
}
|
|
121
|
+
let onRejected: @convention(block) (JSValue?) -> Void = { error in
|
|
122
|
+
finish(ApexEngine.errorResponseJSON(error?.toString() ?? "promise rejected"))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Attach both handlers in one `.then(f, r)` call. JavaScriptCore drains the promise
|
|
126
|
+
// microtask queue at the end of this JS turn; our pipeline is fully synchronous in-memory
|
|
127
|
+
// (no real I/O), so the promise settles and the callback runs before invokeMethod returns.
|
|
128
|
+
promise.invokeMethod(
|
|
129
|
+
"then",
|
|
130
|
+
withArguments: [
|
|
131
|
+
JSValue(object: onFulfilled, in: context) as Any,
|
|
132
|
+
JSValue(object: onRejected, in: context) as Any,
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// MARK: - Persistence seam
|
|
140
|
+
|
|
141
|
+
/// Current DB bytes (base64) for persistence, or "" if the app has no on-device DB.
|
|
142
|
+
/// Mirrors `ApexEngine.snapshot()` on Android — evaluated synchronously (the export function is
|
|
143
|
+
/// synchronous; if it were a Promise this would return "[object Promise]", same as Android).
|
|
144
|
+
func snapshot() -> String {
|
|
145
|
+
worker.sync {
|
|
146
|
+
let result = context.evaluateScript(
|
|
147
|
+
"(typeof __APEX_DB_EXPORT__==='function')?__APEX_DB_EXPORT__():''"
|
|
148
|
+
)
|
|
149
|
+
return result?.toString() ?? ""
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// MARK: - Helpers
|
|
154
|
+
|
|
155
|
+
private static func installConsole(into context: JSContext) {
|
|
156
|
+
// Uses JSContext.currentArguments() so variadic console.log(a, b, c) is captured, not just
|
|
157
|
+
// the first arg. A debug aid only; production can leave the shim's no-op console in place.
|
|
158
|
+
let log: @convention(block) () -> Void = {
|
|
159
|
+
let args = (JSContext.currentArguments() as? [JSValue]) ?? []
|
|
160
|
+
let line = args.map { $0.toString() ?? "" }.joined(separator: " ")
|
|
161
|
+
print("[ApexJS] " + line)
|
|
162
|
+
}
|
|
163
|
+
guard let console = JSValue(newObjectIn: context) else { return }
|
|
164
|
+
for method in ["log", "error", "warn", "info", "debug"] {
|
|
165
|
+
console.setObject(log, forKeyedSubscript: method as NSString)
|
|
166
|
+
}
|
|
167
|
+
context.setObject(console, forKeyedSubscript: "console" as NSString)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Read a bundled JS resource from the app bundle Resources as UTF-8 text.
|
|
171
|
+
private static func bundledSource(resource: String, ext: String, bundle: Bundle) throws -> String {
|
|
172
|
+
guard let url = bundle.url(forResource: resource, withExtension: ext) else {
|
|
173
|
+
throw ApexEngineError.missingResource("\(resource).\(ext)")
|
|
174
|
+
}
|
|
175
|
+
return try String(contentsOf: url, encoding: .utf8)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// A well-formed {status,headers,body} JSON string so the scheme handler can always render
|
|
179
|
+
/// something (a 500 page) instead of failing to parse.
|
|
180
|
+
static func errorResponseJSON(_ message: String) -> String {
|
|
181
|
+
let body = "<h1>Apex engine error</h1><pre>\(message)</pre>"
|
|
182
|
+
let payload: [String: Any] = [
|
|
183
|
+
"status": 500,
|
|
184
|
+
"headers": ["content-type": "text/html; charset=utf-8"],
|
|
185
|
+
"body": body,
|
|
186
|
+
]
|
|
187
|
+
if let data = try? JSONSerialization.data(withJSONObject: payload),
|
|
188
|
+
let json = String(data: data, encoding: .utf8) {
|
|
189
|
+
return json
|
|
190
|
+
}
|
|
191
|
+
return #"{"status":500,"headers":{"content-type":"text/html"},"body":"Apex engine error"}"#
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
enum ApexEngineError: Error, CustomStringConvertible {
|
|
196
|
+
case contextCreationFailed
|
|
197
|
+
case missingResource(String)
|
|
198
|
+
|
|
199
|
+
var description: String {
|
|
200
|
+
switch self {
|
|
201
|
+
case .contextCreationFailed: return "Failed to create JSContext"
|
|
202
|
+
case .missingResource(let name): return "Missing bundle resource: \(name)"
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// A single long-lived thread with a LARGE stack that runs a run loop, so all JSContext work has
|
|
208
|
+
/// enough native stack for heavy JS. JavaScriptCore derives its JS call-stack limit from the
|
|
209
|
+
/// native thread stack; the default GCD worker stack (~512 KB) overflows the asm.js SQLite with
|
|
210
|
+
/// "Maximum call stack size exceeded". Work is marshalled on with `perform(_:on:...)`.
|
|
211
|
+
final class ApexEngineThread: NSObject {
|
|
212
|
+
private let thread: Thread
|
|
213
|
+
|
|
214
|
+
init(stackSize: Int) {
|
|
215
|
+
let ready = DispatchSemaphore(value: 0)
|
|
216
|
+
let t = Thread {
|
|
217
|
+
// Keep the run loop alive with a dummy port source, then run it forever.
|
|
218
|
+
let runLoop = RunLoop.current
|
|
219
|
+
runLoop.add(NSMachPort(), forMode: .default)
|
|
220
|
+
ready.signal()
|
|
221
|
+
runLoop.run()
|
|
222
|
+
}
|
|
223
|
+
t.stackSize = stackSize
|
|
224
|
+
t.name = "site.apexjs.shell.engine"
|
|
225
|
+
self.thread = t
|
|
226
|
+
super.init()
|
|
227
|
+
t.start()
|
|
228
|
+
ready.wait() // ensure the run loop is up before we schedule work
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// Boxes a closure so it can ride across `perform(_:on:with:)` (which takes an object).
|
|
232
|
+
private final class Work {
|
|
233
|
+
let block: () -> Void
|
|
234
|
+
init(_ block: @escaping () -> Void) { self.block = block }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@objc private func run(_ box: Any) { (box as? Work)?.block() }
|
|
238
|
+
|
|
239
|
+
/// Run asynchronously on the engine thread.
|
|
240
|
+
func async(_ block: @escaping () -> Void) {
|
|
241
|
+
perform(#selector(run(_:)), on: thread, with: Work(block), waitUntilDone: false)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Run synchronously on the engine thread and return its result. `waitUntilDone: true` executes
|
|
245
|
+
/// the block before returning, so the non-escaping closure is safe via `withoutActuallyEscaping`.
|
|
246
|
+
func sync<T>(_ block: () -> T) -> T {
|
|
247
|
+
var result: T!
|
|
248
|
+
withoutActuallyEscaping(block) { escapable in
|
|
249
|
+
perform(#selector(run(_:)), on: thread, with: Work({ result = escapable() }), waitUntilDone: true)
|
|
250
|
+
}
|
|
251
|
+
return result
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// ApexSchemeHandler.swift — iOS native shell: custom `apex://` scheme → on-device engine.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors android/ApexInterceptor.kt + android/ApexBridge.kt COMBINED. Every request the WebView
|
|
4
|
+
// makes is intercepted here, handed to the embedded engine's `__apexHandle(...)`, and the returned
|
|
5
|
+
// {status, headers, body} is served straight back — NO network, NO localhost server, NO port (so
|
|
6
|
+
// nothing to suspend when the app backgrounds).
|
|
7
|
+
//
|
|
8
|
+
// ── The big iOS advantage over Android ────────────────────────────────────────────────────
|
|
9
|
+
// Android needs TWO paths because `WebViewClient.shouldInterceptRequest` CANNOT read a request
|
|
10
|
+
// body: navigations/GET go through ApexInterceptor, while body-bearing fetch()/POST are rerouted
|
|
11
|
+
// through a JS bridge (ApexBridge + a document-start fetch patch).
|
|
12
|
+
//
|
|
13
|
+
// On iOS, `WKURLSchemeHandler` receives the FULL `URLRequest`, including `httpBody` — so a single
|
|
14
|
+
// handler covers BOTH navigations and body-bearing fetch()/POST. No JS fetch patch, no
|
|
15
|
+
// @JavascriptInterface bridge. This one file replaces both Android files.
|
|
16
|
+
//
|
|
17
|
+
// ⚠️ Known caveat (flag for the Mac tester): on some iOS versions WKWebView has historically
|
|
18
|
+
// dropped the body from custom-scheme requests (`httpBody`/`httpBodyStream` come back nil for
|
|
19
|
+
// POST). If a Mac tester observes empty POST bodies, the fallback is the Android-style approach:
|
|
20
|
+
// a `WKScriptMessageHandler` + document-start fetch patch that forwards fetch() bodies. We read
|
|
21
|
+
// httpBody AND drain httpBodyStream below to maximise the chance the body is present.
|
|
22
|
+
//
|
|
23
|
+
// Cookies: the session is an HttpOnly cookie the engine issues (Set-Cookie) and expects back
|
|
24
|
+
// (Cookie header). Custom-scheme requests do NOT participate in WKHTTPCookieStore, so — exactly
|
|
25
|
+
// like Android's CookieManager usage — we manage it ourselves: inject the stored Cookie on the way
|
|
26
|
+
// in, persist any Set-Cookie on the way out. Kept in this file (a small ApexCookieJar) per spec.
|
|
27
|
+
|
|
28
|
+
import Foundation
|
|
29
|
+
import WebKit
|
|
30
|
+
|
|
31
|
+
final class ApexSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
32
|
+
/// The URL scheme this handler is registered for. The WebView loads `apex://localhost/splash`.
|
|
33
|
+
static let scheme = "apex"
|
|
34
|
+
|
|
35
|
+
private let engine: ApexEngine
|
|
36
|
+
private let cookieJar = ApexCookieJar()
|
|
37
|
+
|
|
38
|
+
/// Tasks currently in flight. WKURLSchemeTask is not thread-safe and must not be messaged after
|
|
39
|
+
/// `stop` (doing so crashes). We track live tasks and no-op once a task has been stopped.
|
|
40
|
+
private var activeTasks = Set<ObjectIdentifier>()
|
|
41
|
+
private let lock = NSLock()
|
|
42
|
+
|
|
43
|
+
init(engine: ApexEngine) {
|
|
44
|
+
self.engine = engine
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// MARK: - WKURLSchemeHandler
|
|
48
|
+
|
|
49
|
+
func webView(_ webView: WKWebView, start task: WKURLSchemeTask) {
|
|
50
|
+
let id = ObjectIdentifier(task)
|
|
51
|
+
lock.lock(); activeTasks.insert(id); lock.unlock()
|
|
52
|
+
|
|
53
|
+
guard let url = task.request.url else {
|
|
54
|
+
finishFailing(task, id: id, error: URLError(.badURL))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
let path = url.path.isEmpty ? "/" : url.path
|
|
58
|
+
|
|
59
|
+
// 1) Static client assets straight from the app bundle (client JS/CSS bundle, favicon) —
|
|
60
|
+
// mirrors ApexInterceptor's `/assets/` + `/favicon.svg` fast path. No engine round-trip.
|
|
61
|
+
if path.hasPrefix("/assets/") || path == "/favicon.svg" {
|
|
62
|
+
if serveBundledAsset(path: path, url: url, task: task, id: id) { return }
|
|
63
|
+
// Fall through to the engine if the file isn't in the bundle (lets the engine 404 cleanly).
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2) Everything else → the on-device engine. Build the {url,method,headers,body} request JSON,
|
|
67
|
+
// INCLUDING the body (the iOS advantage), injecting the stored session cookie.
|
|
68
|
+
let method = (task.request.httpMethod ?? "GET").uppercased()
|
|
69
|
+
var headers = task.request.allHTTPHeaderFields ?? [:]
|
|
70
|
+
|
|
71
|
+
// Inject the stored HttpOnly session cookie if the request doesn't already carry one.
|
|
72
|
+
if headers["cookie"] == nil, headers["Cookie"] == nil,
|
|
73
|
+
let stored = cookieJar.cookieHeader(), !stored.isEmpty {
|
|
74
|
+
headers["cookie"] = stored
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let bodyString = ApexSchemeHandler.readBody(from: task.request)
|
|
78
|
+
|
|
79
|
+
var requestObject: [String: Any] = [
|
|
80
|
+
"url": url.absoluteString,
|
|
81
|
+
"method": method,
|
|
82
|
+
"headers": headers,
|
|
83
|
+
]
|
|
84
|
+
requestObject["body"] = bodyString ?? NSNull()
|
|
85
|
+
|
|
86
|
+
guard
|
|
87
|
+
let data = try? JSONSerialization.data(withJSONObject: requestObject),
|
|
88
|
+
let requestJSON = String(data: data, encoding: .utf8)
|
|
89
|
+
else {
|
|
90
|
+
finishFailing(task, id: id, error: URLError(.cannotParseResponse))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Off the main thread: call the engine, then hop back to the main thread to message the task
|
|
95
|
+
// (WKURLSchemeTask callbacks must be delivered consistently; we use the main thread).
|
|
96
|
+
Task { [weak self] in
|
|
97
|
+
guard let self else { return }
|
|
98
|
+
let responseJSON = await self.engine.handle(requestJSON)
|
|
99
|
+
|
|
100
|
+
// Persist any Set-Cookie so a login (via fetch) is visible to the next full page load.
|
|
101
|
+
self.cookieJar.persistSetCookie(from: responseJSON)
|
|
102
|
+
|
|
103
|
+
// A mutating request may have changed the DB — persist a fresh snapshot so it survives a
|
|
104
|
+
// cold start (mirrors ApexBridge). All body-bearing writes come through here.
|
|
105
|
+
if method != "GET", method != "HEAD", method != "OPTIONS" {
|
|
106
|
+
ApexDbStore.write(self.engine.snapshot())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await MainActor.run {
|
|
110
|
+
self.deliver(responseJSON: responseJSON, url: url, task: task, id: id)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {
|
|
116
|
+
// Mark the task dead so any in-flight completion no-ops instead of messaging a stopped task.
|
|
117
|
+
lock.lock(); activeTasks.remove(ObjectIdentifier(task)); lock.unlock()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// MARK: - Response delivery
|
|
121
|
+
|
|
122
|
+
/// Parse the engine's {status,headers,body} JSON and stream it to the WebView task.
|
|
123
|
+
private func deliver(responseJSON: String, url: URL, task: WKURLSchemeTask, id: ObjectIdentifier) {
|
|
124
|
+
guard isActive(id) else { return }
|
|
125
|
+
|
|
126
|
+
guard
|
|
127
|
+
let data = responseJSON.data(using: .utf8),
|
|
128
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
129
|
+
else {
|
|
130
|
+
finishFailing(task, id: id, error: URLError(.cannotParseResponse))
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let status = (obj["status"] as? Int) ?? 200
|
|
135
|
+
let body = (obj["body"] as? String) ?? ""
|
|
136
|
+
var headers = (obj["headers"] as? [String: String]) ?? ["content-type": "text/html; charset=utf-8"]
|
|
137
|
+
|
|
138
|
+
let bodyData = body.data(using: .utf8) ?? Data()
|
|
139
|
+
// Set Content-Length so the WebView knows the body is complete.
|
|
140
|
+
if headers["content-length"] == nil, headers["Content-Length"] == nil {
|
|
141
|
+
headers["Content-Length"] = String(bodyData.count)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
guard let response = HTTPURLResponse(
|
|
145
|
+
url: url,
|
|
146
|
+
statusCode: status,
|
|
147
|
+
httpVersion: "HTTP/1.1",
|
|
148
|
+
headerFields: headers
|
|
149
|
+
) else {
|
|
150
|
+
finishFailing(task, id: id, error: URLError(.cannotParseResponse))
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
task.didReceive(response)
|
|
155
|
+
task.didReceive(bodyData)
|
|
156
|
+
task.didFinish()
|
|
157
|
+
markDone(id)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// MARK: - Static assets
|
|
161
|
+
|
|
162
|
+
/// Serve a bundled `/assets/...` or `/favicon.svg` file. Returns true if handled.
|
|
163
|
+
///
|
|
164
|
+
/// Assets are copied into the app bundle under an `assets/` folder reference (see ios/README.md),
|
|
165
|
+
/// so `/assets/app-abc123.js` maps to bundle resource `app-abc123.js` in subdirectory `assets`,
|
|
166
|
+
/// and `/favicon.svg` maps to `favicon.svg` at the bundle root.
|
|
167
|
+
private func serveBundledAsset(path: String, url: URL, task: WKURLSchemeTask, id: ObjectIdentifier) -> Bool {
|
|
168
|
+
let fileURL: URL?
|
|
169
|
+
if path == "/favicon.svg" {
|
|
170
|
+
fileURL = Bundle.main.url(forResource: "favicon", withExtension: "svg")
|
|
171
|
+
} else {
|
|
172
|
+
// "/assets/app-abc123.js" → resource "app-abc123", ext "js", subdirectory "assets".
|
|
173
|
+
let name = (path as NSString).lastPathComponent // app-abc123.js
|
|
174
|
+
let ext = (name as NSString).pathExtension // js
|
|
175
|
+
let base = (name as NSString).deletingPathExtension // app-abc123
|
|
176
|
+
let subdir = ((path as NSString).deletingLastPathComponent as NSString)
|
|
177
|
+
.lastPathComponent // assets
|
|
178
|
+
fileURL = Bundle.main.url(forResource: base, withExtension: ext, subdirectory: subdir)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
guard let fileURL, let fileData = try? Data(contentsOf: fileURL) else {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let headers = [
|
|
186
|
+
"content-type": ApexSchemeHandler.mimeType(for: path),
|
|
187
|
+
"content-length": String(fileData.count),
|
|
188
|
+
// Client bundle filenames are content-hashed → safe to cache aggressively.
|
|
189
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
190
|
+
]
|
|
191
|
+
guard let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: headers) else {
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
guard isActive(id) else { return true }
|
|
196
|
+
task.didReceive(response)
|
|
197
|
+
task.didReceive(fileData)
|
|
198
|
+
task.didFinish()
|
|
199
|
+
markDone(id)
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// MARK: - Task bookkeeping
|
|
204
|
+
|
|
205
|
+
private func isActive(_ id: ObjectIdentifier) -> Bool {
|
|
206
|
+
lock.lock(); defer { lock.unlock() }
|
|
207
|
+
return activeTasks.contains(id)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private func markDone(_ id: ObjectIdentifier) {
|
|
211
|
+
lock.lock(); activeTasks.remove(id); lock.unlock()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func finishFailing(_ task: WKURLSchemeTask, id: ObjectIdentifier, error: Error) {
|
|
215
|
+
guard isActive(id) else { return }
|
|
216
|
+
task.didFailWithError(error)
|
|
217
|
+
markDone(id)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// MARK: - Body + MIME helpers
|
|
221
|
+
|
|
222
|
+
/// Read the request body: prefer `httpBody`, fall back to draining `httpBodyStream`.
|
|
223
|
+
/// (See the caveat in the file header about custom-scheme bodies on some iOS versions.)
|
|
224
|
+
private static func readBody(from request: URLRequest) -> String? {
|
|
225
|
+
if let body = request.httpBody {
|
|
226
|
+
return String(data: body, encoding: .utf8)
|
|
227
|
+
}
|
|
228
|
+
guard let stream = request.httpBodyStream else { return nil }
|
|
229
|
+
stream.open()
|
|
230
|
+
defer { stream.close() }
|
|
231
|
+
var data = Data()
|
|
232
|
+
let bufferSize = 4096
|
|
233
|
+
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
|
234
|
+
while stream.hasBytesAvailable {
|
|
235
|
+
let read = stream.read(&buffer, maxLength: bufferSize)
|
|
236
|
+
if read > 0 { data.append(buffer, count: read) } else { break }
|
|
237
|
+
}
|
|
238
|
+
return data.isEmpty ? nil : String(data: data, encoding: .utf8)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/// Mirrors ApexInterceptor.mimeOf.
|
|
242
|
+
private static func mimeType(for path: String) -> String {
|
|
243
|
+
switch (path as NSString).pathExtension.lowercased() {
|
|
244
|
+
case "js", "mjs": return "text/javascript; charset=utf-8"
|
|
245
|
+
case "css": return "text/css; charset=utf-8"
|
|
246
|
+
case "svg": return "image/svg+xml"
|
|
247
|
+
case "json": return "application/json; charset=utf-8"
|
|
248
|
+
case "png": return "image/png"
|
|
249
|
+
case "jpg", "jpeg": return "image/jpeg"
|
|
250
|
+
case "webp": return "image/webp"
|
|
251
|
+
case "woff2": return "font/woff2"
|
|
252
|
+
case "woff": return "font/woff"
|
|
253
|
+
case "ttf": return "font/ttf"
|
|
254
|
+
case "ico": return "image/x-icon"
|
|
255
|
+
default: return "application/octet-stream"
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// MARK: - Cookie jar
|
|
261
|
+
|
|
262
|
+
/// Manages the HttpOnly session cookie the engine issues, the way Android's CookieManager usage in
|
|
263
|
+
/// ApexBridge/ApexInterceptor does. Custom `apex://` requests don't flow through WKHTTPCookieStore,
|
|
264
|
+
/// so we keep a tiny name→value jar ourselves and persist it (mirrors CookieManager's on-disk
|
|
265
|
+
/// persistence) so a login survives a cold start.
|
|
266
|
+
private final class ApexCookieJar {
|
|
267
|
+
private let defaultsKey = "site.apexjs.shell.cookies"
|
|
268
|
+
private let lock = NSLock()
|
|
269
|
+
private var jar: [String: String] // cookie name → value
|
|
270
|
+
|
|
271
|
+
init() {
|
|
272
|
+
let stored = UserDefaults.standard.dictionary(forKey: defaultsKey) as? [String: String]
|
|
273
|
+
jar = stored ?? [:]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// The `Cookie:` header value to send to the engine (all stored name=value pairs), or nil.
|
|
277
|
+
func cookieHeader() -> String? {
|
|
278
|
+
lock.lock(); defer { lock.unlock() }
|
|
279
|
+
guard !jar.isEmpty else { return nil }
|
|
280
|
+
return jar.map { "\($0.key)=\($0.value)" }.joined(separator: "; ")
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Store any Set-Cookie from an engine response JSON. Mirrors ApexBridge.persistSetCookie:
|
|
284
|
+
/// a combined header may hold several cookies separated by ", " before a `name=`.
|
|
285
|
+
func persistSetCookie(from responseJSON: String) {
|
|
286
|
+
guard
|
|
287
|
+
let data = responseJSON.data(using: .utf8),
|
|
288
|
+
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
289
|
+
let headers = obj["headers"] as? [String: Any]
|
|
290
|
+
else { return }
|
|
291
|
+
|
|
292
|
+
// Header name may be any case.
|
|
293
|
+
let raw: String? = (headers["set-cookie"] as? String) ?? (headers["Set-Cookie"] as? String)
|
|
294
|
+
guard let setCookie = raw, !setCookie.isEmpty else { return }
|
|
295
|
+
|
|
296
|
+
lock.lock(); defer { lock.unlock() }
|
|
297
|
+
for chunk in ApexCookieJar.splitCombinedSetCookie(setCookie) {
|
|
298
|
+
// A single cookie: "name=value; Path=/; HttpOnly; ..." — take the first `name=value` pair.
|
|
299
|
+
let firstPair = chunk.split(separator: ";", maxSplits: 1).first.map(String.init) ?? chunk
|
|
300
|
+
guard let eq = firstPair.firstIndex(of: "=") else { continue }
|
|
301
|
+
let name = firstPair[..<eq].trimmingCharacters(in: .whitespaces)
|
|
302
|
+
let value = firstPair[firstPair.index(after: eq)...].trimmingCharacters(in: .whitespaces)
|
|
303
|
+
guard !name.isEmpty else { continue }
|
|
304
|
+
// A cookie cleared with an expiry in the past / empty value → drop it. Simplified vs a full
|
|
305
|
+
// RFC 6265 expiry parse; enough for the single-session PoC (logout clears the value).
|
|
306
|
+
if value.isEmpty {
|
|
307
|
+
jar.removeValue(forKey: name)
|
|
308
|
+
} else {
|
|
309
|
+
jar[name] = value
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
UserDefaults.standard.set(jar, forKey: defaultsKey)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/// Split a combined Set-Cookie header on the boundary ", " that precedes a new `name=` — the
|
|
316
|
+
/// same heuristic as Android's regex `,(?=[^;,]+=)`. Avoids splitting on commas inside an
|
|
317
|
+
/// `Expires=Wed, 09 Jun 2021 ...` attribute.
|
|
318
|
+
private static func splitCombinedSetCookie(_ header: String) -> [String] {
|
|
319
|
+
var results: [String] = []
|
|
320
|
+
var current = ""
|
|
321
|
+
let chars = Array(header)
|
|
322
|
+
var i = 0
|
|
323
|
+
while i < chars.count {
|
|
324
|
+
if chars[i] == "," {
|
|
325
|
+
// Look ahead: is the next token `<name>=` before any ';' or ','? If so, this comma is a
|
|
326
|
+
// cookie separator; otherwise it's part of an attribute value (e.g. an Expires date).
|
|
327
|
+
var j = i + 1
|
|
328
|
+
while j < chars.count, chars[j] == " " { j += 1 }
|
|
329
|
+
var k = j
|
|
330
|
+
var sawEquals = false
|
|
331
|
+
while k < chars.count, chars[k] != ";", chars[k] != "," {
|
|
332
|
+
if chars[k] == "=" { sawEquals = true; break }
|
|
333
|
+
k += 1
|
|
334
|
+
}
|
|
335
|
+
if sawEquals {
|
|
336
|
+
results.append(current.trimmingCharacters(in: .whitespaces))
|
|
337
|
+
current = ""
|
|
338
|
+
i = j
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
current.append(chars[i])
|
|
343
|
+
i += 1
|
|
344
|
+
}
|
|
345
|
+
let tail = current.trimmingCharacters(in: .whitespaces)
|
|
346
|
+
if !tail.isEmpty { results.append(tail) }
|
|
347
|
+
return results
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"colors": [
|
|
3
|
+
{
|
|
4
|
+
"idiom": "universal",
|
|
5
|
+
"color": {
|
|
6
|
+
"color-space": "srgb",
|
|
7
|
+
"components": {
|
|
8
|
+
"red": "0x0B",
|
|
9
|
+
"green": "0x11",
|
|
10
|
+
"blue": "0x20",
|
|
11
|
+
"alpha": "1.000"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"info": {
|
|
17
|
+
"author": "xcode",
|
|
18
|
+
"version": 1
|
|
19
|
+
}
|
|
20
|
+
}
|