craft-native 0.0.91 → 0.0.94

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.
@@ -1,6 +1,7 @@
1
1
  package com.craft.runtime
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.IntentSender
4
5
  import com.google.android.gms.location.Priority
5
6
  import com.google.android.gms.location.LocationServices
6
7
  import com.google.android.gms.location.LocationResult
@@ -110,10 +111,24 @@ object CraftNative {
110
111
  */
111
112
  val isAvailable: Boolean = try {
112
113
  System.loadLibrary("craft")
114
+ // Said out loud, because the two outcomes below are otherwise
115
+ // indistinguishable from outside: a library that never shipped and a
116
+ // library that loaded and then failed to bind both end with the shim
117
+ // answering every call. Zig logs its own registration under the same
118
+ // tag, so the pair reads as a sequence.
119
+ android.util.Log.i("CraftNative", "craft: libcraft.so loaded")
113
120
  true
114
121
  } catch (e: UnsatisfiedLinkError) {
122
+ // The reason, not just the fact. This used to be a bare `false`, and
123
+ // the first time the runtime was genuinely shipped and genuinely
124
+ // failed to load, the only evidence anywhere was that nothing
125
+ // happened — no linker line, no exception, nothing in logcat at all.
126
+ // The message carries the dynamic linker's own complaint, which is the
127
+ // one thing that says *why*.
128
+ android.util.Log.w("CraftNative", "craft: libcraft.so did not load: ${e.message}")
115
129
  false
116
130
  } catch (e: SecurityException) {
131
+ android.util.Log.w("CraftNative", "craft: libcraft.so was refused: ${e.message}")
117
132
  false
118
133
  }
119
134
 
@@ -197,14 +212,20 @@ object CraftNative {
197
212
  /**
198
213
  * Run `script` in the page. Called from Zig, on any thread.
199
214
  *
200
- * Silent when no deliverer is installed — which is every moment before the
201
- * WebView exists, and after it is torn down. A throw here would cross back
202
- * into Zig as a pending exception on a thread that has nowhere to report
203
- * it, and the caller already counts what it could not deliver.
215
+ * Returns whether a deliverer took it. False is every moment before the
216
+ * WebView exists and after it is torn down, and it used to be
217
+ * indistinguishable from a delivery: the script was dropped, Zig's caller
218
+ * reported success, and the page waited on a promise nothing would settle
219
+ * (#231). Zig counts a false, and says so once.
220
+ *
221
+ * Still no throw. A throw here would cross back into Zig as a pending
222
+ * exception on a thread with nowhere to report it; false says the same
223
+ * thing and the caller can act on it.
204
224
  */
205
225
  @JvmStatic
206
- fun deliver(script: String) {
207
- runCatching { deliverer?.invoke(script) }
226
+ fun deliver(script: String): Boolean {
227
+ val target = deliverer ?: return false
228
+ return runCatching { target(script); true }.getOrDefault(false)
208
229
  }
209
230
 
210
231
  private external fun nativeGetDeviceInfo(activity: Activity): String?
@@ -213,7 +234,7 @@ object CraftNative {
213
234
  private external fun nativeClipboardRead(activity: Activity): String?
214
235
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
215
236
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
216
- private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
237
+ private external fun nativeShare(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean
217
238
  private external fun nativeOpenCamera(activity: Activity): Boolean
218
239
  private external fun nativePickImage(activity: Activity): Boolean
219
240
  private external fun nativePickFile(activity: Activity): Boolean
@@ -235,7 +256,7 @@ object CraftNative {
235
256
  private external fun nativeResetDeepLinks(): Boolean
236
257
  private external fun nativeSetInitialURL(url: String?): Boolean
237
258
  private external fun nativeGetInitialURL(): Boolean
238
- private external fun nativeDispatchDeepLink(url: String): Boolean
259
+ private external fun nativeDispatchDeepLink(url: String, initial: Boolean): Boolean
239
260
  private external fun nativeScreenshotReady(bytes: ByteArray)
240
261
  private external fun nativeScreenshotError(message: String)
241
262
  private external fun nativeTakeScreenshot(activity: Activity, webView: WebView): Boolean
@@ -411,11 +432,15 @@ object CraftNative {
411
432
  }
412
433
  }
413
434
 
414
- /** Returns whether Zig launched the share sheet; false falls through. */
415
- fun share(activity: Activity, text: String, title: String): Boolean {
435
+ /**
436
+ * Returns whether Zig launched the share menu for a result; false falls
437
+ * through. `chosen` fires on a pick and `requestCode` routes the result
438
+ * back to CraftBridge, which settles the page's promise from the two.
439
+ */
440
+ fun share(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean {
416
441
  if (!isAvailable) return false
417
442
  return try {
418
- nativeShare(activity, text, title)
443
+ nativeShare(activity, text, title, chosen, requestCode)
419
444
  } catch (e: UnsatisfiedLinkError) {
420
445
  false
421
446
  }
@@ -697,9 +722,9 @@ object CraftNative {
697
722
  return try { nativeGetInitialURL() } catch (e: UnsatisfiedLinkError) { false }
698
723
  }
699
724
 
700
- fun dispatchDeepLink(url: String): Boolean {
725
+ fun dispatchDeepLink(url: String, initial: Boolean): Boolean {
701
726
  if (!isAvailable) return false
702
- return try { nativeDispatchDeepLink(url) } catch (e: UnsatisfiedLinkError) { false }
727
+ return try { nativeDispatchDeepLink(url, initial) } catch (e: UnsatisfiedLinkError) { false }
703
728
  }
704
729
 
705
730
  @JvmStatic
@@ -1,10 +1,14 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.content.Context
4
5
  import android.os.Bundle
5
6
  import android.content.Intent
6
7
  import android.graphics.Bitmap
8
+ import android.net.ConnectivityManager
9
+ import android.net.Network
7
10
  import android.net.Uri
11
+ import android.util.Log
8
12
  import android.webkit.WebResourceRequest
9
13
  import android.webkit.WebResourceError
10
14
  import android.webkit.WebResourceResponse
@@ -19,6 +23,20 @@ import org.json.JSONObject
19
23
  import java.io.FileNotFoundException
20
24
  import java.net.URLConnection
21
25
 
26
+ /** Main-frame failures for which the bundled app is an honest fallback (#254). */
27
+ object CraftLoadFailure {
28
+ fun isUnreachable(errorCode: Int): Boolean = when (errorCode) {
29
+ WebViewClient.ERROR_HOST_LOOKUP,
30
+ WebViewClient.ERROR_CONNECT,
31
+ WebViewClient.ERROR_TIMEOUT,
32
+ // Android uses ERROR_IO for connection interruptions as well as local
33
+ // I/O failures. Keep it here deliberately: it is the closest Android
34
+ // equivalent to iOS's NSURLErrorNetworkConnectionLost.
35
+ WebViewClient.ERROR_IO -> true
36
+ else -> false
37
+ }
38
+ }
39
+
22
40
  class MainActivity : AppCompatActivity() {
23
41
  private lateinit var webView: WebView
24
42
  private lateinit var craftBridge: CraftBridge
@@ -32,6 +50,28 @@ class MainActivity : AppCompatActivity() {
32
50
  .build()
33
51
  }
34
52
  private var loadedBundledFallback = false
53
+ private lateinit var connectivityManager: ConnectivityManager
54
+ @Volatile
55
+ private var isConnected = false
56
+ private var networkMonitoringRegistered = false
57
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
58
+ override fun onAvailable(network: Network) {
59
+ val wasConnected = isConnected
60
+ isConnected = true
61
+ if (!wasConnected && isConnected) {
62
+ runOnUiThread {
63
+ returnFromBundledFallback("the network came back")
64
+ }
65
+ }
66
+ }
67
+
68
+ override fun onLost(network: Network) {
69
+ // A second transport can already be the default by the time the
70
+ // old one reports its loss, so read current state instead of
71
+ // blindly turning the latch off.
72
+ isConnected = connectivityManager.activeNetwork != null
73
+ }
74
+ }
35
75
 
36
76
  @SuppressLint("SetJavaScriptEnabled")
37
77
  override fun onCreate(savedInstanceState: Bundle?) {
@@ -44,11 +84,31 @@ class MainActivity : AppCompatActivity() {
44
84
 
45
85
  webView = findViewById(R.id.webview)
46
86
  craftBridge = CraftBridge(this, webView, CraftHealthConnect(this, webView))
47
- handleIncomingDeepLink(intent, dispatch = false)
48
- if (savedInstanceState == null) handleIncomingShortcut(intent)
87
+ // The link that launched the app (#215). Queued until the page's bridge
88
+ // is injected, then dispatched once with `initial: true`, so a page that
89
+ // subscribes rather than calling getInitialURL still hears it.
90
+ //
91
+ // A relaunch from Recents carries the intent the app was first opened
92
+ // with. The person picked the task out of a list, not the link or the
93
+ // shortcut inside it, and neither is news the second time — so both
94
+ // are skipped, not just the link (#229).
95
+ //
96
+ // A recreated activity restores what the bridge saved rather than
97
+ // reading its intent, which onNewIntent may have replaced with a
98
+ // later warm link.
99
+ val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
100
+ if (savedInstanceState == null) {
101
+ if (!launchedFromHistory) {
102
+ intent.data?.toString()?.let(craftBridge::receiveDeepLink)
103
+ handleIncomingShortcut(intent)
104
+ }
105
+ } else {
106
+ craftBridge.restoreDeepLinks(savedInstanceState)
107
+ }
49
108
 
50
109
  setupWebView()
51
110
  loadContent()
111
+ setupNetworkMonitoring()
52
112
  }
53
113
 
54
114
  @SuppressLint("SetJavaScriptEnabled")
@@ -94,7 +154,13 @@ class MainActivity : AppCompatActivity() {
94
154
 
95
155
  override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
96
156
  super.onReceivedError(view, request, error)
97
- if (request?.isForMainFrame == true && hasBundledFallback && !loadedBundledFallback) {
157
+ if (
158
+ request?.isForMainFrame == true &&
159
+ error != null &&
160
+ CraftLoadFailure.isUnreachable(error.errorCode) &&
161
+ hasBundledFallback &&
162
+ !loadedBundledFallback
163
+ ) {
98
164
  loadedBundledFallback = true
99
165
  webView.loadUrl(BUNDLED_APP_URL)
100
166
  }
@@ -107,6 +173,23 @@ class MainActivity : AppCompatActivity() {
107
173
  webView.addJavascriptInterface(craftBridge, "CraftAndroid")
108
174
  }
109
175
 
176
+ private fun setupNetworkMonitoring() {
177
+ if (!hasBundledFallback || devServerUrl == null) return
178
+ connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
179
+ isConnected = connectivityManager.activeNetwork != null
180
+ connectivityManager.registerDefaultNetworkCallback(networkCallback)
181
+ networkMonitoringRegistered = true
182
+ }
183
+
184
+ /** Leave the bundled copy for the configured remote origin again. */
185
+ private fun returnFromBundledFallback(reason: String) {
186
+ val remoteUrl = devServerUrl ?: return
187
+ if (!loadedBundledFallback) return
188
+ loadedBundledFallback = false
189
+ Log.i(TAG, "$reason; loading $remoteUrl again instead of the bundled copy")
190
+ webView.loadUrl(remoteUrl)
191
+ }
192
+
110
193
  private fun loadContent() {
111
194
  // Check for dev server URL in config
112
195
  val remoteUrl = devServerUrl
@@ -150,12 +233,6 @@ class MainActivity : AppCompatActivity() {
150
233
  }
151
234
  }
152
235
 
153
- private fun handleIncomingDeepLink(source: Intent?, dispatch: Boolean) {
154
- val url = source?.data?.toString() ?: return
155
- craftBridge.setInitialURL(url)
156
- if (dispatch) craftBridge.dispatchDeepLink(url)
157
- }
158
-
159
236
  private fun handleIncomingShortcut(source: Intent?) {
160
237
  source?.getStringExtra(SHORTCUT_TYPE_EXTRA)?.let(craftBridge::dispatchShortcut)
161
238
  }
@@ -198,10 +275,15 @@ class MainActivity : AppCompatActivity() {
198
275
  return (uri.scheme == "https" || localDevelopment) && origin in trustedOrigins
199
276
  }
200
277
 
278
+ override fun onSaveInstanceState(outState: Bundle) {
279
+ super.onSaveInstanceState(outState)
280
+ craftBridge.saveDeepLinks(outState)
281
+ }
282
+
201
283
  override fun onNewIntent(intent: Intent) {
202
284
  super.onNewIntent(intent)
203
285
  setIntent(intent)
204
- handleIncomingDeepLink(intent, dispatch = true)
286
+ intent.data?.toString()?.let(craftBridge::receiveDeepLink)
205
287
  handleIncomingShortcut(intent)
206
288
  }
207
289
 
@@ -216,6 +298,7 @@ class MainActivity : AppCompatActivity() {
216
298
  override fun onResume() {
217
299
  super.onResume()
218
300
  webView.onResume()
301
+ returnFromBundledFallback("the app returned to the foreground")
219
302
  }
220
303
 
221
304
  override fun onPause() {
@@ -224,6 +307,10 @@ class MainActivity : AppCompatActivity() {
224
307
  }
225
308
 
226
309
  override fun onDestroy() {
310
+ if (networkMonitoringRegistered) {
311
+ connectivityManager.unregisterNetworkCallback(networkCallback)
312
+ networkMonitoringRegistered = false
313
+ }
227
314
  craftBridge.close()
228
315
  webView.destroy()
229
316
  super.onDestroy()
@@ -245,6 +332,7 @@ class MainActivity : AppCompatActivity() {
245
332
  }
246
333
 
247
334
  companion object {
335
+ private const val TAG = "CraftMainActivity"
248
336
  private const val BUNDLED_APP_HOST = "appassets.androidplatform.net"
249
337
  private const val BUNDLED_APP_ORIGIN = "https://appassets.androidplatform.net"
250
338
  private const val BUNDLED_APP_URL = "$BUNDLED_APP_ORIGIN/"
@@ -550,9 +550,12 @@ export declare const share: {
550
550
  /**
551
551
  * Open native share dialog.
552
552
  *
553
+ * Resolves `true` when the person shared and `false` when they dismissed the
554
+ * dialog, on the native bridges and in the browser alike.
555
+ *
553
556
  * @param options - Share options
554
557
  */
555
- share(options: ShareOptions): Promise<void>;
558
+ share(options: ShareOptions): Promise<boolean>;
556
559
  /**
557
560
  * Check if sharing is available.
558
561
  */
@@ -614,6 +617,22 @@ export interface NotificationOptions {
614
617
  /** Schedule for future (timestamp) */
615
618
  scheduleAt?: number;
616
619
  }
620
+ /** A notification as the bridges read it: `delay` in place of `scheduleAt`. */
621
+ export type BridgeNotification = Omit<NotificationOptions, 'scheduleAt'> & {
622
+ delay?: number;
623
+ };
624
+ /**
625
+ * The bridges' spelling of a notification: `scheduleAt` becomes `delay`.
626
+ *
627
+ * Both bridges schedule from `delay`, milliseconds from now, and iOS also from
628
+ * `timestamp`; neither reads `scheduleAt`, so a reminder meant for an hour
629
+ * from now fired at once (#261). `delay` rather than `timestamp` because
630
+ * Android reads only `delay`, and because an interval is what an instant
631
+ * means, where iOS's calendar trigger would move with the device's time zone.
632
+ * A moment already past sends no delay at all, which both bridges deliver
633
+ * now: a zero delay raises inside UserNotifications on the Swift side.
634
+ */
635
+ export declare function bridgeNotification(options: NotificationOptions, now?: number): BridgeNotification;
617
636
  /**
618
637
  * Local notifications API.
619
638
  *
@@ -670,7 +689,14 @@ export declare const keepAwake: {
670
689
  };
671
690
  export declare const deepLinks: {
672
691
  getInitialURL(): Promise<string | null>;
673
- onLink(callback: (url: string) => void): () => void;
692
+ /**
693
+ * The second argument says whether this is the link that launched the app.
694
+ * A page that also calls `getInitialURL()` after an await gets that link
695
+ * from both, and can skip it here when `initial` is true.
696
+ */
697
+ onLink(callback: (url: string, link: {
698
+ initial: boolean;
699
+ }) => void): () => void;
674
700
  };
675
701
  export declare function normalizeDeepLinkURL(value: unknown): string | null;
676
702
  export declare const network: {
@@ -683,7 +709,23 @@ export declare const appReview: {
683
709
  export declare const pushNotifications: {
684
710
  register(): Promise<string>;
685
711
  onToken(callback: (token: string) => void): () => void;
712
+ /**
713
+ * A tap on a notification, with the payload it was sent with.
714
+ *
715
+ * Goes through the bridge's replay where there is one, so a tap that
716
+ * launched the app still reaches a page that subscribes once it has
717
+ * hydrated — rather than only one listening before the bridge was ready.
718
+ */
686
719
  onNotification(callback: (data: Record<string, unknown>) => void): () => void;
720
+ /**
721
+ * A notification that arrived while the app was open, with the payload it
722
+ * was sent with — the same shape `onNotification` hands a tap.
723
+ *
724
+ * The banner still shows. This is for a page that wants to react while it
725
+ * is on screen, such as refreshing what the push was about. Live only: an
726
+ * arrival before the page subscribed is not held for it. iOS only for now.
727
+ */
728
+ onReceive(callback: (data: Record<string, unknown>) => void): () => void;
687
729
  };
688
730
  export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts';
689
731
  export interface HealthDataOptions {
@@ -46,6 +46,10 @@ export declare function resolveCraftBinary(explicit?: string): string;
46
46
  * path-matrix behaviour the pantry contract exists to remove. So instead the
47
47
  * spawn is marked, and a CLI that starts up already marked knows it *is* the
48
48
  * thing that was spawned, and says so.
49
+ *
50
+ * Both spawners set it: `runCraftBinary()` in the CLI, and `CraftApp.show()`
51
+ * in the SDK. The SDK one was missing until #236, so every SDK caller on such
52
+ * a machine still met the raw parser error this marker exists to replace.
49
53
  */
50
54
  export declare const CRAFT_CLI_SPAWN_MARKER = "CRAFT_CLI_SPAWNED_FROM";
51
55
  /**
package/dist/cli.js CHANGED
@@ -1575,7 +1575,7 @@ async function open(options) {
1575
1575
  await $`open ${projectPath}`;
1576
1576
  }
1577
1577
  function orderSimulators(devices) {
1578
- return [...devices].sort((left, right) => {
1578
+ return devices.filter((device) => device.runtime.startsWith("iOS")).sort((left, right) => {
1579
1579
  const booted = Number(right.state === "Booted") - Number(left.state === "Booted");
1580
1580
  if (booted !== 0)
1581
1581
  return booted;
@@ -1730,11 +1730,14 @@ var exports_src2 = {};
1730
1730
  __export(exports_src2, {
1731
1731
  syncAndroidWebAssets: () => syncAndroidWebAssets,
1732
1732
  run: () => run2,
1733
+ resolveRuntimeDir: () => resolveRuntimeDir2,
1733
1734
  renderAndroidPermissions: () => renderAndroidPermissions,
1734
1735
  renderAndroidDeepLinks: () => renderAndroidDeepLinks,
1735
1736
  open: () => open2,
1737
+ installRuntime: () => installRuntime2,
1736
1738
  init: () => init2,
1737
- build: () => build2
1739
+ build: () => build2,
1740
+ ANDROID_MIN_SDK: () => ANDROID_MIN_SDK
1738
1741
  });
1739
1742
  import { cpSync as cpSync3, existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
1740
1743
  import { dirname as dirname2, extname, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
@@ -1838,6 +1841,9 @@ function validateAndroidConfig(config) {
1838
1841
  throw new Error(`Android ${name} must be a positive integer`);
1839
1842
  }
1840
1843
  }
1844
+ if (Number(config.minSdk) < ANDROID_MIN_SDK) {
1845
+ throw new Error(`Android minSdk must be at least ${ANDROID_MIN_SDK} (Android 8.0) for the Craft runtime`);
1846
+ }
1841
1847
  if (Number(config.minSdk) > Number(config.targetSdk)) {
1842
1848
  throw new Error("Android minSdk must not exceed targetSdk");
1843
1849
  }
@@ -1928,6 +1934,36 @@ function renderAndroidDeepLinks(config) {
1928
1934
  </intent-filter>`).join(`
1929
1935
  `);
1930
1936
  }
1937
+ function resolveRuntimeDir2(override) {
1938
+ if (override === null)
1939
+ return null;
1940
+ const dir = override ?? process.env.CRAFT_ANDROID_RUNTIME;
1941
+ if (!dir)
1942
+ return null;
1943
+ if (!existsSync5(dir)) {
1944
+ const source = override === undefined ? "CRAFT_ANDROID_RUNTIME points at" : "runtimeDir is";
1945
+ throw new Error(`${source} ${dir}, which does not exist.`);
1946
+ }
1947
+ return dir;
1948
+ }
1949
+ function installRuntime2(output, runtimeDir) {
1950
+ const found = RUNTIME_ABIS.map((abi) => ({ abi, source: join3(runtimeDir, abi, "libcraft.so") })).filter((entry) => existsSync5(entry.source));
1951
+ if (found.length === 0) {
1952
+ throw new Error(`${runtimeDir} has no <abi>/libcraft.so for any of ${RUNTIME_ABIS.join(", ")}. ` + "Run `zig build build-android-all -Doptimize=ReleaseSafe` in packages/zig and point at its zig-out/android.");
1953
+ }
1954
+ if (found.length < RUNTIME_ABIS.length) {
1955
+ const missing = RUNTIME_ABIS.filter((abi) => !found.some((entry) => entry.abi === abi));
1956
+ console.warn(` \u26A0 only ${found.map((entry) => entry.abi).join(", ")} was found; ${missing.join(", ")} is missing. ` + "The app will fall back to the Kotlin shim on those devices.");
1957
+ }
1958
+ const dest = join3(output, "app/src/main/jniLibs");
1959
+ rmSync3(dest, { force: true, recursive: true });
1960
+ for (const { abi, source } of found) {
1961
+ const abiDir = join3(dest, abi);
1962
+ mkdirSync3(abiDir, { recursive: true });
1963
+ cpSync3(source, join3(abiDir, "libcraft.so"));
1964
+ }
1965
+ return true;
1966
+ }
1931
1967
  async function init2(options) {
1932
1968
  const { name, packageName, output } = options;
1933
1969
  console.log(`
@@ -1945,7 +1981,6 @@ async function init2(options) {
1945
1981
  if (config.enableBackgroundLocation)
1946
1982
  config.enableGeolocation = true;
1947
1983
  if (config.enableHealthConnect) {
1948
- config.minSdk = Math.max(config.minSdk ?? 26, 26);
1949
1984
  config.compileSdk = Math.max(config.compileSdk ?? 36, 36);
1950
1985
  }
1951
1986
  normalizeAndroidNetworkConfig(config);
@@ -2029,7 +2064,7 @@ async function init2(options) {
2029
2064
  const projectGradleTemplate = readFileSync4(join3(TEMPLATES_DIR2, "build.gradle.kts.project.template"), "utf-8");
2030
2065
  writeFileSync4(join3(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
2031
2066
  const appGradleTemplate = readFileSync4(join3(TEMPLATES_DIR2, "build.gradle.kts.app.template"), "utf-8");
2032
- const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk || 24)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
2067
+ const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
2033
2068
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "");
2034
2069
  writeFileSync4(join3(output, "app/build.gradle.kts"), appGradle);
2035
2070
  const proguardTemplate = readFileSync4(join3(TEMPLATES_DIR2, "proguard-rules.pro.template"), "utf-8");
@@ -2147,6 +2182,11 @@ zipStorePath=wrapper/dists
2147
2182
  </body>
2148
2183
  </html>`;
2149
2184
  writeFileSync4(join3(output, "app/src/main/assets/index.html"), placeholderHtml);
2185
+ const runtimeDir = resolveRuntimeDir2(options.runtimeDir);
2186
+ if (runtimeDir) {
2187
+ installRuntime2(output, runtimeDir);
2188
+ console.log(" Installed the Zig runtime from", runtimeDir);
2189
+ }
2150
2190
  console.log("\u2705 Project initialized");
2151
2191
  console.log("");
2152
2192
  console.log("Next steps:");
@@ -2165,6 +2205,15 @@ async function build2(options) {
2165
2205
  throw new Error(`No craft.config.json found in ${output}. Run 'craft android init' first.`);
2166
2206
  }
2167
2207
  const config = JSON.parse(readFileSync4(configPath, "utf-8"));
2208
+ if (existsSync5(join3(output, "app/src/main/jniLibs"))) {
2209
+ const runtimeDir = resolveRuntimeDir2(options.runtimeDir);
2210
+ if (runtimeDir) {
2211
+ installRuntime2(output, runtimeDir);
2212
+ console.log(" Refreshed the Zig runtime from", runtimeDir);
2213
+ } else {
2214
+ console.log(" Keeping the Zig runtime installed at init (no runtime directory configured)");
2215
+ }
2216
+ }
2168
2217
  if (devServer) {
2169
2218
  const url = androidWebUrl(devServer, "Android dev server URL");
2170
2219
  config.devServerURL = url.toString();
@@ -2313,7 +2362,7 @@ window.__craftRejectPendingPromises = function(message) {
2313
2362
  if (entry) entry.settle(false, new Error(message || 'Android bridge closed'));
2314
2363
  });
2315
2364
  };
2316
- `, TEMPLATES_DIR2, LOCAL_DEVELOPMENT_HOSTS, KOTLIN_KEYWORDS, DEFAULT_CONFIG2;
2365
+ `, TEMPLATES_DIR2, LOCAL_DEVELOPMENT_HOSTS, KOTLIN_KEYWORDS, ANDROID_MIN_SDK = 26, DEFAULT_CONFIG2, RUNTIME_ABIS;
2317
2366
  var init_src2 = __esm(() => {
2318
2367
  ({ $: $2 } = globalThis.Bun);
2319
2368
  TEMPLATES_DIR2 = join3(dirname2(import.meta.dir), "templates");
@@ -2367,10 +2416,11 @@ var init_src2 = __esm(() => {
2367
2416
  enableDeepLinks: false,
2368
2417
  urlSchemes: [],
2369
2418
  trustedOrigins: [],
2370
- minSdk: 26,
2419
+ minSdk: ANDROID_MIN_SDK,
2371
2420
  compileSdk: 36,
2372
2421
  targetSdk: 35
2373
2422
  };
2423
+ RUNTIME_ABIS = ["arm64-v8a", "x86_64"];
2374
2424
  });
2375
2425
 
2376
2426
  // ../../node_modules/@stacksjs/clapp/dist/index.js
@@ -4483,7 +4533,7 @@ function craftBinaryIsCliShimMessage(spawnedFrom) {
4483
4533
  return [
4484
4534
  `"${spawnedFrom}" on PATH is the Craft CLI, not the native binary.`,
4485
4535
  "",
4486
- "The CLI spawned it expecting the native build, and re-entered itself \u2014",
4536
+ "Craft spawned it expecting the native build and reached this CLI instead \u2014",
4487
4537
  "which is why an option you never typed (--url) came back as unknown.",
4488
4538
  "",
4489
4539
  "Fix it in one of two ways:",
@@ -4537,7 +4587,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
4537
4587
  `);
4538
4588
  }
4539
4589
  // package.json
4540
- var version = "0.0.91";
4590
+ var version = "0.0.94";
4541
4591
 
4542
4592
  // bin/cli.ts
4543
4593
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -5025,10 +5075,16 @@ cli.command("publish", "Publish app to App Store or Play Store").option("--ios",
5025
5075
  }
5026
5076
  });
5027
5077
  cli.command("init <name>", "Initialize a new Craft project").option("--template <type>", "Project template (blank, tabs, drawer, dashboard, desktop, ios, android, all)", { default: "blank" }).option("--bundle-id <id>", "Bundle identifier for mobile").example("craft init MyApp").example("craft init MyApp --template tabs").example("craft init MyApp --template dashboard").example("craft init MyApp --template all").action(async (name, options) => {
5078
+ const template = options?.template || "blank";
5079
+ const templates = ["blank", "tabs", "drawer", "dashboard", "desktop", "ios", "android", "all"];
5080
+ if (!templates.includes(template)) {
5081
+ console.error(`Error: Unknown template "${template}"`);
5082
+ console.error(`Available templates: ${templates.join(", ")}`);
5083
+ process6.exit(1);
5084
+ }
5028
5085
  console.log(`
5029
5086
  \u26A1 Creating new Craft project: ${name}
5030
5087
  `);
5031
- const template = options?.template || "blank";
5032
5088
  const bundleId = options?.bundleId || `com.example.${name.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
5033
5089
  const appNameSlug = name.toLowerCase().replace(/[^a-z0-9]/g, "-");
5034
5090
  const { mkdirSync: mkdirSync4, writeFileSync: writeFileSync5, existsSync: existsSync7, readdirSync: readdirSync3, readFileSync: readFileSync5, cpSync: cpSync4 } = await import("fs");