create-apexjs 0.6.7 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/default/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/package.json
CHANGED
|
@@ -4,29 +4,58 @@
|
|
|
4
4
|
<template>
|
|
5
5
|
<div class="flex min-h-svh flex-col bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark">
|
|
6
6
|
<header class="border-b border-outline dark:border-outline-dark">
|
|
7
|
-
<
|
|
8
|
-
class="
|
|
9
|
-
x-data="{ dark: false }"
|
|
7
|
+
<div
|
|
8
|
+
class="relative"
|
|
9
|
+
x-data="{ dark: false, open: false }"
|
|
10
10
|
x-init="dark = localStorage.getItem('theme') ? localStorage.getItem('theme') === 'dark' : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); document.documentElement.classList.toggle('dark', dark)"
|
|
11
|
+
@resize.window="open = false"
|
|
11
12
|
client:load
|
|
12
13
|
>
|
|
13
|
-
<
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
<nav class="mx-auto flex max-w-5xl items-center gap-2 px-6 py-4">
|
|
15
|
+
<a href="/" class="mr-auto flex items-center gap-2 font-title text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
|
16
|
+
<img src="/favicon.svg" alt="" width="24" height="24" class="size-6" />
|
|
17
|
+
{{name}}
|
|
18
|
+
</a>
|
|
19
|
+
<!-- Inline links on ≥sm; collapse into the hamburger menu on mobile. -->
|
|
20
|
+
<div class="hidden items-center gap-1 sm:flex">
|
|
21
|
+
<a href="/" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Home</a>
|
|
22
|
+
<a href="/blog" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Blog</a>
|
|
23
|
+
<a href="/about" class="rounded-radius px-3 py-1.5 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">About</a>
|
|
24
|
+
</div>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
aria-label="Toggle dark mode"
|
|
28
|
+
class="rounded-radius border border-outline p-2 text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt"
|
|
29
|
+
@click="dark = !dark; document.documentElement.classList.toggle('dark', dark); localStorage.setItem('theme', dark ? 'dark' : 'light')"
|
|
30
|
+
>
|
|
31
|
+
<svg x-show="!dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
|
32
|
+
<svg x-cloak x-show="dark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
33
|
+
</button>
|
|
34
|
+
<!-- Hamburger: mobile only. -->
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
aria-label="Menu"
|
|
38
|
+
:aria-expanded="open"
|
|
39
|
+
class="rounded-radius border border-outline p-2 text-on-surface hover:bg-surface-alt sm:hidden dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt"
|
|
40
|
+
@click="open = !open"
|
|
41
|
+
>
|
|
42
|
+
<svg x-show="!open" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
|
43
|
+
<svg x-cloak x-show="open" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
|
44
|
+
</button>
|
|
45
|
+
</nav>
|
|
46
|
+
<!-- Floating mobile menu — overlays the page (doesn't push content down). -->
|
|
47
|
+
<div x-cloak x-show="open" @click="open = false" class="fixed inset-0 z-40 sm:hidden" aria-hidden="true"></div>
|
|
48
|
+
<div
|
|
49
|
+
x-cloak x-show="open"
|
|
50
|
+
x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 -translate-y-1" x-transition:enter-end="opacity-100 translate-y-0"
|
|
51
|
+
x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
|
52
|
+
class="absolute right-4 top-full z-50 mt-1 w-52 rounded-radius border border-outline bg-surface p-1.5 shadow-lg sm:hidden dark:border-outline-dark dark:bg-surface-dark"
|
|
25
53
|
>
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
54
|
+
<a href="/" @click="open = false" class="block rounded-radius px-3 py-2 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Home</a>
|
|
55
|
+
<a href="/blog" @click="open = false" class="block rounded-radius px-3 py-2 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">Blog</a>
|
|
56
|
+
<a href="/about" @click="open = false" class="block rounded-radius px-3 py-2 text-sm font-medium hover:bg-surface-alt dark:hover:bg-surface-dark-alt">About</a>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
30
59
|
</header>
|
|
31
60
|
|
|
32
61
|
<main class="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
|
@@ -1,28 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lazyDb } from '@apex-stack/data'
|
|
2
2
|
import { Message } from '../models/Message.js'
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Open the database lazily — no top-level `await`, so this module also bundles into the
|
|
5
|
+
// classic-script `apex build --mobile` output (a bare on-device engine can't do top-level
|
|
6
|
+
// await). On-device, `createDb`'s libSQL driver transparently becomes an in-memory sql.js
|
|
7
|
+
// database; a hosted Postgres is used when DATABASE_URL is set (server/dev only). The real
|
|
8
|
+
// connection opens + migrates + seeds on first use.
|
|
9
9
|
const url = process.env.DATABASE_URL
|
|
10
|
-
export const handle =
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
export const handle = lazyDb(
|
|
11
|
+
() => (url ? { driver: 'postgres', url } : { driver: 'libsql', url: ':memory:' }),
|
|
12
|
+
{
|
|
13
|
+
init: async (h) => {
|
|
14
|
+
// Create the schema — dialect-aware (SQLite vs Postgres) and idempotent, from the model.
|
|
15
|
+
await h.exec(Message.migrationSql(h.dialect))
|
|
13
16
|
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
}
|
|
17
|
+
// Seed a couple of rows if the table is empty.
|
|
18
|
+
const now = h.dialect === 'postgres' ? 'now()' : "datetime('now')"
|
|
19
|
+
const seeded = await h.query('SELECT COUNT(*) AS n FROM messages')
|
|
20
|
+
if (Number(seeded[0]?.n ?? 0) === 0) {
|
|
21
|
+
await h.exec(
|
|
22
|
+
`INSERT INTO messages (author, body, created_at, updated_at) VALUES
|
|
23
|
+
('Ada Lovelace', 'Apex feels like HTML with superpowers.', ${now}, ${now}),
|
|
24
|
+
('Alan Turing', 'One route — and my AI assistant can call it too.', ${now}, ${now})`,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Apex Android shell
|
|
2
|
+
|
|
3
|
+
A native Android app that runs your Apex backend **on the device** (offline, no server) and
|
|
4
|
+
renders it in a WebView. Produced by `assemble-android.mjs` + `apex build --mobile`.
|
|
5
|
+
|
|
6
|
+
## What's here
|
|
7
|
+
```
|
|
8
|
+
android/
|
|
9
|
+
├─ settings.gradle.kts / build.gradle.kts / gradle.properties
|
|
10
|
+
├─ app/build.gradle.kts # androidx.javascriptengine + splashscreen + coroutines
|
|
11
|
+
└─ app/src/main/
|
|
12
|
+
├─ AndroidManifest.xml # launcher activity, no INTERNET permission (offline)
|
|
13
|
+
├─ java/site/apexjs/shell/
|
|
14
|
+
│ ├─ MainActivity.kt # WebView + native splash → loads /splash
|
|
15
|
+
│ ├─ ApexEngine.kt # loads server.mjs + apex-bridge.js, runs __apexHandle
|
|
16
|
+
│ └─ ApexInterceptor.kt # every request → engine (assets served from APK)
|
|
17
|
+
├─ assets/ # ← server.mjs, apex-bridge.js, client assets (assembler fills)
|
|
18
|
+
└─ res/ # ← generated launcher icon, adaptive icon, splash color
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Build it
|
|
22
|
+
From your **Apex app root**:
|
|
23
|
+
```bash
|
|
24
|
+
node path/to/native-shell/assemble-android.mjs --icon public/icon.png --splash public/icon.png --bg '#0b1120'
|
|
25
|
+
```
|
|
26
|
+
That runs `apex build --mobile`, copies the bundle + client assets into `app/src/main/assets/`,
|
|
27
|
+
and generates the launcher/adaptive icons + native splash from your source image.
|
|
28
|
+
|
|
29
|
+
Then:
|
|
30
|
+
```bash
|
|
31
|
+
cd native-shell/android
|
|
32
|
+
./gradlew assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
|
|
33
|
+
# or open the folder in Android Studio and Run on the emulator
|
|
34
|
+
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The installed app has your **launcher icon**, shows the **native splash** (icon on your bg),
|
|
38
|
+
then your **`pages/splash.alpine`** animated intro, then the app — all rendered by the Apex
|
|
39
|
+
server running inside the app on `androidx.javascriptengine`. Turn Wi-Fi off: it still works.
|
|
40
|
+
|
|
41
|
+
## Engine note
|
|
42
|
+
`ApexEngine` uses Google's **androidx.javascriptengine** (out-of-process JS sandbox, no JNI).
|
|
43
|
+
`__apexHandle` returns a `Promise<string>`; that resolves via the `PROMISE_RETURN` feature
|
|
44
|
+
(beta01+). Swap in an embedded QuickJS (JNI) or Hermes (in an RN shell) with the same shape.
|
|
45
|
+
|
|
46
|
+
## On-device drivers
|
|
47
|
+
`apex build --mobile` reports routes needing `crypto.subtle` (sessions) or `@libsql/client/web`
|
|
48
|
+
(DB). `androidx.javascriptengine` provides `crypto`; for DB, alias `@libsql/client` →
|
|
49
|
+
`@libsql/client/web` (WASM) before bundling, or bridge to Android's native SQLite.
|
|
50
|
+
|
|
51
|
+
## Verify (before a device)
|
|
52
|
+
`node ../verify-bundle.mjs ../../<app>/dist/mobile/server.mjs` renders the bundle under QuickJS
|
|
53
|
+
on your machine — proves the on-device path without building the APK.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id("com.android.application")
|
|
3
|
+
id("org.jetbrains.kotlin.android")
|
|
4
|
+
}
|
|
5
|
+
android {
|
|
6
|
+
namespace = "site.apexjs.shell"
|
|
7
|
+
compileSdk = 34
|
|
8
|
+
defaultConfig {
|
|
9
|
+
applicationId = "site.apexjs.shell"
|
|
10
|
+
minSdk = 26 // adaptive icons + androidx.javascriptengine
|
|
11
|
+
targetSdk = 34
|
|
12
|
+
versionCode = 1
|
|
13
|
+
versionName = "1.0"
|
|
14
|
+
}
|
|
15
|
+
compileOptions {
|
|
16
|
+
sourceCompatibility = JavaVersion.VERSION_17
|
|
17
|
+
targetCompatibility = JavaVersion.VERSION_17
|
|
18
|
+
}
|
|
19
|
+
kotlinOptions { jvmTarget = "17" }
|
|
20
|
+
}
|
|
21
|
+
dependencies {
|
|
22
|
+
implementation("androidx.core:core-ktx:1.13.1")
|
|
23
|
+
implementation("androidx.core:core-splashscreen:1.0.1")
|
|
24
|
+
implementation("androidx.appcompat:appcompat:1.7.0")
|
|
25
|
+
implementation("androidx.activity:activity-ktx:1.9.2")
|
|
26
|
+
implementation("androidx.javascriptengine:javascriptengine:1.0.0-beta01")
|
|
27
|
+
implementation("androidx.webkit:webkit:1.11.0") // WebViewCompat.addDocumentStartJavaScript
|
|
28
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
|
29
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.1")
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
3
|
+
|
|
4
|
+
<!-- No INTERNET permission needed: the backend runs on-device, offline. Add it only if
|
|
5
|
+
your app makes outbound network calls. -->
|
|
6
|
+
|
|
7
|
+
<application
|
|
8
|
+
android:allowBackup="true"
|
|
9
|
+
android:icon="@mipmap/ic_launcher"
|
|
10
|
+
android:roundIcon="@mipmap/ic_launcher_round"
|
|
11
|
+
android:label="@string/app_name"
|
|
12
|
+
android:theme="@style/Theme.Apex.Splash">
|
|
13
|
+
|
|
14
|
+
<activity
|
|
15
|
+
android:name=".MainActivity"
|
|
16
|
+
android:exported="true"
|
|
17
|
+
android:configChanges="orientation|screenSize|keyboardHidden">
|
|
18
|
+
<intent-filter>
|
|
19
|
+
<action android:name="android.intent.action.MAIN" />
|
|
20
|
+
<category android:name="android.intent.category.LAUNCHER" />
|
|
21
|
+
</intent-filter>
|
|
22
|
+
</activity>
|
|
23
|
+
</application>
|
|
24
|
+
</manifest>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package site.apexjs.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.webkit.CookieManager
|
|
5
|
+
import android.webkit.JavascriptInterface
|
|
6
|
+
import kotlinx.coroutines.runBlocking
|
|
7
|
+
import org.json.JSONObject
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* JS -> native bridge for DYNAMIC requests (fetch/XHR) from the page.
|
|
11
|
+
*
|
|
12
|
+
* Android's WebViewClient.shouldInterceptRequest cannot read a request BODY, so POST/PUT/PATCH
|
|
13
|
+
* from the app's fetch() calls (e.g. POST /api/messages, /api/login) are routed here instead —
|
|
14
|
+
* see the document-start fetch patch installed in MainActivity — where the body IS available.
|
|
15
|
+
*
|
|
16
|
+
* It also owns the HttpOnly session cookie via CookieManager: the page can't read or set an
|
|
17
|
+
* HttpOnly cookie itself, so the bridge injects the stored Cookie on the way in and persists
|
|
18
|
+
* any Set-Cookie on the way out. Navigations (GET) go through ApexInterceptor, which does the
|
|
19
|
+
* same, so a login made via fetch() is visible to the next full page load.
|
|
20
|
+
*
|
|
21
|
+
* @JavascriptInterface methods run on a WebView binder thread (not the UI thread), so blocking
|
|
22
|
+
* on the engine coroutine here is fine. NOTE: avoid a slash-star sequence in KDoc.
|
|
23
|
+
*/
|
|
24
|
+
class ApexBridge(private val engine: ApexEngine, private val context: Context) {
|
|
25
|
+
@JavascriptInterface
|
|
26
|
+
fun handle(requestJson: String): String {
|
|
27
|
+
val req = JSONObject(requestJson)
|
|
28
|
+
val url = req.optString("url")
|
|
29
|
+
val method = req.optString("method", "GET").uppercase()
|
|
30
|
+
val headers = req.optJSONObject("headers") ?: JSONObject()
|
|
31
|
+
|
|
32
|
+
// Attach the stored session cookie (the page can't read HttpOnly cookies).
|
|
33
|
+
val cookie = CookieManager.getInstance().getCookie(url)
|
|
34
|
+
if (!cookie.isNullOrBlank() && !headers.has("cookie")) headers.put("cookie", cookie)
|
|
35
|
+
req.put("headers", headers)
|
|
36
|
+
|
|
37
|
+
val resStr = runBlocking { engine.handle(req.toString()) }
|
|
38
|
+
persistSetCookie(url, resStr)
|
|
39
|
+
|
|
40
|
+
// A mutating request may have changed the DB — persist a fresh snapshot so it survives a
|
|
41
|
+
// cold start. (All body-bearing writes come through this bridge; navigations are GET.)
|
|
42
|
+
if (method != "GET" && method != "HEAD" && method != "OPTIONS") {
|
|
43
|
+
runBlocking { ApexDbStore.write(context, engine.snapshot()) }
|
|
44
|
+
}
|
|
45
|
+
return resStr
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
companion object {
|
|
49
|
+
/** Store any Set-Cookie from an engine response JSON into the CookieManager. */
|
|
50
|
+
fun persistSetCookie(url: String, responseJson: String) {
|
|
51
|
+
val setCookie = JSONObject(responseJson).optJSONObject("headers")?.optString("set-cookie").orEmpty()
|
|
52
|
+
if (setCookie.isBlank()) return
|
|
53
|
+
val cm = CookieManager.getInstance()
|
|
54
|
+
// A combined header may hold several cookies separated by ", " before a `name=`.
|
|
55
|
+
setCookie.split(Regex(",(?=[^;,]+=)")).forEach { cm.setCookie(url, it.trim()) }
|
|
56
|
+
cm.flush()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package site.apexjs.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import java.io.File
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persistence for the on-device database (Approach A: native file bridge).
|
|
8
|
+
*
|
|
9
|
+
* The on-device SQLite lives in the JS engine as in-memory bytes. To survive a cold start we
|
|
10
|
+
* persist those bytes (base64 of `db.export()`) to a private app file: ApexEngine reads it at
|
|
11
|
+
* boot and injects `__APEX_DB_SNAPSHOT__`; ApexBridge writes it back after a mutating request.
|
|
12
|
+
* Simple, atomic-enough for a single-user app, and no extra dependency.
|
|
13
|
+
*/
|
|
14
|
+
object ApexDbStore {
|
|
15
|
+
private const val FILE = "apex-db.b64"
|
|
16
|
+
|
|
17
|
+
private fun file(context: Context) = File(context.filesDir, FILE)
|
|
18
|
+
|
|
19
|
+
/** The saved snapshot (base64), or null if none yet. */
|
|
20
|
+
fun read(context: Context): String? =
|
|
21
|
+
file(context).takeIf { it.exists() }?.readText()?.ifBlank { null }
|
|
22
|
+
|
|
23
|
+
/** Persist the snapshot (base64). A blank value (app has no DB) is ignored. */
|
|
24
|
+
fun write(context: Context, base64: String) {
|
|
25
|
+
if (base64.isBlank()) return
|
|
26
|
+
val f = file(context)
|
|
27
|
+
val tmp = File(f.parentFile, "$FILE.tmp")
|
|
28
|
+
tmp.writeText(base64)
|
|
29
|
+
tmp.renameTo(f) // replace atomically so a crash mid-write can't corrupt the DB file
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
package site.apexjs.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.javascriptengine.JavaScriptSandbox
|
|
5
|
+
import androidx.javascriptengine.JavaScriptIsolate
|
|
6
|
+
import kotlinx.coroutines.guava.await
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Embedded JS engine that runs the Apex on-device server bundle.
|
|
10
|
+
*
|
|
11
|
+
* Uses Google's official androidx.javascriptengine (a WebView-backed, out-of-process JS
|
|
12
|
+
* sandbox — no JNI, no bundled engine). It loads `server.mjs` (from `apex build --mobile`)
|
|
13
|
+
* and `apex-bridge.js` from assets, then evaluates `__apexHandle(requestJson)` per request.
|
|
14
|
+
*
|
|
15
|
+
* Alternative engines with the same shape: an embedded QuickJS (react-native-quick-js /
|
|
16
|
+
* a QuickJS-Android JNI wrapper) or, in a React Native shell, Hermes directly.
|
|
17
|
+
*
|
|
18
|
+
* Gradle: implementation("androidx.javascriptengine:javascriptengine:1.0.0-beta01")
|
|
19
|
+
* implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:<ver>")
|
|
20
|
+
*/
|
|
21
|
+
class ApexEngine private constructor(
|
|
22
|
+
private val sandbox: JavaScriptSandbox,
|
|
23
|
+
private val isolate: JavaScriptIsolate,
|
|
24
|
+
) {
|
|
25
|
+
companion object {
|
|
26
|
+
suspend fun create(context: Context): ApexEngine {
|
|
27
|
+
val sandbox = JavaScriptSandbox.createConnectedInstanceAsync(context).await()
|
|
28
|
+
val isolate = sandbox.createIsolate()
|
|
29
|
+
// Restore a persisted DB snapshot (if any) BEFORE the bundle boots, so the on-device
|
|
30
|
+
// database opens from it instead of empty. See ApexDbStore + ApexBridge.
|
|
31
|
+
ApexDbStore.read(context)?.let { snap ->
|
|
32
|
+
isolate.evaluateJavaScriptAsync(
|
|
33
|
+
"globalThis.__APEX_DB_SNAPSHOT__=" + org.json.JSONObject.quote(snap),
|
|
34
|
+
).await()
|
|
35
|
+
}
|
|
36
|
+
// Load the self-contained server bundle (sets globalThis.APEX) then the bridge.
|
|
37
|
+
isolate.evaluateJavaScriptAsync(context.assets.readText("server.mjs")).await()
|
|
38
|
+
isolate.evaluateJavaScriptAsync(context.assets.readText("apex-bridge.js")).await()
|
|
39
|
+
return ApexEngine(sandbox, isolate)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Current DB bytes (base64) for persistence, or "" if the app has no on-device DB. */
|
|
44
|
+
suspend fun snapshot(): String =
|
|
45
|
+
isolate.evaluateJavaScriptAsync(
|
|
46
|
+
"(typeof __APEX_DB_EXPORT__==='function')?__APEX_DB_EXPORT__():''",
|
|
47
|
+
).await()
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Handle one request. `requestJson` = {"url","method","headers","body"}.
|
|
51
|
+
* Returns {"status","headers","body"} as JSON. __apexHandle returns a Promise<string>;
|
|
52
|
+
* javascriptengine resolves it when the PROMISE_RETURN feature is supported (beta01+).
|
|
53
|
+
*/
|
|
54
|
+
suspend fun handle(requestJson: String): String {
|
|
55
|
+
val escaped = org.json.JSONObject.quote(requestJson) // safe JS string literal
|
|
56
|
+
return isolate.evaluateJavaScriptAsync("__apexHandle($escaped)").await()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun close() {
|
|
60
|
+
isolate.close()
|
|
61
|
+
sandbox.close()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private fun android.content.res.AssetManager.readText(name: String): String =
|
|
66
|
+
open(name).bufferedReader().use { it.readText() }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
package site.apexjs.shell
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.webkit.WebResourceRequest
|
|
5
|
+
import android.webkit.WebResourceResponse
|
|
6
|
+
import android.webkit.WebView
|
|
7
|
+
import android.webkit.WebViewClient
|
|
8
|
+
import kotlinx.coroutines.runBlocking
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
import java.io.ByteArrayInputStream
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Serves every WebView request from the on-device Apex engine — offline, no network.
|
|
14
|
+
* Static client assets (the built client bundle under assets, favicon) are served straight from
|
|
15
|
+
* the APK; everything else goes to `__apexHandle` (SSR pages + /api). shouldInterceptRequest runs
|
|
16
|
+
* off the UI thread, so blocking on the engine coroutine here is fine.
|
|
17
|
+
* NOTE: avoid a slash-star sequence in KDoc — Kotlin block comments nest and it won't close.
|
|
18
|
+
*/
|
|
19
|
+
class ApexInterceptor(
|
|
20
|
+
private val context: Context,
|
|
21
|
+
private val engine: ApexEngine,
|
|
22
|
+
) : WebViewClient() {
|
|
23
|
+
|
|
24
|
+
override fun shouldInterceptRequest(
|
|
25
|
+
view: WebView,
|
|
26
|
+
request: WebResourceRequest,
|
|
27
|
+
): WebResourceResponse? {
|
|
28
|
+
val path = request.url.path ?: "/"
|
|
29
|
+
|
|
30
|
+
// 1) Static client assets from the APK (client JS/CSS bundle, favicon).
|
|
31
|
+
if (path.startsWith("/assets/") || path == "/favicon.svg") {
|
|
32
|
+
return try {
|
|
33
|
+
val asset = context.assets.open(path.removePrefix("/"))
|
|
34
|
+
WebResourceResponse(mimeOf(path), "utf-8", asset)
|
|
35
|
+
} catch (e: Exception) {
|
|
36
|
+
null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2) Dynamic routes (SSR pages) → the engine. shouldInterceptRequest can't read a request
|
|
41
|
+
// body, so these are effectively GET/navigation; POST/fetch with a body goes through the
|
|
42
|
+
// ApexBridge JS interface instead. We inject the stored session cookie so a login made via
|
|
43
|
+
// fetch() is visible to the next full page load.
|
|
44
|
+
val url = request.url.toString()
|
|
45
|
+
val reqHeaders = JSONObject(request.requestHeaders as Map<*, *>)
|
|
46
|
+
val cookie = android.webkit.CookieManager.getInstance().getCookie(url)
|
|
47
|
+
if (!cookie.isNullOrBlank() && !reqHeaders.has("cookie") && !reqHeaders.has("Cookie")) {
|
|
48
|
+
reqHeaders.put("cookie", cookie)
|
|
49
|
+
}
|
|
50
|
+
val reqJson = JSONObject().apply {
|
|
51
|
+
put("url", url)
|
|
52
|
+
put("method", request.method)
|
|
53
|
+
put("headers", reqHeaders)
|
|
54
|
+
put("body", JSONObject.NULL)
|
|
55
|
+
}.toString()
|
|
56
|
+
|
|
57
|
+
val resStr = try {
|
|
58
|
+
runBlocking { engine.handle(reqJson) }
|
|
59
|
+
} catch (e: Exception) {
|
|
60
|
+
return WebResourceResponse("text/html", "utf-8", 500, "Server Error", emptyMap(),
|
|
61
|
+
ByteArrayInputStream("<h1>Apex engine error</h1><pre>${e.message}</pre>".toByteArray()))
|
|
62
|
+
}
|
|
63
|
+
ApexBridge.persistSetCookie(url, resStr)
|
|
64
|
+
val res = JSONObject(resStr)
|
|
65
|
+
|
|
66
|
+
val headers = res.optJSONObject("headers") ?: JSONObject()
|
|
67
|
+
val contentType = headers.optString("content-type", "text/html")
|
|
68
|
+
return WebResourceResponse(
|
|
69
|
+
contentType.substringBefore(";").ifBlank { "text/html" },
|
|
70
|
+
"utf-8",
|
|
71
|
+
res.optInt("status", 200),
|
|
72
|
+
"OK",
|
|
73
|
+
headers.keys().asSequence().associateWith { headers.getString(it) },
|
|
74
|
+
ByteArrayInputStream(res.optString("body", "").toByteArray(Charsets.UTF_8)),
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private fun mimeOf(path: String) = when (path.substringAfterLast('.')) {
|
|
79
|
+
"js", "mjs" -> "text/javascript"; "css" -> "text/css"; "svg" -> "image/svg+xml"
|
|
80
|
+
"json" -> "application/json"; "png" -> "image/png"; "woff2" -> "font/woff2"
|
|
81
|
+
else -> "application/octet-stream"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package site.apexjs.shell
|
|
2
|
+
|
|
3
|
+
import android.os.Bundle
|
|
4
|
+
import android.webkit.CookieManager
|
|
5
|
+
import android.webkit.WebView
|
|
6
|
+
import androidx.activity.ComponentActivity
|
|
7
|
+
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|
8
|
+
import androidx.webkit.WebViewCompat
|
|
9
|
+
import androidx.webkit.WebViewFeature
|
|
10
|
+
import kotlinx.coroutines.launch
|
|
11
|
+
import androidx.lifecycle.lifecycleScope
|
|
12
|
+
|
|
13
|
+
// Installed at document-start (before any page script) so the app's fetch() calls — which
|
|
14
|
+
// carry a request BODY the native interceptor can't see — route through the ApexBridge JS
|
|
15
|
+
// interface to the on-device engine. Same-origin only; anything else falls back to real fetch.
|
|
16
|
+
private const val FETCH_PATCH = """
|
|
17
|
+
(function(){
|
|
18
|
+
if (window.__apexFetchPatched) return; window.__apexFetchPatched = true;
|
|
19
|
+
var orig = window.fetch ? window.fetch.bind(window) : null;
|
|
20
|
+
window.fetch = function(input, init){
|
|
21
|
+
return new Promise(function(resolve, reject){
|
|
22
|
+
try {
|
|
23
|
+
init = init || {};
|
|
24
|
+
var url = typeof input === 'string' ? input : (input && input.url);
|
|
25
|
+
var abs = new URL(url, location.href);
|
|
26
|
+
if (abs.origin !== location.origin || !window.__ApexNative) {
|
|
27
|
+
if (orig) { resolve(orig(input, init)); return; } throw new Error('no fetch');
|
|
28
|
+
}
|
|
29
|
+
var method = (init.method || (typeof input !== 'string' && input && input.method) || 'GET').toUpperCase();
|
|
30
|
+
var headers = {};
|
|
31
|
+
var ih = init.headers || (typeof input !== 'string' && input && input.headers);
|
|
32
|
+
if (ih) { if (typeof ih.forEach === 'function') ih.forEach(function(v,k){ headers[k]=v; }); else for (var k in ih) headers[k]=ih[k]; }
|
|
33
|
+
if (!headers.origin && !headers.Origin) headers.origin = location.origin;
|
|
34
|
+
var body = init.body != null ? (typeof init.body === 'string' ? init.body : JSON.stringify(init.body)) : null;
|
|
35
|
+
var resStr = window.__ApexNative.handle(JSON.stringify({ url: abs.href, method: method, headers: headers, body: body }));
|
|
36
|
+
var res = JSON.parse(resStr);
|
|
37
|
+
resolve(new Response(res.body, { status: res.status || 200, headers: res.headers || {} }));
|
|
38
|
+
} catch (e) { if (orig) resolve(orig(input, init)); else reject(e); }
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
})();
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The Apex native shell. Boots the embedded JS engine (which runs the on-device Apex server
|
|
46
|
+
* bundle), points a WebView at the app root, and intercepts every request to serve it from
|
|
47
|
+
* that engine — offline, no server, no port.
|
|
48
|
+
*
|
|
49
|
+
* Splash: the native cold-start splash (Theme.Apex.Splash) bridges process start; the WebView
|
|
50
|
+
* then loads the `pages/splash.alpine` route (your animated intro, SSR-rendered instantly),
|
|
51
|
+
* which navigates to `/` when it signals ready.
|
|
52
|
+
*/
|
|
53
|
+
class MainActivity : ComponentActivity() {
|
|
54
|
+
private lateinit var engine: ApexEngine
|
|
55
|
+
|
|
56
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
57
|
+
val splash = installSplashScreen() // native cold-start splash (androidx.core:core-splashscreen)
|
|
58
|
+
super.onCreate(savedInstanceState)
|
|
59
|
+
|
|
60
|
+
val webView = WebView(this)
|
|
61
|
+
setContentView(webView)
|
|
62
|
+
webView.settings.javaScriptEnabled = true
|
|
63
|
+
webView.settings.domStorageEnabled = true
|
|
64
|
+
|
|
65
|
+
// Cookies: the session is an HttpOnly cookie the bridge/interceptor manage via CookieManager.
|
|
66
|
+
CookieManager.getInstance().setAcceptCookie(true)
|
|
67
|
+
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
|
|
68
|
+
|
|
69
|
+
lifecycleScope.launch {
|
|
70
|
+
// 1) Start the engine + load the on-device server bundle from assets/.
|
|
71
|
+
engine = ApexEngine.create(applicationContext)
|
|
72
|
+
|
|
73
|
+
// 2) Intercept navigations/static → engine (assets served directly).
|
|
74
|
+
webView.webViewClient = ApexInterceptor(applicationContext, engine)
|
|
75
|
+
|
|
76
|
+
// 3) Bridge dynamic fetch() (with bodies) → engine. shouldInterceptRequest can't read a
|
|
77
|
+
// request body, so POST/login go through this JS interface + a document-start patch.
|
|
78
|
+
webView.addJavascriptInterface(ApexBridge(engine, applicationContext), "__ApexNative")
|
|
79
|
+
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
|
80
|
+
WebViewCompat.addDocumentStartJavaScript(webView, FETCH_PATCH, setOf("*"))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4) Load the app. Start at the animated splash route; it navigates to '/' when ready.
|
|
84
|
+
// (Any origin — every request is intercepted; the host is ignored.)
|
|
85
|
+
webView.loadUrl("https://localhost/splash")
|
|
86
|
+
|
|
87
|
+
// Keep the native splash until the first paint, then let the .alpine splash take over.
|
|
88
|
+
splash.setKeepOnScreenCondition { false }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
override fun onDestroy() {
|
|
93
|
+
if (::engine.isInitialized) engine.close()
|
|
94
|
+
super.onDestroy()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
Binary file
|
|
Binary file
|