craft-native 0.0.90 → 0.0.92

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 (31) hide show
  1. package/dist/android/src/index.d.ts +36 -1
  2. package/dist/android/src/index.js +332 -44
  3. package/dist/android/src/promise-runtime.d.ts +3 -0
  4. package/dist/android/templates/CraftBridge.kt.template +2167 -1177
  5. package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
  6. package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
  7. package/dist/android/templates/CraftNative.kt.template +2250 -0
  8. package/dist/android/templates/LocationRecordingService.kt.template +34 -9
  9. package/dist/android/templates/MainActivity.kt.template +42 -8
  10. package/dist/android/templates/proguard-rules.pro.template +4 -1
  11. package/dist/android/templates/test-bridges.html +10 -33
  12. package/dist/api/index.d.ts +1 -1
  13. package/dist/api/ios-advanced.d.ts +8 -5
  14. package/dist/api/live-activity-handle.d.ts +6 -0
  15. package/dist/api/mobile.d.ts +25 -7
  16. package/dist/api/window.d.ts +2 -0
  17. package/dist/cli.js +452 -129
  18. package/dist/index.cjs +77 -22
  19. package/dist/index.js +77 -22
  20. package/dist/ios/src/index.d.ts +1 -1
  21. package/dist/ios/src/index.js +23 -5
  22. package/dist/ios/templates/CraftApp.swift +706 -92
  23. package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
  24. package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
  25. package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
  26. package/dist/ios/templates/project.yml.template +10 -4
  27. package/dist/mobile.js +52 -21
  28. package/dist/scaffold-version.d.ts +5 -0
  29. package/package.json +1 -1
  30. package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
  31. package/dist/android/templates/CraftWidgetProvider.kt.template +0 -246
@@ -37,6 +37,14 @@ export interface InitOptions {
37
37
  packageName?: string;
38
38
  output: string;
39
39
  config?: Partial<CraftAndroidConfig>;
40
+ /**
41
+ * Where to find the Zig runtime's `<abi>/libcraft.so`.
42
+ *
43
+ * `null` means no runtime regardless of the environment; `undefined` falls
44
+ * through to `CRAFT_ANDROID_RUNTIME`. Same shape as the iOS builder's
45
+ * `runtimeDir`.
46
+ */
47
+ runtimeDir?: string | null;
40
48
  }
41
49
  export interface BuildOptions {
42
50
  htmlPath?: string;
@@ -44,6 +52,8 @@ export interface BuildOptions {
44
52
  output: string;
45
53
  release?: boolean;
46
54
  compile?: boolean;
55
+ /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
56
+ runtimeDir?: string | null;
47
57
  }
48
58
  export interface OpenOptions {
49
59
  output: string;
@@ -56,8 +66,33 @@ export declare function syncAndroidWebAssets(source: string, output: string): vo
56
66
  export declare function renderAndroidPermissions(config: CraftAndroidConfig): string;
57
67
  export declare function renderAndroidDeepLinks(config: CraftAndroidConfig): string;
58
68
  /**
59
- * Initialize a new Android project
69
+ * Where the runtime directory comes from when the caller does not say.
70
+ *
71
+ * The same shape as `CRAFT_BIN` and the iOS builder's `CRAFT_IOS_RUNTIME`: an
72
+ * explicit override for the monorepo dev loop, not a lookup path. Shipping the
73
+ * runtime to real apps means putting these libraries in the pantry package
74
+ * beside the `craft` binary, which is a distribution decision this function
75
+ * does not make.
76
+ */
77
+ export declare function resolveRuntimeDir(override?: string | null): string | null;
78
+ /**
79
+ * Copy the Zig runtime into the generated project as
80
+ * `app/src/main/jniLibs/<abi>/libcraft.so`.
81
+ *
82
+ * That path is AGP's default `jniLibs.srcDirs`, so nothing in the Gradle
83
+ * templates has to know about it — the library is packaged into the APK and
84
+ * `System.loadLibrary("craft")` finds it because the file is named for the
85
+ * `craft` it asks for.
86
+ *
87
+ * Until this existed, nothing put the library anywhere. `CraftNative` caught
88
+ * the `UnsatisfiedLinkError`, set `isAvailable = false`, and every action fell
89
+ * through to the Kotlin shim — by design, so an app with no runtime still
90
+ * works, which is also why nobody noticed that *every* generated app was in
91
+ * that state and the whole Android half of the Zig bridge had never run.
92
+ *
93
+ * Returns true when a runtime was installed.
60
94
  */
95
+ export declare function installRuntime(output: string, runtimeDir: string): boolean;
61
96
  export declare function init(options: InitOptions): Promise<void>;
62
97
  /**
63
98
  * Build Android project
@@ -1,9 +1,115 @@
1
1
  // @bun
2
2
  // ../android/src/index.ts
3
3
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
4
- import { dirname, join, resolve } from "path";
4
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "path";
5
5
  var {$ } = globalThis.Bun;
6
+
7
+ // ../android/src/promise-runtime.ts
8
+ var ANDROID_PROMISE_RUNTIME = `
9
+ if (window.__craftRejectPendingPromises) {
10
+ window.__craftRejectPendingPromises('Android bridge reinitialized');
11
+ }
12
+ if (window.__craftRejectPermissionRequests) {
13
+ window.__craftRejectPermissionRequests('Android bridge reinitialized');
14
+ }
15
+ window.__craftPromiseRuntimeClosed = false;
16
+ window.__craftPendingPromises = Object.create(null);
17
+ window.__craftPromise = function(channel, resolveName, rejectName, invoke, timeoutMs, timeoutError) {
18
+ if (window.__craftPromiseRuntimeClosed) {
19
+ return Promise.reject(new Error('Android bridge is closed'));
20
+ }
21
+ if (window.__craftPendingPromises[channel]) {
22
+ return Promise.reject(new Error('A '.concat(channel, ' request is already in progress')));
23
+ }
24
+
25
+ return new Promise(function(resolve, reject) {
26
+ var entry = {settled: false, timer: null, settle: null};
27
+ var resolveCallback;
28
+ var rejectCallback;
29
+
30
+ entry.settle = function(succeeded, value) {
31
+ if (entry.settled || window.__craftPendingPromises[channel] !== entry) return;
32
+ entry.settled = true;
33
+ if (entry.timer !== null) clearTimeout(entry.timer);
34
+ if (window[resolveName] === resolveCallback) window[resolveName] = null;
35
+ if (window[rejectName] === rejectCallback) window[rejectName] = null;
36
+ delete window.__craftPendingPromises[channel];
37
+ if (succeeded) resolve(value);
38
+ else reject(value);
39
+ };
40
+
41
+ resolveCallback = function(value) {
42
+ entry.settle(true, value);
43
+ };
44
+ rejectCallback = function(error) {
45
+ entry.settle(false, error);
46
+ };
47
+ window[resolveName] = resolveCallback;
48
+ window[rejectName] = rejectCallback;
49
+ window.__craftPendingPromises[channel] = entry;
50
+
51
+ if (timeoutMs > 0) {
52
+ entry.timer = setTimeout(function() {
53
+ entry.settle(false, timeoutError || new Error(channel.concat(' request timed out')));
54
+ }, timeoutMs);
55
+ }
56
+
57
+ try {
58
+ invoke();
59
+ }
60
+ catch (error) {
61
+ entry.settle(false, error);
62
+ }
63
+ });
64
+ };
65
+
66
+ window.__craftRejectPendingPromises = function(message) {
67
+ window.__craftPromiseRuntimeClosed = true;
68
+ Object.keys(window.__craftPendingPromises).forEach(function(channel) {
69
+ var entry = window.__craftPendingPromises[channel];
70
+ if (entry) entry.settle(false, new Error(message || 'Android bridge closed'));
71
+ });
72
+ };
73
+ `;
74
+ function renderAndroidPromiseRuntime(indent = "") {
75
+ return ANDROID_PROMISE_RUNTIME.trim().split(`
76
+ `).map((line) => `${indent}${line}`).join(`
77
+ `);
78
+ }
79
+
80
+ // ../android/src/index.ts
6
81
  var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
82
+ var LOCAL_DEVELOPMENT_HOSTS = new Set(["localhost", "127.0.0.1", "10.0.2.2"]);
83
+ var KOTLIN_KEYWORDS = new Set([
84
+ "as",
85
+ "break",
86
+ "class",
87
+ "continue",
88
+ "do",
89
+ "else",
90
+ "false",
91
+ "for",
92
+ "fun",
93
+ "if",
94
+ "in",
95
+ "interface",
96
+ "is",
97
+ "null",
98
+ "object",
99
+ "package",
100
+ "return",
101
+ "super",
102
+ "this",
103
+ "throw",
104
+ "true",
105
+ "try",
106
+ "typealias",
107
+ "typeof",
108
+ "val",
109
+ "var",
110
+ "when",
111
+ "while"
112
+ ]);
7
113
  var DEFAULT_CONFIG = {
8
114
  version: "1.0.0",
9
115
  versionCode: 1,
@@ -27,16 +133,135 @@ var DEFAULT_CONFIG = {
27
133
  compileSdk: 36,
28
134
  targetSdk: 35
29
135
  };
136
+ function escapeXml(value) {
137
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
138
+ }
139
+ function escapeKotlinString(value) {
140
+ return value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$").replaceAll("\r", "\\r").replaceAll(`
141
+ `, "\\n");
142
+ }
143
+ function generatedPackageSegment(name) {
144
+ const normalized = name.toLowerCase().replace(/[^a-z0-9_]/g, "");
145
+ if (!normalized)
146
+ return "app";
147
+ return /^[a-z_]/.test(normalized) ? normalized : `app${normalized}`;
148
+ }
149
+ function generatedGradleProjectName(name) {
150
+ const invalidCharacters = ["/", "\\", ":", "<", ">", '"', "?", "*", "|"];
151
+ const normalized = invalidCharacters.reduce((value, character) => value.replaceAll(character, "-"), name).trim();
152
+ return normalized || "craft-app";
153
+ }
154
+ function requireRegularFile(path, label) {
155
+ if (!existsSync(path))
156
+ throw new Error(`${label} not found: ${path}`);
157
+ if (!statSync(path).isFile())
158
+ throw new Error(`${label} must be a file: ${path}`);
159
+ }
160
+ function containsPath(parent, candidate) {
161
+ const relativePath = relative(parent, candidate);
162
+ return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
163
+ }
164
+ function validateGoogleServicesFile(path, packageName) {
165
+ requireRegularFile(path, "Google services file");
166
+ let document;
167
+ try {
168
+ document = JSON.parse(readFileSync(path, "utf8"));
169
+ } catch (error) {
170
+ throw new Error(`Google services file must contain valid JSON: ${path}`, { cause: error });
171
+ }
172
+ const matchingClient = document.client?.some((client) => {
173
+ return client.client_info?.android_client_info?.package_name === packageName;
174
+ });
175
+ if (!matchingClient) {
176
+ throw new Error(`Google services file has no client for Android package ${packageName}: ${path}`);
177
+ }
178
+ }
179
+ function androidWebUrl(value, field) {
180
+ let url;
181
+ try {
182
+ url = new URL(value);
183
+ } catch {
184
+ throw new Error(`Invalid ${field}: ${value}`);
185
+ }
186
+ const localDevelopment = url.protocol === "http:" && LOCAL_DEVELOPMENT_HOSTS.has(url.hostname);
187
+ if (url.protocol !== "https:" && !localDevelopment || url.username || url.password) {
188
+ throw new Error(`${field} must use HTTPS or local HTTP without credentials: ${value}`);
189
+ }
190
+ return url;
191
+ }
192
+ function normalizeAndroidNetworkConfig(config) {
193
+ const schemes = config.urlSchemes?.map((value) => value.trim().toLowerCase()) ?? [];
194
+ if (schemes.some((value) => !/^[a-z][a-z0-9+.-]*$/.test(value))) {
195
+ throw new Error("Android deep-link schemes must be valid URI schemes");
196
+ }
197
+ config.urlSchemes = [...new Set(schemes)];
198
+ if (config.enableDeepLinks && config.urlSchemes.length === 0) {
199
+ throw new Error("Android deep links require at least one URL scheme");
200
+ }
201
+ const trustedOrigins = (config.trustedOrigins ?? []).map((value) => {
202
+ return androidWebUrl(value, "Android trusted origin").origin;
203
+ });
204
+ if (config.devServerURL) {
205
+ const devServer = androidWebUrl(config.devServerURL, "Android dev server URL");
206
+ config.devServerURL = devServer.toString();
207
+ trustedOrigins.push(devServer.origin);
208
+ }
209
+ config.trustedOrigins = [...new Set(trustedOrigins)];
210
+ }
211
+ function validateAndroidConfig(config) {
212
+ if (!config.appName.trim())
213
+ throw new Error("Android app name must not be empty");
214
+ const packageSegments = config.packageName.split(".");
215
+ if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
216
+ throw new Error(`Invalid Android package name: ${config.packageName}`);
217
+ }
218
+ if (!/^#(?:[\dA-F]{3,4}|[\dA-F]{6}|[\dA-F]{8})$/i.test(config.backgroundColor ?? "")) {
219
+ throw new Error(`Invalid Android background color: ${config.backgroundColor}`);
220
+ }
221
+ for (const [name, value] of [
222
+ ["versionCode", config.versionCode],
223
+ ["minSdk", config.minSdk],
224
+ ["compileSdk", config.compileSdk],
225
+ ["targetSdk", config.targetSdk]
226
+ ]) {
227
+ if (!Number.isInteger(value) || Number(value) < 1) {
228
+ throw new Error(`Android ${name} must be a positive integer`);
229
+ }
230
+ }
231
+ if (Number(config.minSdk) > Number(config.targetSdk)) {
232
+ throw new Error("Android minSdk must not exceed targetSdk");
233
+ }
234
+ if (Number(config.targetSdk) > Number(config.compileSdk)) {
235
+ throw new Error("Android targetSdk must not exceed compileSdk");
236
+ }
237
+ }
238
+ function writeAndroidConfig(output, config) {
239
+ writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
240
+ const runtimeConfig = { ...config };
241
+ delete runtimeConfig.appIconPath;
242
+ delete runtimeConfig.googleServicesFile;
243
+ writeFileSync(join(output, "app/src/main/assets/craft.config.json"), JSON.stringify(runtimeConfig, null, 2));
244
+ }
30
245
  function syncAndroidWebAssets(source, output) {
31
246
  const sourcePath = resolve(source);
32
247
  if (!existsSync(sourcePath))
33
248
  throw new Error(`Web asset path not found: ${source}`);
34
- const assetsDir = join(output, "app/src/main/assets");
249
+ const sourceStat = statSync(sourcePath);
250
+ if (!sourceStat.isDirectory() && !sourceStat.isFile()) {
251
+ throw new Error(`Web asset path must be a file or directory: ${source}`);
252
+ }
253
+ const assetsDir = resolve(output, "app/src/main/assets");
254
+ if (containsPath(sourcePath, assetsDir) || containsPath(assetsDir, sourcePath)) {
255
+ throw new Error(`Web asset source must not overlap generated asset directory: ${source}`);
256
+ }
257
+ if (sourceStat.isDirectory()) {
258
+ requireRegularFile(join(sourcePath, "index.html"), "Web asset directory entry point");
259
+ }
35
260
  const configPath = join(assetsDir, "craft.config.json");
36
261
  const config = existsSync(configPath) ? readFileSync(configPath) : undefined;
37
262
  rmSync(assetsDir, { recursive: true, force: true });
38
263
  mkdirSync(assetsDir, { recursive: true });
39
- if (statSync(sourcePath).isDirectory())
264
+ if (sourceStat.isDirectory())
40
265
  cpSync(sourcePath, assetsDir, { recursive: true });
41
266
  else
42
267
  cpSync(sourcePath, join(assetsDir, "index.html"));
@@ -93,14 +318,70 @@ function renderAndroidDeepLinks(config) {
93
318
  </intent-filter>`).join(`
94
319
  `);
95
320
  }
321
+ var RUNTIME_ABIS = ["arm64-v8a", "x86_64"];
322
+ function resolveRuntimeDir(override) {
323
+ if (override === null)
324
+ return null;
325
+ const dir = override ?? process.env.CRAFT_ANDROID_RUNTIME;
326
+ if (!dir)
327
+ return null;
328
+ if (!existsSync(dir)) {
329
+ const source = override === undefined ? "CRAFT_ANDROID_RUNTIME points at" : "runtimeDir is";
330
+ throw new Error(`${source} ${dir}, which does not exist.`);
331
+ }
332
+ return dir;
333
+ }
334
+ function installRuntime(output, runtimeDir) {
335
+ const found = RUNTIME_ABIS.map((abi) => ({ abi, source: join(runtimeDir, abi, "libcraft.so") })).filter((entry) => existsSync(entry.source));
336
+ if (found.length === 0) {
337
+ 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.");
338
+ }
339
+ if (found.length < RUNTIME_ABIS.length) {
340
+ const missing = RUNTIME_ABIS.filter((abi) => !found.some((entry) => entry.abi === abi));
341
+ 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.");
342
+ }
343
+ const dest = join(output, "app/src/main/jniLibs");
344
+ rmSync(dest, { force: true, recursive: true });
345
+ for (const { abi, source } of found) {
346
+ const abiDir = join(dest, abi);
347
+ mkdirSync(abiDir, { recursive: true });
348
+ cpSync(source, join(abiDir, "libcraft.so"));
349
+ }
350
+ return true;
351
+ }
96
352
  async function init(options) {
97
353
  const { name, packageName, output } = options;
98
354
  console.log(`
99
355
  \u26A1 Initializing Craft Android project: ${name}`);
100
356
  console.log(` Output: ${output}
101
357
  `);
102
- const finalPackageName = packageName || `com.craft.${name.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
358
+ const finalPackageName = packageName || `com.craft.${generatedPackageSegment(name)}`;
103
359
  const packagePath = finalPackageName.replace(/\./g, "/");
360
+ const config = {
361
+ ...DEFAULT_CONFIG,
362
+ ...options.config,
363
+ appName: name,
364
+ packageName: finalPackageName
365
+ };
366
+ if (config.enableBackgroundLocation)
367
+ config.enableGeolocation = true;
368
+ if (config.enableHealthConnect) {
369
+ config.minSdk = Math.max(config.minSdk ?? 26, 26);
370
+ config.compileSdk = Math.max(config.compileSdk ?? 36, 36);
371
+ }
372
+ normalizeAndroidNetworkConfig(config);
373
+ validateAndroidConfig(config);
374
+ if (config.enablePushNotifications && !config.googleServicesFile) {
375
+ throw new Error("Android push notifications require a googleServicesFile");
376
+ }
377
+ if (config.googleServicesFile)
378
+ validateGoogleServicesFile(config.googleServicesFile, finalPackageName);
379
+ if (config.appIconPath)
380
+ requireRegularFile(config.appIconPath, "App icon");
381
+ const appIconExtension = config.appIconPath ? extname(config.appIconPath).toLowerCase() : undefined;
382
+ if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
383
+ throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
384
+ }
104
385
  const dirs = [
105
386
  output,
106
387
  join(output, "app/src/main/java", packagePath),
@@ -110,32 +391,24 @@ async function init(options) {
110
391
  join(output, "app/src/main/assets"),
111
392
  join(output, "gradle/wrapper")
112
393
  ];
394
+ if (existsSync(output) && !statSync(output).isDirectory()) {
395
+ throw new Error(`Android project output must be a directory: ${output}`);
396
+ }
113
397
  for (const dir of dirs) {
114
398
  if (!existsSync(dir)) {
115
399
  mkdirSync(dir, { recursive: true });
116
400
  }
117
401
  }
118
- const config = {
119
- ...DEFAULT_CONFIG,
120
- appName: name,
121
- packageName: finalPackageName,
122
- ...options.config
123
- };
124
- if (config.enableBackgroundLocation)
125
- config.enableGeolocation = true;
126
- writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
127
- writeFileSync(join(output, "app/src/main/assets/craft.config.json"), JSON.stringify(config, null, 2));
402
+ writeAndroidConfig(output, config);
128
403
  const hasGoogleServices = Boolean(config.googleServicesFile);
129
404
  if (config.googleServicesFile) {
130
- if (!existsSync(config.googleServicesFile))
131
- throw new Error(`Google services file not found: ${config.googleServicesFile}`);
132
405
  cpSync(config.googleServicesFile, join(output, "app/google-services.json"));
133
406
  }
134
407
  const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, "MainActivity.kt.template"), "utf-8");
135
408
  const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
136
409
  writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
137
410
  const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
138
- const craftBridge = craftBridgeTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
411
+ const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
139
412
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
140
413
  && ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
141
414
  ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4204)
@@ -145,27 +418,26 @@ async function init(options) {
145
418
  val token = if (task.isSuccessful) task.result else null
146
419
  val callback = if (token.isNullOrBlank()) "window._craftPushReject" else "window._craftPushResolve"
147
420
  val payload = JSONObject.quote(token ?: task.exception?.message ?: "Firebase Cloud Messaging is not configured")
148
- webView.evaluateJavascript("$callback && $callback($payload)", null)
421
+ evaluatePromiseJavascript("$callback && $callback($payload)")
149
422
  }
150
423
  } catch (error: Exception) {
151
424
  val payload = JSONObject.quote(error.message ?: "Firebase Cloud Messaging is not configured")
152
- webView.evaluateJavascript("window._craftPushReject && window._craftPushReject($payload)", null)
425
+ evaluatePromiseJavascript("window._craftPushReject && window._craftPushReject($payload)")
153
426
  }
154
- }` : `activity.runOnUiThread {
155
- webView.evaluateJavascript(
156
- "window._craftPushReject && window._craftPushReject('Push notifications are disabled')",
157
- null
158
- )
159
- }`);
427
+ }` : `evaluatePromiseJavascript(
428
+ "window._craftPushReject && window._craftPushReject('Push notifications are disabled')"
429
+ )`);
160
430
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftBridge.kt"), craftBridge);
431
+ const craftNative = readFileSync(join(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
432
+ const nativeDir = join(output, "app/src/main/java/com/craft/runtime");
433
+ mkdirSync(nativeDir, { recursive: true });
434
+ writeFileSync(join(nativeDir, "CraftNative.kt"), craftNative);
435
+ const serviceTemplate = readFileSync(join(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
436
+ writeFileSync(join(nativeDir, "LocationRecordingService.kt"), serviceTemplate);
161
437
  const healthTemplate = readFileSync(join(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
162
438
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftHealthConnect.kt"), healthTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
163
- if (config.enableBackgroundLocation) {
164
- const serviceTemplate = readFileSync(join(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
165
- writeFileSync(join(output, "app/src/main/java", packagePath, "LocationRecordingService.kt"), serviceTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
166
- }
167
439
  const manifestTemplate = readFileSync(join(TEMPLATES_DIR, "AndroidManifest.xml.template"), "utf-8");
168
- const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name=".LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
440
+ const manifest = manifestTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{PERMISSIONS\}\}/g, renderAndroidPermissions(config)).replace(/\{\{USES_CLEARTEXT\}\}/g, config.devServerURL?.startsWith("http://") ? "true" : "false").replace(/\{\{DEEP_LINK_INTENT_FILTERS\}\}/g, renderAndroidDeepLinks(config)).replace(/\{\{BACKGROUND_SERVICE\}\}/g, config.enableBackgroundLocation ? ' <service android:name="com.craft.runtime.LocationRecordingService" android:exported="false" android:foregroundServiceType="location" android:stopWithTask="false" />' : "").replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect ? ` <queries>
169
441
  <package android:name="com.google.android.apps.healthdata" />
170
442
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
171
443
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -178,13 +450,13 @@ async function init(options) {
178
450
  const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
179
451
  writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
180
452
  const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
181
- const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, 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(Math.max(config.compileSdk || 36, config.enableHealthConnect ? 36 : 1))).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")
453
+ 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")
182
454
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "");
183
455
  writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
184
456
  const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
185
457
  writeFileSync(join(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
186
458
  const settingsTemplate = readFileSync(join(TEMPLATES_DIR, "settings.gradle.kts.template"), "utf-8");
187
- const settings = settingsTemplate.replace(/\{\{APP_NAME\}\}/g, name);
459
+ const settings = settingsTemplate.replace(/\{\{APP_NAME\}\}/g, escapeKotlinString(generatedGradleProjectName(name)));
188
460
  writeFileSync(join(output, "settings.gradle.kts"), settings);
189
461
  const gradleProperties = `org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
190
462
  android.useAndroidX=true
@@ -205,7 +477,7 @@ zipStorePath=wrapper/dists
205
477
  writeFileSync(join(output, "gradle/wrapper/gradle-wrapper.properties"), gradleWrapperProps);
206
478
  const stringsXml = `<?xml version="1.0" encoding="utf-8"?>
207
479
  <resources>
208
- <string name="app_name">${name}</string>
480
+ <string name="app_name">${escapeXml(name)}</string>
209
481
  </resources>
210
482
  `;
211
483
  writeFileSync(join(output, "app/src/main/res/values/strings.xml"), stringsXml);
@@ -229,9 +501,7 @@ zipStorePath=wrapper/dists
229
501
  </vector>
230
502
  `;
231
503
  if (config.appIconPath) {
232
- if (!existsSync(config.appIconPath))
233
- throw new Error(`App icon not found: ${config.appIconPath}`);
234
- cpSync(config.appIconPath, join(output, "app/src/main/res/drawable/craft_app_icon.png"));
504
+ cpSync(config.appIconPath, join(output, `app/src/main/res/drawable/craft_app_icon${appIconExtension}`));
235
505
  } else {
236
506
  writeFileSync(join(output, "app/src/main/res/drawable/craft_app_icon.xml"), appIconXml);
237
507
  }
@@ -265,7 +535,7 @@ zipStorePath=wrapper/dists
265
535
  <head>
266
536
  <meta charset="UTF-8">
267
537
  <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
268
- <title>${name}</title>
538
+ <title>${escapeXml(name)}</title>
269
539
  <style>
270
540
  * { margin: 0; padding: 0; box-sizing: border-box; }
271
541
  body {
@@ -285,7 +555,7 @@ zipStorePath=wrapper/dists
285
555
  </head>
286
556
  <body>
287
557
  <div class="container">
288
- <h1>\u26A1 ${name}</h1>
558
+ <h1>\u26A1 ${escapeXml(name)}</h1>
289
559
  <p>Built with Craft Android</p>
290
560
  <p class="ready" id="status">Waiting for Craft bridge...</p>
291
561
  </div>
@@ -298,6 +568,11 @@ zipStorePath=wrapper/dists
298
568
  </body>
299
569
  </html>`;
300
570
  writeFileSync(join(output, "app/src/main/assets/index.html"), placeholderHtml);
571
+ const runtimeDir = resolveRuntimeDir(options.runtimeDir);
572
+ if (runtimeDir) {
573
+ installRuntime(output, runtimeDir);
574
+ console.log(" Installed the Zig runtime from", runtimeDir);
575
+ }
301
576
  console.log("\u2705 Project initialized");
302
577
  console.log("");
303
578
  console.log("Next steps:");
@@ -316,18 +591,29 @@ async function build(options) {
316
591
  throw new Error(`No craft.config.json found in ${output}. Run 'craft android init' first.`);
317
592
  }
318
593
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
594
+ if (existsSync(join(output, "app/src/main/jniLibs"))) {
595
+ const runtimeDir = resolveRuntimeDir(options.runtimeDir);
596
+ if (runtimeDir) {
597
+ installRuntime(output, runtimeDir);
598
+ console.log(" Refreshed the Zig runtime from", runtimeDir);
599
+ } else {
600
+ console.log(" Keeping the Zig runtime installed at init (no runtime directory configured)");
601
+ }
602
+ }
319
603
  if (devServer) {
320
- config.devServerURL = devServer;
321
- config.trustedOrigins = [...new Set([...config.trustedOrigins ?? [], new URL(devServer).origin])];
322
- writeFileSync(configPath, JSON.stringify(config, null, 2));
323
- writeFileSync(join(output, "app/src/main/assets/craft.config.json"), JSON.stringify(config, null, 2));
604
+ const url = androidWebUrl(devServer, "Android dev server URL");
605
+ config.devServerURL = url.toString();
606
+ config.trustedOrigins = [...new Set([
607
+ ...(config.trustedOrigins ?? []).map((value) => androidWebUrl(value, "Android trusted origin").origin),
608
+ url.origin
609
+ ])];
610
+ writeAndroidConfig(output, config);
324
611
  console.log(` Dev server: ${devServer}`);
325
612
  }
326
613
  if (htmlPath) {
327
614
  syncAndroidWebAssets(htmlPath, output);
328
615
  config.hasBundledFallback = Boolean(devServer);
329
- writeFileSync(configPath, JSON.stringify(config, null, 2));
330
- writeFileSync(join(output, "app/src/main/assets/craft.config.json"), JSON.stringify(config, null, 2));
616
+ writeAndroidConfig(output, config);
331
617
  console.log(` Synced: ${htmlPath} \u2192 assets/`);
332
618
  }
333
619
  if (!compile)
@@ -400,9 +686,11 @@ async function run(options) {
400
686
  export {
401
687
  syncAndroidWebAssets,
402
688
  run,
689
+ resolveRuntimeDir,
403
690
  renderAndroidPermissions,
404
691
  renderAndroidDeepLinks,
405
692
  open,
693
+ installRuntime,
406
694
  init,
407
695
  build
408
696
  };
@@ -0,0 +1,3 @@
1
+ declare const ANDROID_PROMISE_RUNTIME = "\nif (window.__craftRejectPendingPromises) {\n window.__craftRejectPendingPromises('Android bridge reinitialized');\n}\nif (window.__craftRejectPermissionRequests) {\n window.__craftRejectPermissionRequests('Android bridge reinitialized');\n}\nwindow.__craftPromiseRuntimeClosed = false;\nwindow.__craftPendingPromises = Object.create(null);\nwindow.__craftPromise = function(channel, resolveName, rejectName, invoke, timeoutMs, timeoutError) {\n if (window.__craftPromiseRuntimeClosed) {\n return Promise.reject(new Error('Android bridge is closed'));\n }\n if (window.__craftPendingPromises[channel]) {\n return Promise.reject(new Error('A '.concat(channel, ' request is already in progress')));\n }\n\n return new Promise(function(resolve, reject) {\n var entry = {settled: false, timer: null, settle: null};\n var resolveCallback;\n var rejectCallback;\n\n entry.settle = function(succeeded, value) {\n if (entry.settled || window.__craftPendingPromises[channel] !== entry) return;\n entry.settled = true;\n if (entry.timer !== null) clearTimeout(entry.timer);\n if (window[resolveName] === resolveCallback) window[resolveName] = null;\n if (window[rejectName] === rejectCallback) window[rejectName] = null;\n delete window.__craftPendingPromises[channel];\n if (succeeded) resolve(value);\n else reject(value);\n };\n\n resolveCallback = function(value) {\n entry.settle(true, value);\n };\n rejectCallback = function(error) {\n entry.settle(false, error);\n };\n window[resolveName] = resolveCallback;\n window[rejectName] = rejectCallback;\n window.__craftPendingPromises[channel] = entry;\n\n if (timeoutMs > 0) {\n entry.timer = setTimeout(function() {\n entry.settle(false, timeoutError || new Error(channel.concat(' request timed out')));\n }, timeoutMs);\n }\n\n try {\n invoke();\n }\n catch (error) {\n entry.settle(false, error);\n }\n });\n};\n\nwindow.__craftRejectPendingPromises = function(message) {\n window.__craftPromiseRuntimeClosed = true;\n Object.keys(window.__craftPendingPromises).forEach(function(channel) {\n var entry = window.__craftPendingPromises[channel];\n if (entry) entry.settle(false, new Error(message || 'Android bridge closed'));\n });\n};\n";
2
+ export declare function renderAndroidPromiseRuntime(indent?: string): string;
3
+ export { ANDROID_PROMISE_RUNTIME };