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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<resources>
|
|
3
|
+
<!-- Native cold-start splash: the generated launcher icon on the generated background.
|
|
4
|
+
postSplashScreenTheme hands off to the app; then pages/splash.alpine animates. -->
|
|
5
|
+
<style name="Theme.Apex.Splash" parent="Theme.SplashScreen">
|
|
6
|
+
<item name="windowSplashScreenBackground">@color/ic_launcher_background</item>
|
|
7
|
+
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
|
8
|
+
<item name="postSplashScreenTheme">@style/Theme.Apex</item>
|
|
9
|
+
</style>
|
|
10
|
+
<style name="Theme.Apex" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
|
11
|
+
</resources>
|
|
Binary file
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// apex-bridge.js — the JS side of the native shell.
|
|
2
|
+
//
|
|
3
|
+
// Load order in the embedded engine (Hermes / JSC / QuickJS):
|
|
4
|
+
// 1) server.mjs (from `apex build --mobile` — sets globalThis.APEX = { run })
|
|
5
|
+
// 2) this file
|
|
6
|
+
//
|
|
7
|
+
// The native interceptor (iOS WKURLSchemeHandler / Android shouldInterceptRequest) calls
|
|
8
|
+
// __apexHandle(jsonRequest) → Promise<jsonResponse>. String-in/string-out keeps the FFI
|
|
9
|
+
// trivial across every engine.
|
|
10
|
+
//
|
|
11
|
+
// jsonRequest = { "url": "...", "method": "GET", "headers": {..}, "body": "..."|null }
|
|
12
|
+
// jsonResponse = { "status": 200, "headers": {"content-type": "text/html", ...}, "body": "..." }
|
|
13
|
+
|
|
14
|
+
globalThis.__apexHandle = async function (jsonRequest) {
|
|
15
|
+
const { url, method, headers, body } = JSON.parse(jsonRequest)
|
|
16
|
+
const req = new Request(url, { method: method || 'GET', headers: headers || {}, body })
|
|
17
|
+
const res = await globalThis.APEX.run(req) // { status, headers, body }
|
|
18
|
+
return JSON.stringify(res)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Optional: a synchronous readiness flag the native side can poll before the first request.
|
|
22
|
+
globalThis.__apexReady = typeof globalThis.APEX?.run === 'function'
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// gen-mobile-assets.mjs — reference for `apex build --mobile` asset generation.
|
|
2
|
+
// One source icon (+ optional splash) → all native launcher/adaptive/appicon assets.
|
|
3
|
+
// node gen-mobile-assets.mjs <icon.png> [--out ./native-shell/android] [--splash splash.png] [--bg '#0b0b0b']
|
|
4
|
+
//
|
|
5
|
+
// STATIC native assets only (icons + cold-start splash). The ANIMATED splash is a
|
|
6
|
+
// `pages/splash.alpine` route the shell renders first — no generation, it's just a page.
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { pathToFileURL } from 'node:url'
|
|
11
|
+
|
|
12
|
+
// Resolve `sharp` from wherever the command RUNS (the app root), not this script's dir — ESM
|
|
13
|
+
// `import 'sharp'` (and NODE_PATH) resolve relative to the file, so a `sharp` installed in the
|
|
14
|
+
// app wouldn't be found. `npm i -D sharp` in your app, then run the assembler from the app root.
|
|
15
|
+
let sharp
|
|
16
|
+
try {
|
|
17
|
+
sharp = (await import('sharp')).default
|
|
18
|
+
} catch {
|
|
19
|
+
try {
|
|
20
|
+
sharp = createRequire(pathToFileURL(join(process.cwd(), 'x.js')).href)('sharp')
|
|
21
|
+
} catch {
|
|
22
|
+
console.error(' ✗ `sharp` not found — run `npm i -D sharp` in your app, then re-run.')
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2)
|
|
28
|
+
const srcIcon = args[0]
|
|
29
|
+
const outIdx = args.indexOf('--out')
|
|
30
|
+
const androidDir = outIdx >= 0 ? args[outIdx + 1] : './native-shell/android'
|
|
31
|
+
const out = `${androidDir}/app/src/main/res`
|
|
32
|
+
const splash = args.includes('--splash') ? args[args.indexOf('--splash') + 1] : null
|
|
33
|
+
const bg = args.includes('--bg') ? args[args.indexOf('--bg') + 1] : '#0b0b0b'
|
|
34
|
+
if (!srcIcon) throw new Error('usage: gen-mobile-assets.mjs <icon.png> [--out dir] [--splash s.png] [--bg #hex]')
|
|
35
|
+
|
|
36
|
+
const write = (p, buf) => {
|
|
37
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
38
|
+
writeFileSync(p, buf)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Android launcher icons (legacy square, all densities) ─────────────────────────
|
|
42
|
+
const DENSITIES = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 192 }
|
|
43
|
+
for (const [d, px] of Object.entries(DENSITIES)) {
|
|
44
|
+
const png = await sharp(srcIcon).resize(px, px, { fit: 'cover' }).png().toBuffer()
|
|
45
|
+
write(join(out, `mipmap-${d}`, 'ic_launcher.png'), png)
|
|
46
|
+
write(join(out, `mipmap-${d}`, 'ic_launcher_round.png'), png)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Adaptive icon (API 26+): foreground layer at 108dp per density + XML + bg color ──
|
|
50
|
+
// Foreground art occupies the inner ~66dp safe zone; we pad the source into 108dp.
|
|
51
|
+
const FG = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 }
|
|
52
|
+
for (const [d, px] of Object.entries(FG)) {
|
|
53
|
+
const inner = Math.round(px * 0.62)
|
|
54
|
+
const fg = await sharp(srcIcon)
|
|
55
|
+
.resize(inner, inner, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
56
|
+
.extend({
|
|
57
|
+
top: Math.round((px - inner) / 2),
|
|
58
|
+
bottom: Math.round((px - inner) / 2),
|
|
59
|
+
left: Math.round((px - inner) / 2),
|
|
60
|
+
right: Math.round((px - inner) / 2),
|
|
61
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
62
|
+
})
|
|
63
|
+
.png()
|
|
64
|
+
.toBuffer()
|
|
65
|
+
write(join(out, `mipmap-${d}`, 'ic_launcher_foreground.png'), fg)
|
|
66
|
+
}
|
|
67
|
+
write(
|
|
68
|
+
join(out, 'values', 'ic_launcher_background.xml'),
|
|
69
|
+
`<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${bg}</color>\n</resources>\n`,
|
|
70
|
+
)
|
|
71
|
+
const adaptiveXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
72
|
+
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
73
|
+
<background android:drawable="@color/ic_launcher_background" />
|
|
74
|
+
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
75
|
+
</adaptive-icon>\n`
|
|
76
|
+
write(join(out, 'mipmap-anydpi-v26', 'ic_launcher.xml'), adaptiveXml)
|
|
77
|
+
write(join(out, 'mipmap-anydpi-v26', 'ic_launcher_round.xml'), adaptiveXml)
|
|
78
|
+
|
|
79
|
+
// ── Play Store icon (512) ─────────────────────────────────────────────────────────
|
|
80
|
+
write('./native-shell/android/play_store_512.png', await sharp(srcIcon).resize(512, 512).png().toBuffer())
|
|
81
|
+
|
|
82
|
+
// ── Static cold-start splash (Android 12+ theme uses a centered icon on bg) ─────────
|
|
83
|
+
if (splash) {
|
|
84
|
+
for (const [d, px] of Object.entries({ mdpi: 288, hdpi: 432, xhdpi: 576, xxhdpi: 864, xxxhdpi: 1152 })) {
|
|
85
|
+
write(join(out, `drawable-${d}`, 'splash.png'),
|
|
86
|
+
await sharp(splash).resize(px, px, { fit: 'contain', background: bg }).png().toBuffer())
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── iOS AppIcon.appiconset (single-size 1024, Xcode 14+ format) ─────────────────────
|
|
91
|
+
const iosDir = './native-shell/ios/Assets.xcassets/AppIcon.appiconset'
|
|
92
|
+
write(join(iosDir, 'icon-1024.png'), await sharp(srcIcon).resize(1024, 1024).flatten({ background: bg }).png().toBuffer())
|
|
93
|
+
write(join(iosDir, 'Contents.json'), JSON.stringify({
|
|
94
|
+
images: [{ filename: 'icon-1024.png', idiom: 'universal', platform: 'ios', size: '1024x1024' }],
|
|
95
|
+
info: { author: 'apex', version: 1 },
|
|
96
|
+
}, null, 2))
|
|
97
|
+
|
|
98
|
+
console.log('✓ Android: mipmap-* (5 densities) launcher + round + adaptive foreground + ic_launcher.xml + bg color')
|
|
99
|
+
console.log('✓ Android: play_store_512.png' + (splash ? ' + drawable-*/splash.png' : ''))
|
|
100
|
+
console.log('✓ iOS: AppIcon.appiconset (1024 + Contents.json)')
|
|
101
|
+
console.log(' (Animated splash = pages/splash.alpine — rendered by the shell first, no generation.)')
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ApexApp.swift — SwiftUI entry point for the Apex iOS shell.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors android/MainActivity.kt: boot the on-device JS engine, create a WKWebView whose
|
|
4
|
+
// `apex://` scheme is served by ApexSchemeHandler, and load `apex://localhost/splash`.
|
|
5
|
+
//
|
|
6
|
+
// ── Where this diverges from Android ──────────────────────────────────────────────────────
|
|
7
|
+
// • Android boots the engine asynchronously (androidx.javascriptengine sandbox handshake) inside
|
|
8
|
+
// lifecycleScope, then wires the WebView. On iOS, JSContext is synchronous, so we build the
|
|
9
|
+
// engine up front (in ApexRuntime) — no async gate before the first load.
|
|
10
|
+
// • Android's fetch()-with-body needs a JS bridge + document-start patch; iOS does NOT (the
|
|
11
|
+
// scheme handler reads the body directly), so there is nothing extra to install on the WebView.
|
|
12
|
+
// • Splash: iOS shows a native launch screen (configured via Info.plist `UILaunchScreen`, a solid
|
|
13
|
+
// brand-colour screen — see ios/README.md) before JS runs, then the WebView loads the
|
|
14
|
+
// `pages/splash.alpine` route (SSR-rendered instantly), which navigates to `/` when ready —
|
|
15
|
+
// the same two-stage splash handoff as Android.
|
|
16
|
+
|
|
17
|
+
import SwiftUI
|
|
18
|
+
import WebKit
|
|
19
|
+
|
|
20
|
+
@main
|
|
21
|
+
struct ApexShellApp: App {
|
|
22
|
+
/// The engine + scheme handler live for the whole app lifetime.
|
|
23
|
+
@StateObject private var runtime = ApexRuntime()
|
|
24
|
+
|
|
25
|
+
var body: some Scene {
|
|
26
|
+
WindowGroup {
|
|
27
|
+
ApexWebView(runtime: runtime)
|
|
28
|
+
.ignoresSafeArea() // full-bleed; the .alpine splash handles safe-area insets itself
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Owns the singleton engine and scheme handler. Building the engine can fail only if the bundle
|
|
34
|
+
/// resources are missing (server.mjs / apex-bridge.js not copied in) — we surface that as an error
|
|
35
|
+
/// page rather than crashing, so a Mac tester sees a clear message.
|
|
36
|
+
final class ApexRuntime: ObservableObject {
|
|
37
|
+
let engine: ApexEngine?
|
|
38
|
+
let bootError: String?
|
|
39
|
+
|
|
40
|
+
init() {
|
|
41
|
+
do {
|
|
42
|
+
// Restore the persisted DB snapshot (if any) BEFORE the bundle boots (see ApexDbStore).
|
|
43
|
+
let engine = try ApexEngine(snapshot: ApexDbStore.read())
|
|
44
|
+
self.engine = engine
|
|
45
|
+
self.bootError = nil
|
|
46
|
+
} catch {
|
|
47
|
+
self.engine = nil
|
|
48
|
+
self.bootError = "\(error)"
|
|
49
|
+
print("[ApexJS] engine boot failed: \(error)")
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Bridges a WKWebView into SwiftUI. Registers the `apex://` scheme handler on the configuration
|
|
55
|
+
/// (this MUST happen before the WebView is created — you can't add a scheme handler afterwards)
|
|
56
|
+
/// and loads the animated splash route.
|
|
57
|
+
struct ApexWebView: UIViewRepresentable {
|
|
58
|
+
let runtime: ApexRuntime
|
|
59
|
+
|
|
60
|
+
func makeUIView(context: Context) -> WKWebView {
|
|
61
|
+
let configuration = WKWebViewConfiguration()
|
|
62
|
+
|
|
63
|
+
// Enable JavaScript (needed for client hydration in the WebView).
|
|
64
|
+
if #available(iOS 14.0, *) {
|
|
65
|
+
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
|
|
66
|
+
} else {
|
|
67
|
+
configuration.preferences.javaScriptEnabled = true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Register the custom scheme → on-device engine. If the engine failed to boot we still create
|
|
71
|
+
// the WebView but load an error page instead.
|
|
72
|
+
if let engine = runtime.engine {
|
|
73
|
+
configuration.setURLSchemeHandler(
|
|
74
|
+
ApexSchemeHandler(engine: engine),
|
|
75
|
+
forURLScheme: ApexSchemeHandler.scheme
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let webView = WKWebView(frame: .zero, configuration: configuration)
|
|
80
|
+
webView.allowsBackForwardNavigationGestures = true
|
|
81
|
+
// Match the native launch screen background so there's no flash before the .alpine splash.
|
|
82
|
+
webView.isOpaque = false
|
|
83
|
+
webView.backgroundColor = UIColor(red: 0x0b/255, green: 0x11/255, blue: 0x20/255, alpha: 1) // #0b1120
|
|
84
|
+
webView.scrollView.backgroundColor = webView.backgroundColor
|
|
85
|
+
|
|
86
|
+
if runtime.engine != nil {
|
|
87
|
+
// Load the animated splash route first; it navigates to '/' when ready (same as Android).
|
|
88
|
+
// Any host works — every request is intercepted; the host ("localhost") is ignored.
|
|
89
|
+
if let url = URL(string: "\(ApexSchemeHandler.scheme)://localhost/splash") {
|
|
90
|
+
webView.load(URLRequest(url: url))
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
let message = runtime.bootError ?? "unknown error"
|
|
94
|
+
webView.loadHTMLString(
|
|
95
|
+
"<h1>Apex engine failed to start</h1><pre>\(message)</pre>"
|
|
96
|
+
+ "<p>Check that server.mjs and apex-bridge.js are in the app bundle Resources.</p>",
|
|
97
|
+
baseURL: nil
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return webView
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func updateUIView(_ webView: WKWebView, context: Context) {
|
|
104
|
+
// Stateless — the engine drives everything through the scheme handler. Nothing to reconcile.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// ApexDbStore.swift — persistence for the on-device database (Approach A: native file bridge).
|
|
2
|
+
//
|
|
3
|
+
// 1:1 port of android/ApexDbStore.kt. The on-device SQLite lives inside the JS engine as
|
|
4
|
+
// in-memory bytes; to survive a cold start we persist those bytes (base64 of `db.export()`) to a
|
|
5
|
+
// private app file. ApexEngine reads it at boot and injects `__APEX_DB_SNAPSHOT__`; the scheme
|
|
6
|
+
// handler writes it back after a mutating request.
|
|
7
|
+
//
|
|
8
|
+
// ── Where this diverges from Android ──────────────────────────────────────────────────────
|
|
9
|
+
// • Android writes to `context.filesDir` (app-private) and does an atomic `renameTo`.
|
|
10
|
+
// • iOS writes to the app's Application Support directory (app-private, backed up, not purgeable
|
|
11
|
+
// like Caches) and uses `FileManager.replaceItemAt` for the atomic swap — the documented
|
|
12
|
+
// Foundation primitive for "write tmp, then atomically replace destination".
|
|
13
|
+
|
|
14
|
+
import Foundation
|
|
15
|
+
|
|
16
|
+
enum ApexDbStore {
|
|
17
|
+
private static let fileName = "apex-db.b64"
|
|
18
|
+
|
|
19
|
+
/// Application Support dir (created on first use). App-private, included in backups, and — unlike
|
|
20
|
+
/// Caches — not eligible for eviction under storage pressure, so the DB snapshot is durable.
|
|
21
|
+
private static func directory() throws -> URL {
|
|
22
|
+
let fm = FileManager.default
|
|
23
|
+
let base = try fm.url(
|
|
24
|
+
for: .applicationSupportDirectory,
|
|
25
|
+
in: .userDomainMask,
|
|
26
|
+
appropriateFor: nil,
|
|
27
|
+
create: true
|
|
28
|
+
)
|
|
29
|
+
if !fm.fileExists(atPath: base.path) {
|
|
30
|
+
try fm.createDirectory(at: base, withIntermediateDirectories: true)
|
|
31
|
+
}
|
|
32
|
+
return base
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private static func fileURL() throws -> URL {
|
|
36
|
+
try directory().appendingPathComponent(fileName)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The saved snapshot (base64), or nil if none yet / empty. Mirrors `read()` on Android.
|
|
40
|
+
static func read() -> String? {
|
|
41
|
+
guard
|
|
42
|
+
let url = try? fileURL(),
|
|
43
|
+
FileManager.default.fileExists(atPath: url.path),
|
|
44
|
+
let text = try? String(contentsOf: url, encoding: .utf8),
|
|
45
|
+
!text.isEmpty
|
|
46
|
+
else {
|
|
47
|
+
return nil
|
|
48
|
+
}
|
|
49
|
+
return text
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Persist the snapshot (base64). A blank value (app has no DB) is ignored. Atomic: write a tmp
|
|
53
|
+
/// file then swap it into place so a crash mid-write can't corrupt the DB file. Mirrors
|
|
54
|
+
/// `write()` on Android.
|
|
55
|
+
static func write(_ base64: String) {
|
|
56
|
+
guard !base64.isEmpty else { return }
|
|
57
|
+
do {
|
|
58
|
+
let fm = FileManager.default
|
|
59
|
+
let dest = try fileURL()
|
|
60
|
+
let tmp = try directory().appendingPathComponent("\(fileName).tmp")
|
|
61
|
+
try base64.data(using: .utf8)?.write(to: tmp, options: .atomic)
|
|
62
|
+
|
|
63
|
+
if fm.fileExists(atPath: dest.path) {
|
|
64
|
+
// Atomic replace of an existing file.
|
|
65
|
+
_ = try fm.replaceItemAt(dest, withItemAt: tmp)
|
|
66
|
+
} else {
|
|
67
|
+
// First write — nothing to replace, just move it into place.
|
|
68
|
+
try fm.moveItem(at: tmp, to: dest)
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
print("[ApexJS] ApexDbStore.write failed: \(error)")
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|