html2apk 0.1.0
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/README.md +472 -0
- package/bin/html2apk-desktop.js +23 -0
- package/bin/html2apk.js +19 -0
- package/examples/minimal/app.json +27 -0
- package/examples/minimal/dist/MeuApp-1.0.0-debug.apk +0 -0
- package/examples/minimal/index.html +41 -0
- package/html2apk.png +0 -0
- package/index.js +3 -0
- package/package.json +76 -0
- package/src/android/README.md +7 -0
- package/src/bridge/install-bridge.js +16 -0
- package/src/cli/index.js +163 -0
- package/src/cordova/apk-finder.js +45 -0
- package/src/cordova/config-xml.js +110 -0
- package/src/cordova/project.js +56 -0
- package/src/core/build-apk.js +189 -0
- package/src/core/config.js +99 -0
- package/src/core/defaults.js +37 -0
- package/src/core/validation.js +58 -0
- package/src/desktop/main.js +522 -0
- package/src/desktop/preload.js +30 -0
- package/src/desktop/renderer/index.html +323 -0
- package/src/desktop/renderer/renderer.js +1074 -0
- package/src/desktop/renderer/styles.css +1208 -0
- package/src/index.js +12 -0
- package/src/runtime-manager/doctor.js +164 -0
- package/src/runtime-manager/index.js +190 -0
- package/src/templates/cordova-plugin-html2apk-bridge/package.json +16 -0
- package/src/templates/cordova-plugin-html2apk-bridge/plugin.xml +39 -0
- package/src/templates/cordova-plugin-html2apk-bridge/src/android/BootReceiver.java +20 -0
- package/src/templates/cordova-plugin-html2apk-bridge/src/android/Html2ApkBridge.java +375 -0
- package/src/templates/cordova-plugin-html2apk-bridge/src/android/NotificationReceiver.java +112 -0
- package/src/templates/cordova-plugin-html2apk-bridge/src/android/NotificationStore.java +91 -0
- package/src/templates/cordova-plugin-html2apk-bridge/www/html2apk-bridge.js +129 -0
- package/src/utils/command-runner.js +124 -0
- package/src/utils/fs-extra.js +111 -0
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { buildApk } = require("../core/build-apk");
|
|
6
|
+
const { runDoctor, formatDoctorReport } = require("../runtime-manager/doctor");
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`html2apk 0.1.0
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
html2apk init
|
|
13
|
+
html2apk build [--release] [--debug] [--mode fullscreen|standalone] [--android-platform android@15.0.0]
|
|
14
|
+
html2apk doctor
|
|
15
|
+
|
|
16
|
+
The current working directory is always treated as the user app root.`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseBuildArgs(args) {
|
|
20
|
+
const options = {};
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
23
|
+
const arg = args[index];
|
|
24
|
+
if (arg === "--release") {
|
|
25
|
+
options.release = true;
|
|
26
|
+
} else if (arg === "--debug") {
|
|
27
|
+
options.debug = true;
|
|
28
|
+
} else if (arg === "--mode") {
|
|
29
|
+
options.mode = args[index + 1];
|
|
30
|
+
index += 1;
|
|
31
|
+
} else if (arg === "--entry-file") {
|
|
32
|
+
options.entryFile = args[index + 1];
|
|
33
|
+
index += 1;
|
|
34
|
+
} else if (arg === "--web-root") {
|
|
35
|
+
options.webRoot = args[index + 1];
|
|
36
|
+
index += 1;
|
|
37
|
+
} else if (arg === "--app-name") {
|
|
38
|
+
options.appName = args[index + 1];
|
|
39
|
+
index += 1;
|
|
40
|
+
} else if (arg === "--package-id") {
|
|
41
|
+
options.packageId = args[index + 1];
|
|
42
|
+
index += 1;
|
|
43
|
+
} else if (arg === "--android-platform") {
|
|
44
|
+
options.androidPlatform = args[index + 1];
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return options;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function packageSegment(value) {
|
|
53
|
+
return String(value || "meuapp")
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9_]+/g, "")
|
|
56
|
+
.replace(/^[^a-z]+/, "") || "meuapp";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createPlaceholderConfig(projectName = "MeuApp") {
|
|
60
|
+
const appName = projectName || "MeuApp";
|
|
61
|
+
return {
|
|
62
|
+
_editMe: "Edite os campos abaixo e rode: html2apk doctor && html2apk build",
|
|
63
|
+
appName,
|
|
64
|
+
packageId: `com.seuapp.${packageSegment(appName)}`,
|
|
65
|
+
version: "1.0.0",
|
|
66
|
+
mode: "fullscreen",
|
|
67
|
+
icon: "",
|
|
68
|
+
splash: "",
|
|
69
|
+
permissions: [
|
|
70
|
+
"INTERNET",
|
|
71
|
+
"POST_NOTIFICATIONS",
|
|
72
|
+
"VIBRATE"
|
|
73
|
+
],
|
|
74
|
+
plugins: [],
|
|
75
|
+
release: false,
|
|
76
|
+
androidPlatform: "android@15.0.0",
|
|
77
|
+
keystore: {
|
|
78
|
+
path: "",
|
|
79
|
+
alias: "",
|
|
80
|
+
storePassword: "",
|
|
81
|
+
keyPassword: ""
|
|
82
|
+
},
|
|
83
|
+
debug: false,
|
|
84
|
+
entryFile: "index.html",
|
|
85
|
+
webRoot: ".",
|
|
86
|
+
files: []
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function initProject() {
|
|
91
|
+
const appJsonPath = path.resolve(process.cwd(), "app.json");
|
|
92
|
+
const indexPath = path.resolve(process.cwd(), "index.html");
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await fs.access(appJsonPath);
|
|
96
|
+
} catch {
|
|
97
|
+
const appName = path.basename(process.cwd()) || "MeuApp";
|
|
98
|
+
const config = createPlaceholderConfig(appName);
|
|
99
|
+
await fs.writeFile(appJsonPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await fs.access(indexPath);
|
|
104
|
+
} catch {
|
|
105
|
+
await fs.writeFile(indexPath, `<!doctype html>
|
|
106
|
+
<html lang="pt-BR">
|
|
107
|
+
<head>
|
|
108
|
+
<meta charset="utf-8">
|
|
109
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
110
|
+
<title>html2apk app</title>
|
|
111
|
+
</head>
|
|
112
|
+
<body>
|
|
113
|
+
<h1>html2apk</h1>
|
|
114
|
+
<button onclick="toast('Ola do Android')">Toast</button>
|
|
115
|
+
<button onclick="notificar('Notificacao enviada')">Notificar</button>
|
|
116
|
+
<script src="cordova.js"></script>
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|
|
119
|
+
`, "utf8");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log("Created app.json and index.html when missing.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runCli(argv) {
|
|
126
|
+
const command = argv[0];
|
|
127
|
+
|
|
128
|
+
if (!command || command === "--help" || command === "-h") {
|
|
129
|
+
printHelp();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (command === "init") {
|
|
134
|
+
await initProject();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (command === "doctor") {
|
|
139
|
+
const report = await runDoctor();
|
|
140
|
+
console.log(formatDoctorReport(report));
|
|
141
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (command === "build") {
|
|
146
|
+
const options = parseBuildArgs(argv.slice(1));
|
|
147
|
+
const result = await buildApk(options);
|
|
148
|
+
console.log(`APK generated: ${result.apkPath}`);
|
|
149
|
+
if (result.buildDir) {
|
|
150
|
+
console.log(`Build directory kept: ${result.buildDir}`);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
printHelp();
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
createPlaceholderConfig,
|
|
161
|
+
runCli,
|
|
162
|
+
parseBuildArgs
|
|
163
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
async function walk(dirPath, results = []) {
|
|
7
|
+
let entries = [];
|
|
8
|
+
try {
|
|
9
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
10
|
+
} catch {
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
await walk(fullPath, results);
|
|
18
|
+
} else if (entry.isFile() && entry.name.endsWith(".apk")) {
|
|
19
|
+
const stat = await fs.stat(fullPath);
|
|
20
|
+
results.push({ path: fullPath, mtimeMs: stat.mtimeMs });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function findApk(buildDir, options) {
|
|
28
|
+
const outputRoot = path.join(buildDir, "platforms", "android", "app", "build", "outputs", "apk");
|
|
29
|
+
const apks = await walk(outputRoot);
|
|
30
|
+
|
|
31
|
+
if (apks.length === 0) {
|
|
32
|
+
throw new Error("Cordova build finished, but no APK was found.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const expectedFlavor = options.release ? "release" : "debug";
|
|
36
|
+
const preferred = apks
|
|
37
|
+
.filter((item) => item.path.toLowerCase().includes(expectedFlavor))
|
|
38
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
|
|
39
|
+
|
|
40
|
+
return (preferred || apks.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]).path;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
findApk
|
|
45
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
function xmlEscape(value) {
|
|
7
|
+
return String(value)
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, """);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function androidPermissionName(permission) {
|
|
15
|
+
const value = String(permission).trim();
|
|
16
|
+
return value.includes(".") ? value : `android.permission.${value}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderPermissions(permissions) {
|
|
20
|
+
if (!permissions.length) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const items = permissions
|
|
25
|
+
.map((permission) => ` <uses-permission android:name="${xmlEscape(androidPermissionName(permission))}" />`)
|
|
26
|
+
.join("\n");
|
|
27
|
+
|
|
28
|
+
return ` <config-file target="AndroidManifest.xml" parent="/manifest">
|
|
29
|
+
${items}
|
|
30
|
+
</config-file>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderIcon(icon) {
|
|
34
|
+
if (!icon) {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
return ` <icon src="${xmlEscape(icon)}" />`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderPreference(name, value) {
|
|
41
|
+
if (value === undefined || value === null || value === "") {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return ` <preference name="${xmlEscape(name)}" value="${xmlEscape(value)}" />`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderAndroidSplashPreferences(options) {
|
|
49
|
+
const splashIcon = options.androidSplashScreenAnimatedIcon || options.splash || options.icon;
|
|
50
|
+
if (!splashIcon) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
renderPreference("AndroidWindowSplashScreenAnimatedIcon", splashIcon),
|
|
56
|
+
renderPreference("AndroidWindowSplashScreenBackground", options.splashBackgroundColor || options.backgroundColor || "#FFFFFF"),
|
|
57
|
+
renderPreference("AndroidWindowSplashScreenAnimationDuration", options.splashAnimationDuration || "200")
|
|
58
|
+
].filter(Boolean).join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderConfigXml(options) {
|
|
62
|
+
const fullscreen = options.mode === "fullscreen" ? "true" : "false";
|
|
63
|
+
const permissions = renderPermissions(options.permissions || []);
|
|
64
|
+
const icon = renderIcon(options.icon);
|
|
65
|
+
const splashPreferences = renderAndroidSplashPreferences(options);
|
|
66
|
+
const platformItems = [permissions, icon, splashPreferences].filter(Boolean).join("\n");
|
|
67
|
+
|
|
68
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
69
|
+
<widget id="${xmlEscape(options.packageId)}"
|
|
70
|
+
version="${xmlEscape(options.version)}"
|
|
71
|
+
xmlns="http://www.w3.org/ns/widgets"
|
|
72
|
+
xmlns:cdv="http://cordova.apache.org/ns/1.0"
|
|
73
|
+
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
74
|
+
<name>${xmlEscape(options.appName)}</name>
|
|
75
|
+
<description>Generated by html2apk.</description>
|
|
76
|
+
<author email="support@example.com" href="https://example.com">html2apk</author>
|
|
77
|
+
|
|
78
|
+
<content src="${xmlEscape(options.entryFile || "index.html")}" />
|
|
79
|
+
<access origin="*" />
|
|
80
|
+
<allow-navigation href="*" />
|
|
81
|
+
<allow-intent href="http://*/*" />
|
|
82
|
+
<allow-intent href="https://*/*" />
|
|
83
|
+
<allow-intent href="tel:*" />
|
|
84
|
+
<allow-intent href="sms:*" />
|
|
85
|
+
<allow-intent href="mailto:*" />
|
|
86
|
+
<allow-intent href="geo:*" />
|
|
87
|
+
|
|
88
|
+
<preference name="Fullscreen" value="${fullscreen}" />
|
|
89
|
+
<preference name="AndroidXEnabled" value="true" />
|
|
90
|
+
<preference name="AndroidPersistentFileLocation" value="Compatibility" />
|
|
91
|
+
<preference name="AndroidLaunchMode" value="singleTop" />
|
|
92
|
+
<preference name="DisallowOverscroll" value="true" />
|
|
93
|
+
<preference name="GradlePluginKotlinEnabled" value="true" />
|
|
94
|
+
|
|
95
|
+
<platform name="android">
|
|
96
|
+
${platformItems || " <!-- Extra Android options are generated here. -->"}
|
|
97
|
+
</platform>
|
|
98
|
+
</widget>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function writeConfigXml(configPath, options) {
|
|
103
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
104
|
+
await fs.writeFile(configPath, renderConfigXml(options), "utf8");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
renderConfigXml,
|
|
109
|
+
writeConfigXml
|
|
110
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ANDROID_PLATFORM = "android@15.0.0";
|
|
4
|
+
|
|
5
|
+
async function createCordovaProject(buildDir, options, runner) {
|
|
6
|
+
await runner.run("cordova", [
|
|
7
|
+
"create",
|
|
8
|
+
buildDir,
|
|
9
|
+
options.packageId,
|
|
10
|
+
options.appName,
|
|
11
|
+
"--no-telemetry"
|
|
12
|
+
]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function addCordovaPlugin(buildDir, plugin, runner) {
|
|
16
|
+
await runner.run("cordova", ["plugin", "add", plugin, "--no-telemetry"], {
|
|
17
|
+
cwd: buildDir
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function androidPlatformSpec(options = {}) {
|
|
22
|
+
return options.androidPlatform || DEFAULT_ANDROID_PLATFORM;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function addAndroidPlatform(buildDir, options, runner) {
|
|
26
|
+
await runner.run("cordova", ["platform", "add", androidPlatformSpec(options), "--no-telemetry"], {
|
|
27
|
+
cwd: buildDir
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function buildAndroid(buildDir, options, buildJsonPath, runner) {
|
|
32
|
+
const args = ["build", "android", "--no-telemetry"];
|
|
33
|
+
|
|
34
|
+
if (options.release) {
|
|
35
|
+
args.push("--release");
|
|
36
|
+
} else {
|
|
37
|
+
args.push("--debug");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (buildJsonPath) {
|
|
41
|
+
args.push("--buildConfig", buildJsonPath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await runner.run("cordova", args, {
|
|
45
|
+
cwd: buildDir,
|
|
46
|
+
pipeOutput: options.debug
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
DEFAULT_ANDROID_PLATFORM,
|
|
52
|
+
createCordovaProject,
|
|
53
|
+
addCordovaPlugin,
|
|
54
|
+
addAndroidPlatform,
|
|
55
|
+
buildAndroid
|
|
56
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { resolveBuildOptions } = require("./config");
|
|
7
|
+
const { validateEntryFile, validateRequiredOptions } = require("./validation");
|
|
8
|
+
const { createCordovaProject, addAndroidPlatform, buildAndroid, addCordovaPlugin } = require("../cordova/project");
|
|
9
|
+
const { writeConfigXml } = require("../cordova/config-xml");
|
|
10
|
+
const { findApk } = require("../cordova/apk-finder");
|
|
11
|
+
const { copyWebAssets, ensureDir, removePath, copyFile } = require("../utils/fs-extra");
|
|
12
|
+
const { createCommandRunner } = require("../utils/command-runner");
|
|
13
|
+
const { installBridgePlugin } = require("../bridge/install-bridge");
|
|
14
|
+
const { getRuntimeEnvironment } = require("../runtime-manager");
|
|
15
|
+
|
|
16
|
+
function isRemoteAsset(assetPath) {
|
|
17
|
+
return /^https?:\/\//i.test(String(assetPath || ""));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toBuildAssetPath(buildDir, assetPath) {
|
|
21
|
+
if (!assetPath || isRemoteAsset(assetPath)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return path.resolve(buildDir, assetPath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isInside(parent, child) {
|
|
29
|
+
const relative = path.relative(parent, child);
|
|
30
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toCordovaPath(value) {
|
|
34
|
+
return String(value).replace(/\\/g, "/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function copyCordovaAsset(projectRoot, buildDir, assetPath, assetName) {
|
|
38
|
+
if (!assetPath || isRemoteAsset(assetPath)) {
|
|
39
|
+
return assetPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const assetText = String(assetPath);
|
|
43
|
+
let source;
|
|
44
|
+
let destination;
|
|
45
|
+
let cordovaPath;
|
|
46
|
+
|
|
47
|
+
if (path.isAbsolute(assetText)) {
|
|
48
|
+
source = assetText;
|
|
49
|
+
const extension = path.extname(source) || ".png";
|
|
50
|
+
cordovaPath = path.join("res", "html2apk", `${assetName}${extension}`);
|
|
51
|
+
destination = path.join(buildDir, cordovaPath);
|
|
52
|
+
} else {
|
|
53
|
+
source = path.resolve(projectRoot, assetText);
|
|
54
|
+
if (!isInside(projectRoot, source)) {
|
|
55
|
+
throw new Error(`Asset path must stay inside the project root: ${assetPath}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
destination = path.resolve(buildDir, assetText);
|
|
59
|
+
cordovaPath = assetText;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!isInside(buildDir, destination)) {
|
|
63
|
+
throw new Error(`Asset path must stay inside the Cordova project: ${assetPath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await copyFile(source, destination);
|
|
67
|
+
return toCordovaPath(cordovaPath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function createBuildJson(buildDir, options, projectRoot) {
|
|
71
|
+
if (!options.release || !options.keystore || !options.keystore.path) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const storeFile = path.resolve(projectRoot, options.keystore.path);
|
|
76
|
+
const buildJsonPath = path.join(buildDir, "build.json");
|
|
77
|
+
const release = {
|
|
78
|
+
packageType: "apk",
|
|
79
|
+
keystore: storeFile,
|
|
80
|
+
storePassword: options.keystore.storePassword || options.keystore.password,
|
|
81
|
+
alias: options.keystore.alias,
|
|
82
|
+
password: options.keystore.keyPassword || options.keystore.password,
|
|
83
|
+
keystoreType: options.keystore.type
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
Object.keys(release).forEach((key) => {
|
|
87
|
+
if (release[key] === undefined || release[key] === null) {
|
|
88
|
+
delete release[key];
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await fs.writeFile(buildJsonPath, JSON.stringify({ android: { release } }, null, 2));
|
|
93
|
+
return buildJsonPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function outputApkName(options) {
|
|
97
|
+
const safeName = String(options.appName || "app").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
98
|
+
const flavor = options.release ? "release" : "debug";
|
|
99
|
+
return `${safeName}-${options.version}-${flavor}.apk`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function buildApk(overrides = {}) {
|
|
103
|
+
const onLog = typeof overrides.onLog === "function" ? overrides.onLog : null;
|
|
104
|
+
const { projectRoot, configPath, options } = await resolveBuildOptions(overrides);
|
|
105
|
+
validateRequiredOptions(options);
|
|
106
|
+
const { webRoot } = await validateEntryFile(projectRoot, options);
|
|
107
|
+
|
|
108
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "html2apk-"));
|
|
109
|
+
const buildDir = path.join(tempRoot, "cordova-project");
|
|
110
|
+
const logs = [];
|
|
111
|
+
const runtime = getRuntimeEnvironment();
|
|
112
|
+
const runner = createCommandRunner({ logs, env: runtime.env, onLog });
|
|
113
|
+
let tempCleaned = false;
|
|
114
|
+
|
|
115
|
+
function log(line) {
|
|
116
|
+
logs.push(line);
|
|
117
|
+
if (onLog) {
|
|
118
|
+
onLog(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
log(`Project root: ${projectRoot}`);
|
|
124
|
+
log(configPath ? `Config: ${configPath}` : "Config: defaults only");
|
|
125
|
+
if (runtime.androidSdk) {
|
|
126
|
+
log(`Android SDK: ${runtime.androidSdk}`);
|
|
127
|
+
}
|
|
128
|
+
if (runtime.javaHome) {
|
|
129
|
+
log(`JAVA_HOME: ${runtime.javaHome}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await createCordovaProject(buildDir, options, runner);
|
|
133
|
+
const cordovaOptions = { ...options };
|
|
134
|
+
cordovaOptions.icon = await copyCordovaAsset(projectRoot, buildDir, options.icon, "icon");
|
|
135
|
+
cordovaOptions.splash = await copyCordovaAsset(projectRoot, buildDir, options.splash || options.icon, "splash");
|
|
136
|
+
cordovaOptions.androidSplashScreenAnimatedIcon = toBuildAssetPath(buildDir, cordovaOptions.splash);
|
|
137
|
+
await writeConfigXml(path.join(buildDir, "config.xml"), cordovaOptions);
|
|
138
|
+
await copyWebAssets(webRoot, path.join(buildDir, "www"), options, projectRoot);
|
|
139
|
+
|
|
140
|
+
const bridgePluginPath = await installBridgePlugin(buildDir);
|
|
141
|
+
await addCordovaPlugin(buildDir, bridgePluginPath, runner);
|
|
142
|
+
|
|
143
|
+
for (const plugin of options.plugins) {
|
|
144
|
+
await addCordovaPlugin(buildDir, plugin, runner);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await addAndroidPlatform(buildDir, options, runner);
|
|
148
|
+
const buildJsonPath = await createBuildJson(buildDir, options, projectRoot);
|
|
149
|
+
await buildAndroid(buildDir, options, buildJsonPath, runner);
|
|
150
|
+
|
|
151
|
+
const apkPathInBuild = await findApk(buildDir, options);
|
|
152
|
+
const outputDir = path.resolve(projectRoot, options.outputDir || "dist");
|
|
153
|
+
await ensureDir(outputDir);
|
|
154
|
+
|
|
155
|
+
const apkPath = path.join(outputDir, outputApkName(options));
|
|
156
|
+
await copyFile(apkPathInBuild, apkPath);
|
|
157
|
+
|
|
158
|
+
if (!options.debug) {
|
|
159
|
+
await removePath(tempRoot);
|
|
160
|
+
tempCleaned = true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
apkPath,
|
|
165
|
+
buildDir: options.debug ? buildDir : null,
|
|
166
|
+
logs,
|
|
167
|
+
status: "success",
|
|
168
|
+
tempCleaned
|
|
169
|
+
};
|
|
170
|
+
} catch (error) {
|
|
171
|
+
log(`Error: ${error.message}`);
|
|
172
|
+
if (!options.debug) {
|
|
173
|
+
await removePath(tempRoot).catch(() => {});
|
|
174
|
+
tempCleaned = true;
|
|
175
|
+
} else {
|
|
176
|
+
log(`Debug mode enabled. Temporary build kept at: ${buildDir}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
error.logs = logs;
|
|
180
|
+
error.buildDir = options.debug ? buildDir : null;
|
|
181
|
+
error.tempCleaned = tempCleaned;
|
|
182
|
+
error.status = "error";
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
buildApk
|
|
189
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { createDefaultOptions } = require("./defaults");
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILES = ["app.json", "config.json"];
|
|
8
|
+
|
|
9
|
+
async function pathExists(filePath) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(filePath);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function loadProjectConfig(projectRoot = process.cwd()) {
|
|
19
|
+
for (const fileName of CONFIG_FILES) {
|
|
20
|
+
const configPath = path.join(projectRoot, fileName);
|
|
21
|
+
if (await pathExists(configPath)) {
|
|
22
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
23
|
+
try {
|
|
24
|
+
return {
|
|
25
|
+
config: JSON.parse(raw),
|
|
26
|
+
configPath
|
|
27
|
+
};
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`Invalid JSON in ${fileName}: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
config: {},
|
|
36
|
+
configPath: null
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mergeDeep(base, next) {
|
|
41
|
+
const output = { ...base };
|
|
42
|
+
|
|
43
|
+
for (const [key, value] of Object.entries(next || {})) {
|
|
44
|
+
if (
|
|
45
|
+
value &&
|
|
46
|
+
typeof value === "object" &&
|
|
47
|
+
!Array.isArray(value) &&
|
|
48
|
+
base[key] &&
|
|
49
|
+
typeof base[key] === "object" &&
|
|
50
|
+
!Array.isArray(base[key])
|
|
51
|
+
) {
|
|
52
|
+
output[key] = mergeDeep(base[key], value);
|
|
53
|
+
} else if (value !== undefined) {
|
|
54
|
+
output[key] = value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return output;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeOptions(options) {
|
|
62
|
+
const normalized = { ...options };
|
|
63
|
+
|
|
64
|
+
normalized.mode = normalized.mode === "fullscreen" ? "fullscreen" : "standalone";
|
|
65
|
+
normalized.debug = Boolean(normalized.debug);
|
|
66
|
+
normalized.release = Boolean(normalized.release);
|
|
67
|
+
normalized.permissions = Array.isArray(normalized.permissions)
|
|
68
|
+
? normalized.permissions.filter(Boolean)
|
|
69
|
+
: [];
|
|
70
|
+
normalized.plugins = Array.isArray(normalized.plugins)
|
|
71
|
+
? normalized.plugins.filter(Boolean)
|
|
72
|
+
: [];
|
|
73
|
+
normalized.entryFile = normalized.entryFile || "index.html";
|
|
74
|
+
normalized.webRoot = normalized.webRoot || ".";
|
|
75
|
+
|
|
76
|
+
return normalized;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function resolveBuildOptions(overrides = {}) {
|
|
80
|
+
const projectRoot = path.resolve(overrides.projectRoot || process.cwd());
|
|
81
|
+
const { config, configPath } = await loadProjectConfig(projectRoot);
|
|
82
|
+
const defaults = createDefaultOptions(projectRoot);
|
|
83
|
+
const merged = mergeDeep(mergeDeep(defaults, config), overrides);
|
|
84
|
+
delete merged.projectRoot;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
projectRoot,
|
|
88
|
+
configPath,
|
|
89
|
+
options: normalizeOptions(merged)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
CONFIG_FILES,
|
|
95
|
+
loadProjectConfig,
|
|
96
|
+
resolveBuildOptions,
|
|
97
|
+
mergeDeep,
|
|
98
|
+
normalizeOptions
|
|
99
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
function toPackageSegment(value) {
|
|
6
|
+
return String(value || "app")
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^a-z0-9_]+/g, "")
|
|
9
|
+
.replace(/^[^a-z]+/, "") || "app";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createDefaultOptions(projectRoot) {
|
|
13
|
+
const appName = path.basename(projectRoot || process.cwd()) || "Html2ApkApp";
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
appName,
|
|
17
|
+
packageId: `com.html2apk.${toPackageSegment(appName)}`,
|
|
18
|
+
version: "1.0.0",
|
|
19
|
+
mode: "standalone",
|
|
20
|
+
debug: false,
|
|
21
|
+
icon: null,
|
|
22
|
+
splash: null,
|
|
23
|
+
permissions: ["INTERNET"],
|
|
24
|
+
plugins: [],
|
|
25
|
+
release: false,
|
|
26
|
+
keystore: null,
|
|
27
|
+
androidPlatform: "android@15.0.0",
|
|
28
|
+
files: null,
|
|
29
|
+
entryFile: "index.html",
|
|
30
|
+
webRoot: ".",
|
|
31
|
+
outputDir: "dist"
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
createDefaultOptions
|
|
37
|
+
};
|