craft-native 0.0.89 → 0.0.91

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 (29) hide show
  1. package/dist/android/src/index.js +285 -44
  2. package/dist/android/src/promise-runtime.d.ts +3 -0
  3. package/dist/android/templates/CraftBridge.kt.template +1951 -1193
  4. package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
  5. package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
  6. package/dist/android/templates/CraftNative.kt.template +2231 -0
  7. package/dist/android/templates/LocationRecordingService.kt.template +34 -9
  8. package/dist/android/templates/MainActivity.kt.template +25 -2
  9. package/dist/android/templates/proguard-rules.pro.template +4 -1
  10. package/dist/android/templates/test-bridges.html +10 -33
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/ios-advanced.d.ts +8 -5
  13. package/dist/api/live-activity-handle.d.ts +6 -0
  14. package/dist/api/mobile.d.ts +13 -5
  15. package/dist/api/window.d.ts +2 -0
  16. package/dist/cli.js +404 -128
  17. package/dist/index.cjs +65 -17
  18. package/dist/index.js +65 -17
  19. package/dist/ios/src/index.js +22 -4
  20. package/dist/ios/templates/CraftApp.swift +473 -60
  21. package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
  22. package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
  23. package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
  24. package/dist/ios/templates/project.yml.template +10 -4
  25. package/dist/mobile.js +36 -13
  26. package/dist/scaffold-version.d.ts +5 -0
  27. package/package.json +1 -1
  28. package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
  29. package/dist/android/templates/CraftWidgetProvider.kt.template +0 -246
@@ -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"));
@@ -99,8 +324,33 @@ async function init(options) {
99
324
  \u26A1 Initializing Craft Android project: ${name}`);
100
325
  console.log(` Output: ${output}
101
326
  `);
102
- const finalPackageName = packageName || `com.craft.${name.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
327
+ const finalPackageName = packageName || `com.craft.${generatedPackageSegment(name)}`;
103
328
  const packagePath = finalPackageName.replace(/\./g, "/");
329
+ const config = {
330
+ ...DEFAULT_CONFIG,
331
+ ...options.config,
332
+ appName: name,
333
+ packageName: finalPackageName
334
+ };
335
+ if (config.enableBackgroundLocation)
336
+ config.enableGeolocation = true;
337
+ if (config.enableHealthConnect) {
338
+ config.minSdk = Math.max(config.minSdk ?? 26, 26);
339
+ config.compileSdk = Math.max(config.compileSdk ?? 36, 36);
340
+ }
341
+ normalizeAndroidNetworkConfig(config);
342
+ validateAndroidConfig(config);
343
+ if (config.enablePushNotifications && !config.googleServicesFile) {
344
+ throw new Error("Android push notifications require a googleServicesFile");
345
+ }
346
+ if (config.googleServicesFile)
347
+ validateGoogleServicesFile(config.googleServicesFile, finalPackageName);
348
+ if (config.appIconPath)
349
+ requireRegularFile(config.appIconPath, "App icon");
350
+ const appIconExtension = config.appIconPath ? extname(config.appIconPath).toLowerCase() : undefined;
351
+ if (config.appIconPath && ![".gif", ".jpg", ".png", ".webp"].includes(appIconExtension ?? "")) {
352
+ throw new Error(`Unsupported Android app icon format: ${appIconExtension || "(none)"}`);
353
+ }
104
354
  const dirs = [
105
355
  output,
106
356
  join(output, "app/src/main/java", packagePath),
@@ -110,32 +360,24 @@ async function init(options) {
110
360
  join(output, "app/src/main/assets"),
111
361
  join(output, "gradle/wrapper")
112
362
  ];
363
+ if (existsSync(output) && !statSync(output).isDirectory()) {
364
+ throw new Error(`Android project output must be a directory: ${output}`);
365
+ }
113
366
  for (const dir of dirs) {
114
367
  if (!existsSync(dir)) {
115
368
  mkdirSync(dir, { recursive: true });
116
369
  }
117
370
  }
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));
371
+ writeAndroidConfig(output, config);
128
372
  const hasGoogleServices = Boolean(config.googleServicesFile);
129
373
  if (config.googleServicesFile) {
130
- if (!existsSync(config.googleServicesFile))
131
- throw new Error(`Google services file not found: ${config.googleServicesFile}`);
132
374
  cpSync(config.googleServicesFile, join(output, "app/google-services.json"));
133
375
  }
134
376
  const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, "MainActivity.kt.template"), "utf-8");
135
377
  const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
136
378
  writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
137
379
  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 {
380
+ 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
381
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
140
382
  && ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
141
383
  ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4204)
@@ -145,27 +387,26 @@ async function init(options) {
145
387
  val token = if (task.isSuccessful) task.result else null
146
388
  val callback = if (token.isNullOrBlank()) "window._craftPushReject" else "window._craftPushResolve"
147
389
  val payload = JSONObject.quote(token ?: task.exception?.message ?: "Firebase Cloud Messaging is not configured")
148
- webView.evaluateJavascript("$callback && $callback($payload)", null)
390
+ evaluatePromiseJavascript("$callback && $callback($payload)")
149
391
  }
150
392
  } catch (error: Exception) {
151
393
  val payload = JSONObject.quote(error.message ?: "Firebase Cloud Messaging is not configured")
152
- webView.evaluateJavascript("window._craftPushReject && window._craftPushReject($payload)", null)
394
+ evaluatePromiseJavascript("window._craftPushReject && window._craftPushReject($payload)")
153
395
  }
154
- }` : `activity.runOnUiThread {
155
- webView.evaluateJavascript(
156
- "window._craftPushReject && window._craftPushReject('Push notifications are disabled')",
157
- null
158
- )
159
- }`);
396
+ }` : `evaluatePromiseJavascript(
397
+ "window._craftPushReject && window._craftPushReject('Push notifications are disabled')"
398
+ )`);
160
399
  writeFileSync(join(output, "app/src/main/java", packagePath, "CraftBridge.kt"), craftBridge);
400
+ const craftNative = readFileSync(join(TEMPLATES_DIR, "CraftNative.kt.template"), "utf-8");
401
+ const nativeDir = join(output, "app/src/main/java/com/craft/runtime");
402
+ mkdirSync(nativeDir, { recursive: true });
403
+ writeFileSync(join(nativeDir, "CraftNative.kt"), craftNative);
404
+ const serviceTemplate = readFileSync(join(TEMPLATES_DIR, "LocationRecordingService.kt.template"), "utf-8");
405
+ writeFileSync(join(nativeDir, "LocationRecordingService.kt"), serviceTemplate);
161
406
  const healthTemplate = readFileSync(join(TEMPLATES_DIR, config.enableHealthConnect ? "CraftHealthConnect.kt.template" : "CraftHealthConnectStub.kt.template"), "utf-8");
162
407
  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
408
  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>
409
+ 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
410
  <package android:name="com.google.android.apps.healthdata" />
170
411
  </queries>` : "").replace(/\{\{HEALTH_CONNECT_RATIONALE\}\}/g, config.enableHealthConnect ? ` <intent-filter>
171
412
  <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
@@ -178,13 +419,13 @@ async function init(options) {
178
419
  const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
179
420
  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
421
  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")
422
+ 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
423
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "");
183
424
  writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
184
425
  const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
185
426
  writeFileSync(join(output, "app/proguard-rules.pro"), proguardTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
186
427
  const settingsTemplate = readFileSync(join(TEMPLATES_DIR, "settings.gradle.kts.template"), "utf-8");
187
- const settings = settingsTemplate.replace(/\{\{APP_NAME\}\}/g, name);
428
+ const settings = settingsTemplate.replace(/\{\{APP_NAME\}\}/g, escapeKotlinString(generatedGradleProjectName(name)));
188
429
  writeFileSync(join(output, "settings.gradle.kts"), settings);
189
430
  const gradleProperties = `org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
190
431
  android.useAndroidX=true
@@ -205,7 +446,7 @@ zipStorePath=wrapper/dists
205
446
  writeFileSync(join(output, "gradle/wrapper/gradle-wrapper.properties"), gradleWrapperProps);
206
447
  const stringsXml = `<?xml version="1.0" encoding="utf-8"?>
207
448
  <resources>
208
- <string name="app_name">${name}</string>
449
+ <string name="app_name">${escapeXml(name)}</string>
209
450
  </resources>
210
451
  `;
211
452
  writeFileSync(join(output, "app/src/main/res/values/strings.xml"), stringsXml);
@@ -229,9 +470,7 @@ zipStorePath=wrapper/dists
229
470
  </vector>
230
471
  `;
231
472
  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"));
473
+ cpSync(config.appIconPath, join(output, `app/src/main/res/drawable/craft_app_icon${appIconExtension}`));
235
474
  } else {
236
475
  writeFileSync(join(output, "app/src/main/res/drawable/craft_app_icon.xml"), appIconXml);
237
476
  }
@@ -265,7 +504,7 @@ zipStorePath=wrapper/dists
265
504
  <head>
266
505
  <meta charset="UTF-8">
267
506
  <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>
507
+ <title>${escapeXml(name)}</title>
269
508
  <style>
270
509
  * { margin: 0; padding: 0; box-sizing: border-box; }
271
510
  body {
@@ -285,7 +524,7 @@ zipStorePath=wrapper/dists
285
524
  </head>
286
525
  <body>
287
526
  <div class="container">
288
- <h1>\u26A1 ${name}</h1>
527
+ <h1>\u26A1 ${escapeXml(name)}</h1>
289
528
  <p>Built with Craft Android</p>
290
529
  <p class="ready" id="status">Waiting for Craft bridge...</p>
291
530
  </div>
@@ -317,17 +556,19 @@ async function build(options) {
317
556
  }
318
557
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
319
558
  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));
559
+ const url = androidWebUrl(devServer, "Android dev server URL");
560
+ config.devServerURL = url.toString();
561
+ config.trustedOrigins = [...new Set([
562
+ ...(config.trustedOrigins ?? []).map((value) => androidWebUrl(value, "Android trusted origin").origin),
563
+ url.origin
564
+ ])];
565
+ writeAndroidConfig(output, config);
324
566
  console.log(` Dev server: ${devServer}`);
325
567
  }
326
568
  if (htmlPath) {
327
569
  syncAndroidWebAssets(htmlPath, output);
328
570
  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));
571
+ writeAndroidConfig(output, config);
331
572
  console.log(` Synced: ${htmlPath} \u2192 assets/`);
332
573
  }
333
574
  if (!compile)
@@ -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 };