craft-native 0.0.90 → 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.
- package/dist/android/src/index.js +285 -44
- package/dist/android/src/promise-runtime.d.ts +3 -0
- package/dist/android/templates/CraftBridge.kt.template +1951 -1193
- package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
- package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
- package/dist/android/templates/CraftNative.kt.template +2231 -0
- package/dist/android/templates/LocationRecordingService.kt.template +34 -9
- package/dist/android/templates/MainActivity.kt.template +25 -2
- package/dist/android/templates/proguard-rules.pro.template +4 -1
- package/dist/android/templates/test-bridges.html +10 -33
- package/dist/api/index.d.ts +1 -1
- package/dist/api/ios-advanced.d.ts +8 -5
- package/dist/api/live-activity-handle.d.ts +6 -0
- package/dist/api/mobile.d.ts +13 -5
- package/dist/api/window.d.ts +2 -0
- package/dist/cli.js +404 -128
- package/dist/index.cjs +65 -17
- package/dist/index.js +65 -17
- package/dist/ios/src/index.js +22 -4
- package/dist/ios/templates/CraftApp.swift +473 -60
- package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
- package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
- package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
- package/dist/ios/templates/project.yml.template +10 -4
- package/dist/mobile.js +36 -13
- package/dist/scaffold-version.d.ts +5 -0
- package/package.json +1 -1
- package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
- 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
+
evaluatePromiseJavascript("window._craftPushReject && window._craftPushReject($payload)")
|
|
153
395
|
}
|
|
154
|
-
}` : `
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
config.
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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 };
|