create-apexjs 0.6.7 → 0.6.9
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/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
- package/templates/mobile/splash.alpine +48 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
Info.plist for the Apex iOS shell.
|
|
4
|
+
|
|
5
|
+
Mirrors AndroidManifest.xml: a launcher app with a native launch screen and NO network
|
|
6
|
+
entitlement required (the backend runs on-device, offline). The animated pages/splash.alpine
|
|
7
|
+
route takes over after this static launch screen — the two-stage splash handoff.
|
|
8
|
+
|
|
9
|
+
Most $(…) values are Xcode build-setting placeholders; set them in the target's General tab.
|
|
10
|
+
-->
|
|
11
|
+
<plist version="1.0">
|
|
12
|
+
<dict>
|
|
13
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
14
|
+
<string>en</string>
|
|
15
|
+
<key>CFBundleDisplayName</key>
|
|
16
|
+
<string>Apex</string>
|
|
17
|
+
<key>CFBundleExecutable</key>
|
|
18
|
+
<string>$(EXECUTABLE_NAME)</string>
|
|
19
|
+
<key>CFBundleIdentifier</key>
|
|
20
|
+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
21
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
22
|
+
<string>6.0</string>
|
|
23
|
+
<key>CFBundleName</key>
|
|
24
|
+
<string>$(PRODUCT_NAME)</string>
|
|
25
|
+
<key>CFBundlePackageType</key>
|
|
26
|
+
<string>APPL</string>
|
|
27
|
+
<key>CFBundleShortVersionString</key>
|
|
28
|
+
<string>1.0</string>
|
|
29
|
+
<key>CFBundleVersion</key>
|
|
30
|
+
<string>1</string>
|
|
31
|
+
<key>LSRequiresIPhoneOS</key>
|
|
32
|
+
<true/>
|
|
33
|
+
|
|
34
|
+
<!-- Native cold-start launch screen: a solid brand-colour screen shown before any JS runs.
|
|
35
|
+
Uses the LaunchBackground colour asset (#0b1120) in Assets.xcassets. The animated
|
|
36
|
+
pages/splash.alpine renders immediately after and dismisses to '/'. -->
|
|
37
|
+
<key>UILaunchScreen</key>
|
|
38
|
+
<dict>
|
|
39
|
+
<key>UIColorName</key>
|
|
40
|
+
<string>LaunchBackground</string>
|
|
41
|
+
</dict>
|
|
42
|
+
|
|
43
|
+
<key>UISupportedInterfaceOrientations</key>
|
|
44
|
+
<array>
|
|
45
|
+
<string>UIInterfaceOrientationPortrait</string>
|
|
46
|
+
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
47
|
+
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
48
|
+
</array>
|
|
49
|
+
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
50
|
+
<array>
|
|
51
|
+
<string>UIInterfaceOrientationPortrait</string>
|
|
52
|
+
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
53
|
+
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
54
|
+
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
55
|
+
</array>
|
|
56
|
+
|
|
57
|
+
<!-- Status-bar handled by SwiftUI/WebView; no scene manifest needed for the SwiftUI App
|
|
58
|
+
lifecycle (@main struct App). -->
|
|
59
|
+
</dict>
|
|
60
|
+
</plist>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Apex iOS shell
|
|
2
|
+
|
|
3
|
+
> ⚠️ **UNVERIFIED — needs a Mac + Xcode to compile and run.** These files were written on Linux,
|
|
4
|
+
> mirroring the already-shipped, device-verified **Android** shell (`../android/`). No part of the
|
|
5
|
+
> iOS build has been compiled or run — treat it as a well-formed starting point, not a proven app.
|
|
6
|
+
> See **"For the Mac tester"** at the bottom for exactly what to check first.
|
|
7
|
+
|
|
8
|
+
A native iOS app that runs your Apex backend **on the device** (offline, no server, no port) and
|
|
9
|
+
renders it in a `WKWebView`. It is the 1:1 counterpart of the Android shell, using the two iOS
|
|
10
|
+
platform capabilities that make it *simpler* than Android:
|
|
11
|
+
|
|
12
|
+
| Concern | Android | iOS (here) |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| JS engine | `androidx.javascriptengine` (out-of-process, async boot, **no host callbacks**) | **JavaScriptCore `JSContext`** (in-process, sync boot, **host callbacks + exception handler**) |
|
|
15
|
+
| Request interception | `WebViewClient.shouldInterceptRequest` (**cannot read POST body**) | **`WKURLSchemeHandler`** for `apex://` (**reads `httpBody` directly**) |
|
|
16
|
+
| Body-bearing `fetch()`/POST | needs a **2nd path**: `@JavascriptInterface` bridge + document-start `fetch` patch | **not needed** — one scheme handler covers navigations *and* POST |
|
|
17
|
+
| Cookies (HttpOnly session) | `CookieManager` | small `ApexCookieJar` inside the scheme handler (custom schemes bypass `WKHTTPCookieStore`) |
|
|
18
|
+
| DB persistence | `apex-db.b64` in `filesDir`, atomic rename | `apex-db.b64` in Application Support, `FileManager.replaceItemAt` |
|
|
19
|
+
|
|
20
|
+
## What's here
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
ios/
|
|
24
|
+
├─ ApexApp.swift # @main SwiftUI App: boots engine, builds WKWebView, loads apex://localhost/splash
|
|
25
|
+
├─ ApexEngine.swift # JSContext wrapper — loads server.mjs + apex-bridge.js, runs __apexHandle
|
|
26
|
+
├─ ApexSchemeHandler.swift # WKURLSchemeHandler for apex:// → engine (assets from bundle) + cookie jar
|
|
27
|
+
├─ ApexDbStore.swift # DB snapshot persistence (Application Support, atomic)
|
|
28
|
+
├─ Info.plist # launch screen + app config (no network entitlement needed — offline)
|
|
29
|
+
└─ Assets.xcassets/
|
|
30
|
+
├─ AppIcon.appiconset/ # app icon (already present)
|
|
31
|
+
└─ LaunchBackground.colorset/ # #0b1120 launch-screen colour
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Architecture (identical contract to Android)
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
WKWebView ── every request ──► ApexSchemeHandler (apex:// scheme)
|
|
38
|
+
│ /assets/*, /favicon.svg → bundle files
|
|
39
|
+
│ everything else → engine, WITH body + injected Cookie
|
|
40
|
+
▼
|
|
41
|
+
ApexEngine.handle(requestJSON)
|
|
42
|
+
│ globalThis.__apexHandle(json) (apex-bridge.js)
|
|
43
|
+
│ └─ APEX.run(request) → { status, headers, body }
|
|
44
|
+
▼
|
|
45
|
+
{status,headers,body} ── served back ──► WKWebView hydrates
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Because interception is a **function call**, there's no listener and no port — nothing for iOS to
|
|
49
|
+
suspend when the app backgrounds. That's the whole point.
|
|
50
|
+
|
|
51
|
+
## Create the Xcode project & drop these files in
|
|
52
|
+
|
|
53
|
+
There is no `.xcodeproj` in this folder (it's binary/machine-specific). Create one on the Mac:
|
|
54
|
+
|
|
55
|
+
1. **Xcode → New → App.** Interface: **SwiftUI**, Language: **Swift**. Product name e.g. `ApexShell`.
|
|
56
|
+
Set the **Bundle Identifier** and a signing team in *Signing & Capabilities*.
|
|
57
|
+
2. **Delete** the auto-generated `ContentView.swift` and the generated `…App.swift` (this repo's
|
|
58
|
+
`ApexApp.swift` provides the `@main` App).
|
|
59
|
+
3. **Add these files** to the target (drag into the project navigator, *Copy items if needed* off if
|
|
60
|
+
you reference them in place): `ApexApp.swift`, `ApexEngine.swift`, `ApexSchemeHandler.swift`,
|
|
61
|
+
`ApexDbStore.swift`.
|
|
62
|
+
4. **Info.plist / Assets:** either use this folder's `Info.plist` (set *Build Settings → Info.plist
|
|
63
|
+
File* to point at it) or copy its `UILaunchScreen` + keys into the generated one. Add the
|
|
64
|
+
`LaunchBackground` colour set (and your `AppIcon`) to the target's asset catalog.
|
|
65
|
+
5. **Framework:** `import JavaScriptCore` — it's a **system framework**, no dependency to add
|
|
66
|
+
(link it in *Frameworks, Libraries, and Embedded Content* if the linker complains).
|
|
67
|
+
|
|
68
|
+
## Copy the built bundle + client assets into the app
|
|
69
|
+
|
|
70
|
+
From your **Apex app root**, build the mobile bundle:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
apex build --mobile # → dist/mobile/server.mjs
|
|
74
|
+
apex build # (or your normal build) → dist/assets/*, dist/favicon.svg
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then add these to the Xcode target so they land in the app bundle **Resources**:
|
|
78
|
+
|
|
79
|
+
| Source | Add to bundle as | Read by |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `dist/mobile/server.mjs` | `server.mjs` (bundle root) | `ApexEngine` → `Bundle.main.url(forResource:"server", withExtension:"mjs")` |
|
|
82
|
+
| `native-shell/apex-bridge.js` | `apex-bridge.js` (bundle root) | `ApexEngine` → `…forResource:"apex-bridge", withExtension:"js"` |
|
|
83
|
+
| `dist/assets/*` | an **`assets/` folder reference** (blue folder) | `ApexSchemeHandler` → `…forResource:base, withExtension:ext, subdirectory:"assets"` |
|
|
84
|
+
| `dist/favicon.svg` | `favicon.svg` (bundle root) | `ApexSchemeHandler` |
|
|
85
|
+
|
|
86
|
+
> **Important — use a *folder reference* (blue), not a *group* (yellow), for `assets/`.** A folder
|
|
87
|
+
> reference preserves the `assets/` subdirectory so `subdirectory: "assets"` lookups resolve. A
|
|
88
|
+
> group flattens filenames into the bundle root and the `/assets/...` path lookups will 404.
|
|
89
|
+
|
|
90
|
+
Also copy `native-shell/splash.alpine` into your app's `pages/splash.alpine` (the animated intro the
|
|
91
|
+
shell loads first) and rebuild — see `../NATIVE_SHELL.md`.
|
|
92
|
+
|
|
93
|
+
## Run
|
|
94
|
+
|
|
95
|
+
Select an iOS Simulator (or a provisioned device) and **⌘R**. You should see: the native launch
|
|
96
|
+
screen (#0b1120), then the animated `splash.alpine`, then your app — all served by the Apex server
|
|
97
|
+
running inside the app on JavaScriptCore. Turn the network off: it still works.
|
|
98
|
+
|
|
99
|
+
## On-device drivers
|
|
100
|
+
|
|
101
|
+
`apex build --mobile` reports routes needing `crypto.subtle` (sessions) or `@libsql/client/web`
|
|
102
|
+
(DB) — same as Android. JavaScriptCore provides a real `crypto`? **Not by default** — JSCore has no
|
|
103
|
+
WebCrypto. The bundle ships a stub so non-session routes boot; for real sessions, expose a native
|
|
104
|
+
WebCrypto shim as a host object on the `JSContext` (the JSContext host-callback capability makes
|
|
105
|
+
this straightforward — add it in `ApexEngine` before evaluating `server.mjs`). For DB, alias
|
|
106
|
+
`@libsql/client` → `@libsql/client/web` (sql.js asm.js) before bundling, as on Android.
|
|
107
|
+
|
|
108
|
+
## For the Mac tester (check these first — likeliest failure points)
|
|
109
|
+
|
|
110
|
+
1. **Bundle resources present.** If you see *"Apex engine failed to start"*, `server.mjs` /
|
|
111
|
+
`apex-bridge.js` aren't in the bundle. Confirm they're in *Build Phases → Copy Bundle Resources*.
|
|
112
|
+
2. **`/assets/*` 404 → white page after splash.** Almost certainly the `assets/` folder was added as
|
|
113
|
+
a *group* (yellow) not a *folder reference* (blue). Re-add as a blue folder. Verify by loading
|
|
114
|
+
`apex://localhost/assets/<one-file>.js` and watching the Xcode console.
|
|
115
|
+
3. **⭐ POST body reaches the engine.** This is the one genuinely unproven iOS assumption. The task's
|
|
116
|
+
premise is that `WKURLSchemeHandler` exposes `httpBody` for custom schemes — but some iOS
|
|
117
|
+
versions have historically **dropped** the body from custom-scheme requests. **Test a POST**
|
|
118
|
+
(log in, or add a guestbook entry) and confirm the engine receives a non-empty `body`. If it's
|
|
119
|
+
empty: fall back to the Android approach — add a `WKScriptMessageHandler` (`configuration.
|
|
120
|
+
userContentController.add(...)`) plus a document-start `fetch` patch (port `FETCH_PATCH` from
|
|
121
|
+
`../android/.../MainActivity.kt`) that forwards `fetch()` bodies to the handler. The engine and
|
|
122
|
+
cookie/DB plumbing here are unchanged either way.
|
|
123
|
+
4. **Promise resolution.** `ApexEngine.handle` resolves the JS promise via `.then(onFulfilled,
|
|
124
|
+
onRejected)` and assumes JavaScriptCore drains its microtask queue within the same JS turn (true
|
|
125
|
+
for our fully-synchronous in-memory pipeline). If a request ever hangs, that assumption broke —
|
|
126
|
+
pump with `JSVirtualMachine`/a run-loop tick, or wrap the handler to resolve on a `Promise`
|
|
127
|
+
settled callback.
|
|
128
|
+
5. **HttpOnly session cookie** survives a full page navigation and a cold start (login, force-quit,
|
|
129
|
+
relaunch → still logged in). The jar persists to `UserDefaults`.
|
|
130
|
+
6. **DB snapshot** survives a cold start (create data, force-quit, relaunch → data still there). File
|
|
131
|
+
is `apex-db.b64` in Application Support.
|
|
132
|
+
|
|
133
|
+
## Testing without a Mac — CI on the iOS Simulator
|
|
134
|
+
|
|
135
|
+
No Mac? The `ios-shell` GitHub Actions workflow (`.github/workflows/ios.yml`) builds this shell and
|
|
136
|
+
runs `Tests/ApexEngineTests.swift` on the iOS Simulator on a **free GitHub-hosted macOS runner**.
|
|
137
|
+
Trigger it from the repo's **Actions → ios-shell → Run workflow** (or it runs on pushes touching
|
|
138
|
+
`ios/`). It:
|
|
139
|
+
|
|
140
|
+
1. builds the packages + the showcase `--mobile` bundle,
|
|
141
|
+
2. stages `server.mjs` / `apex-bridge.js` / `assets/` / `favicon.svg` into `ios/Generated/`,
|
|
142
|
+
3. `xcodegen generate` (from `project.yml` — no committed `.xcodeproj`),
|
|
143
|
+
4. `xcodebuild test` on an iPhone simulator.
|
|
144
|
+
|
|
145
|
+
`ApexEngineTests` proves the bundle — the shim, SSR + API pipeline, **asm.js SQLite**, sealed-cookie
|
|
146
|
+
auth, and the snapshot persistence seam — actually runs under **JavaScriptCore** (iOS's engine),
|
|
147
|
+
which is the main iOS unknown. It does NOT exercise `WKURLSchemeHandler` (the custom-scheme POST-body
|
|
148
|
+
question in the checklist above) — that still needs a device/UI test on a real Mac + iPhone.
|
|
149
|
+
|
|
150
|
+
### To run on your actual iPhone
|
|
151
|
+
Open the generated project on a Mac (`brew install xcodegen && cd ios && xcodegen generate && open
|
|
152
|
+
ApexShell.xcodeproj`), copy the four `Generated/` items into the app's Resources, set a free Apple ID
|
|
153
|
+
signing team, pick your iPhone, and Run. A free personal team gives a 7-day provisioning profile.
|