create-apexjs 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/default/AGENTS.md +6 -6
- package/templates/default/layouts/default.alpine +48 -19
- package/templates/features/data/db/index.ts +25 -24
- package/templates/mobile/android/README.md +53 -0
- package/templates/mobile/android/app/build.gradle.kts +30 -0
- package/templates/mobile/android/app/src/main/AndroidManifest.xml +24 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexBridge.kt +59 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexDbStore.kt +31 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexEngine.kt +66 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/ApexInterceptor.kt +83 -0
- package/templates/mobile/android/app/src/main/java/site/apexjs/shell/MainActivity.kt +96 -0
- package/templates/mobile/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/templates/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
- package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/templates/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
- package/templates/mobile/android/app/src/main/res/values/strings.xml +4 -0
- package/templates/mobile/android/app/src/main/res/values/themes.xml +11 -0
- package/templates/mobile/android/build.gradle.kts +4 -0
- package/templates/mobile/android/gradle.properties +3 -0
- package/templates/mobile/android/play_store_512.png +0 -0
- package/templates/mobile/android/settings.gradle.kts +4 -0
- package/templates/mobile/apex-bridge.js +22 -0
- package/templates/mobile/gen-mobile-assets.mjs +101 -0
- package/templates/mobile/ios/ApexApp.swift +106 -0
- package/templates/mobile/ios/ApexDbStore.swift +74 -0
- package/templates/mobile/ios/ApexEngine.swift +253 -0
- package/templates/mobile/ios/ApexSchemeHandler.swift +349 -0
- package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/mobile/ios/Assets.xcassets/AppIcon.appiconset/icon-1024.png +0 -0
- package/templates/mobile/ios/Assets.xcassets/LaunchBackground.colorset/Contents.json +20 -0
- package/templates/mobile/ios/Info.plist +60 -0
- package/templates/mobile/ios/README.md +153 -0
- package/templates/mobile/ios/Tests/ApexEngineTests.swift +74 -0
- package/templates/mobile/ios/project.yml +75 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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
|
+
}
|