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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/templates/default/AGENTS.md +6 -6
  3. package/templates/default/layouts/default.alpine +48 -19
  4. package/templates/features/data/db/index.ts +25 -24
  5. package/templates/mobile/android/README.md +53 -0
  6. package/templates/mobile/android/app/build.gradle.kts +30 -0
  7. package/templates/mobile/android/app/src/main/AndroidManifest.xml +24 -0
  8. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexBridge.kt +59 -0
  9. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexDbStore.kt +31 -0
  10. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexEngine.kt +66 -0
  11. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexInterceptor.kt +83 -0
  12. package/templates/mobile/android/app/src/main/java/site/apexjs/shell/MainActivity.kt +96 -0
  13. package/templates/mobile/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  14. package/templates/mobile/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  15. package/templates/mobile/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  16. package/templates/mobile/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  17. package/templates/mobile/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  18. package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  19. package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
  20. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  21. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  22. package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  23. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  24. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  25. package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  26. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  27. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  28. package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  29. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  30. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  31. package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  32. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  33. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  34. package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  35. package/templates/mobile/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  36. package/templates/mobile/android/app/src/main/res/values/strings.xml +4 -0
  37. package/templates/mobile/android/app/src/main/res/values/themes.xml +11 -0
  38. package/templates/mobile/android/build.gradle.kts +4 -0
  39. package/templates/mobile/android/gradle.properties +3 -0
  40. package/templates/mobile/android/play_store_512.png +0 -0
  41. package/templates/mobile/android/settings.gradle.kts +4 -0
  42. package/templates/mobile/apex-bridge.js +22 -0
  43. package/templates/mobile/gen-mobile-assets.mjs +101 -0
  44. package/templates/mobile/ios/ApexApp.swift +106 -0
  45. package/templates/mobile/ios/ApexDbStore.swift +74 -0
  46. package/templates/mobile/ios/ApexEngine.swift +253 -0
  47. package/templates/mobile/ios/ApexSchemeHandler.swift +349 -0
  48. package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  49. package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
  50. package/templates/mobile/ios/Assets.xcassets/LaunchBackground.colorset/Contents.json +20 -0
  51. package/templates/mobile/ios/Info.plist +60 -0
  52. package/templates/mobile/ios/README.md +153 -0
  53. package/templates/mobile/ios/Tests/ApexEngineTests.swift +74 -0
  54. package/templates/mobile/ios/project.yml +75 -0
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <background android:drawable="@color/ic_launcher_background" />
4
+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
5
+ </adaptive-icon>
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <background android:drawable="@color/ic_launcher_background" />
4
+ <foreground android:drawable="@mipmap/ic_launcher_foreground" />
5
+ </adaptive-icon>
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <color name="ic_launcher_background">#0b1120</color>
4
+ </resources>
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <resources>
3
+ <string name="app_name">Apex App</string>
4
+ </resources>
@@ -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>
@@ -0,0 +1,4 @@
1
+ plugins {
2
+ id("com.android.application") version "8.5.0" apply false
3
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
4
+ }
@@ -0,0 +1,3 @@
1
+ org.gradle.jvmargs=-Xmx2048m
2
+ android.useAndroidX=true
3
+ kotlin.code.style=official
@@ -0,0 +1,4 @@
1
+ pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
2
+ dependencyResolutionManagement { repositories { google(); mavenCentral() } }
3
+ rootProject.name = "ApexShell"
4
+ include(":app")
@@ -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
+ }