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.
Files changed (36) hide show
  1. package/README.md +472 -0
  2. package/bin/html2apk-desktop.js +23 -0
  3. package/bin/html2apk.js +19 -0
  4. package/examples/minimal/app.json +27 -0
  5. package/examples/minimal/dist/MeuApp-1.0.0-debug.apk +0 -0
  6. package/examples/minimal/index.html +41 -0
  7. package/html2apk.png +0 -0
  8. package/index.js +3 -0
  9. package/package.json +76 -0
  10. package/src/android/README.md +7 -0
  11. package/src/bridge/install-bridge.js +16 -0
  12. package/src/cli/index.js +163 -0
  13. package/src/cordova/apk-finder.js +45 -0
  14. package/src/cordova/config-xml.js +110 -0
  15. package/src/cordova/project.js +56 -0
  16. package/src/core/build-apk.js +189 -0
  17. package/src/core/config.js +99 -0
  18. package/src/core/defaults.js +37 -0
  19. package/src/core/validation.js +58 -0
  20. package/src/desktop/main.js +522 -0
  21. package/src/desktop/preload.js +30 -0
  22. package/src/desktop/renderer/index.html +323 -0
  23. package/src/desktop/renderer/renderer.js +1074 -0
  24. package/src/desktop/renderer/styles.css +1208 -0
  25. package/src/index.js +12 -0
  26. package/src/runtime-manager/doctor.js +164 -0
  27. package/src/runtime-manager/index.js +190 -0
  28. package/src/templates/cordova-plugin-html2apk-bridge/package.json +16 -0
  29. package/src/templates/cordova-plugin-html2apk-bridge/plugin.xml +39 -0
  30. package/src/templates/cordova-plugin-html2apk-bridge/src/android/BootReceiver.java +20 -0
  31. package/src/templates/cordova-plugin-html2apk-bridge/src/android/Html2ApkBridge.java +375 -0
  32. package/src/templates/cordova-plugin-html2apk-bridge/src/android/NotificationReceiver.java +112 -0
  33. package/src/templates/cordova-plugin-html2apk-bridge/src/android/NotificationStore.java +91 -0
  34. package/src/templates/cordova-plugin-html2apk-bridge/www/html2apk-bridge.js +129 -0
  35. package/src/utils/command-runner.js +124 -0
  36. package/src/utils/fs-extra.js +111 -0
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs/promises");
4
+ const path = require("path");
5
+
6
+ async function exists(filePath) {
7
+ try {
8
+ await fs.access(filePath);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function assertInside(parent, child, label) {
16
+ const relative = path.relative(parent, child);
17
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
18
+ throw new Error(`${label} must stay inside the project root.`);
19
+ }
20
+ }
21
+
22
+ async function validateEntryFile(projectRoot, options) {
23
+ const webRoot = path.resolve(projectRoot, options.webRoot || ".");
24
+ assertInside(projectRoot, webRoot, "webRoot");
25
+
26
+ const entryFile = options.entryFile || "index.html";
27
+ const entryPath = path.resolve(webRoot, entryFile);
28
+ assertInside(webRoot, entryPath, "entryFile");
29
+
30
+ if (!(await exists(entryPath))) {
31
+ throw new Error(`Entry file not found: ${path.relative(projectRoot, entryPath)}`);
32
+ }
33
+
34
+ return {
35
+ webRoot,
36
+ entryPath
37
+ };
38
+ }
39
+
40
+ function validatePackageId(packageId) {
41
+ const pattern = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
42
+ if (!pattern.test(packageId)) {
43
+ throw new Error(`Invalid packageId "${packageId}". Example: com.company.app`);
44
+ }
45
+ }
46
+
47
+ function validateRequiredOptions(options) {
48
+ if (!options.appName) {
49
+ throw new Error("appName is required.");
50
+ }
51
+ validatePackageId(options.packageId);
52
+ }
53
+
54
+ module.exports = {
55
+ validateEntryFile,
56
+ validateRequiredOptions,
57
+ assertInside
58
+ };
@@ -0,0 +1,522 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs/promises");
4
+ const path = require("path");
5
+ const { spawn } = require("child_process");
6
+ const { app, BrowserWindow, dialog, ipcMain, screen, shell } = require("electron");
7
+ const { buildApk } = require("../core/build-apk");
8
+ const {
9
+ REQUIRED_ANDROID_BUILD_TOOLS,
10
+ REQUIRED_ANDROID_PLATFORM,
11
+ getRuntimeEnvironment
12
+ } = require("../runtime-manager");
13
+ const { runDoctor, formatDoctorReport } = require("../runtime-manager/doctor");
14
+
15
+ let mainWindow = null;
16
+ const smokeTest = process.env.HTML2APK_DESKTOP_SMOKE === "1";
17
+ const WINDOW_BACKGROUNDS = {
18
+ light: "#f7fbff",
19
+ dark: "#10141b"
20
+ };
21
+
22
+ app.commandLine.appendSwitch("disable-crash-reporter");
23
+
24
+ function rootPath(...parts) {
25
+ return path.resolve(__dirname, "..", "..", ...parts);
26
+ }
27
+
28
+ function iconPath() {
29
+ return rootPath("html2apk.png");
30
+ }
31
+
32
+ function animateWindowIn(window) {
33
+ let opacity = 0;
34
+ window.setOpacity(opacity);
35
+ window.show();
36
+ window.focus();
37
+
38
+ const timer = setInterval(() => {
39
+ opacity = Math.min(1, opacity + 0.08);
40
+ window.setOpacity(opacity);
41
+ if (opacity >= 1) {
42
+ clearInterval(timer);
43
+ }
44
+ }, 16);
45
+ }
46
+
47
+ function createWindow() {
48
+ const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize;
49
+ const width = Math.min(1280, Math.max(860, Math.floor(screenWidth * 0.92)));
50
+ const height = Math.min(820, Math.max(620, Math.floor(screenHeight * 0.9)));
51
+
52
+ mainWindow = new BrowserWindow({
53
+ width,
54
+ height,
55
+ minWidth: Math.min(860, width),
56
+ minHeight: Math.min(620, height),
57
+ center: true,
58
+ show: false,
59
+ frame: false,
60
+ title: "html2apk",
61
+ icon: iconPath(),
62
+ backgroundColor: WINDOW_BACKGROUNDS.light,
63
+ webPreferences: {
64
+ preload: path.join(__dirname, "preload.js"),
65
+ contextIsolation: true,
66
+ nodeIntegration: false,
67
+ sandbox: false
68
+ }
69
+ });
70
+
71
+ mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
72
+ if (smokeTest) {
73
+ mainWindow.webContents.once("did-finish-load", () => {
74
+ setTimeout(() => app.quit(), 500);
75
+ });
76
+ }
77
+ mainWindow.once("ready-to-show", () => animateWindowIn(mainWindow));
78
+ }
79
+
80
+ async function pathExists(filePath) {
81
+ try {
82
+ await fs.access(filePath);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ async function readJsonIfExists(filePath) {
90
+ try {
91
+ const raw = await fs.readFile(filePath, "utf8");
92
+ return JSON.parse(raw);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ async function inspectProject(projectRoot) {
99
+ if (!projectRoot) {
100
+ throw new Error("Project folder is required.");
101
+ }
102
+
103
+ const stat = await fs.stat(projectRoot);
104
+ if (!stat.isDirectory()) {
105
+ throw new Error("Please choose a folder, not a file.");
106
+ }
107
+
108
+ const appJsonPath = path.join(projectRoot, "app.json");
109
+ const configJsonPath = path.join(projectRoot, "config.json");
110
+ const indexPath = path.join(projectRoot, "index.html");
111
+ const appConfig = await readJsonIfExists(appJsonPath);
112
+ const fallbackConfig = appConfig ? null : await readJsonIfExists(configJsonPath);
113
+ const config = appConfig || fallbackConfig || {};
114
+ const webRoot = path.resolve(projectRoot, config.webRoot || ".");
115
+ const entryPath = path.resolve(webRoot, config.entryFile || "index.html");
116
+
117
+ return {
118
+ projectRoot,
119
+ name: path.basename(projectRoot),
120
+ hasAppJson: await pathExists(appJsonPath),
121
+ hasConfigJson: await pathExists(configJsonPath),
122
+ hasRootIndex: await pathExists(indexPath),
123
+ hasEntryFile: await pathExists(entryPath),
124
+ entryPath,
125
+ config,
126
+ distPath: path.join(projectRoot, "dist")
127
+ };
128
+ }
129
+
130
+ function cleanBuildOptions(options = {}) {
131
+ const output = {
132
+ projectRoot: options.projectRoot,
133
+ debug: Boolean(options.debug),
134
+ release: Boolean(options.release)
135
+ };
136
+
137
+ for (const key of ["mode", "appName", "packageId", "version", "icon", "androidPlatform"]) {
138
+ if (options[key]) {
139
+ output[key] = options[key];
140
+ }
141
+ }
142
+
143
+ return output;
144
+ }
145
+
146
+ function quoteForCmd(value) {
147
+ const text = String(value);
148
+ if (!text.length) {
149
+ return "\"\"";
150
+ }
151
+
152
+ if (/^[a-zA-Z0-9_@./:\\=+\-;]+$/.test(text)) {
153
+ return text;
154
+ }
155
+
156
+ return `"${text.replace(/(["^&|<>])/g, "^$1")}"`;
157
+ }
158
+
159
+ function createSpawnSpec(command, args) {
160
+ const needsCmd = process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
161
+ if (!needsCmd) {
162
+ return { command, args };
163
+ }
164
+
165
+ return {
166
+ command: "cmd.exe",
167
+ args: ["/d", "/s", "/c", [quoteForCmd(command), ...args.map(quoteForCmd)].join(" ")]
168
+ };
169
+ }
170
+
171
+ function sdkManagerPath(runtime) {
172
+ if (!runtime.cmdlineTools) {
173
+ return null;
174
+ }
175
+
176
+ return path.join(runtime.cmdlineTools, process.platform === "win32" ? "sdkmanager.bat" : "sdkmanager");
177
+ }
178
+
179
+ function androidPackageArgs() {
180
+ return [
181
+ "platform-tools",
182
+ `platforms;${REQUIRED_ANDROID_PLATFORM}`,
183
+ `build-tools;${REQUIRED_ANDROID_BUILD_TOOLS}`,
184
+ "cmdline-tools;latest"
185
+ ];
186
+ }
187
+
188
+ function sendInstallLog(sender, line, kind = "raw") {
189
+ sender.send("install:log", {
190
+ line,
191
+ kind,
192
+ time: new Date().toISOString()
193
+ });
194
+ }
195
+
196
+ async function findAndroidCli() {
197
+ if (process.platform !== "win32") {
198
+ return null;
199
+ }
200
+
201
+ const candidates = [
202
+ process.env.LOCALAPPDATA
203
+ ? path.join(
204
+ process.env.LOCALAPPDATA,
205
+ "Microsoft",
206
+ "WinGet",
207
+ "Packages",
208
+ "Google.AndroidCLI_Microsoft.Winget.Source_8wekyb3d8bbwe",
209
+ "android.exe"
210
+ )
211
+ : null,
212
+ "android.exe"
213
+ ].filter(Boolean);
214
+
215
+ for (const candidate of candidates) {
216
+ if (candidate === "android.exe" || await pathExists(candidate)) {
217
+ return candidate;
218
+ }
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ function runInstallCommand(sender, command, args, options = {}) {
225
+ const pretty = `${command} ${args.join(" ")}`.trim();
226
+ const spawnSpec = createSpawnSpec(command, args);
227
+ const env = options.env || process.env;
228
+
229
+ sendInstallLog(sender, `$ ${pretty}`, "system");
230
+
231
+ return new Promise((resolve, reject) => {
232
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
233
+ env,
234
+ stdio: ["pipe", "pipe", "pipe"],
235
+ shell: false,
236
+ windowsHide: true
237
+ });
238
+
239
+ let stdout = "";
240
+ let stderr = "";
241
+
242
+ function acceptLicenseIfPrompted(text) {
243
+ if (!options.acceptLicenses || !child.stdin.writable) {
244
+ return;
245
+ }
246
+
247
+ if (/(accept|license|licen[cç]a|y\/n|y\/N|\[y\/n\]|\(y\/N\))/i.test(text)) {
248
+ child.stdin.write("y\n");
249
+ }
250
+ }
251
+
252
+ child.stdout.on("data", (chunk) => {
253
+ const text = chunk.toString();
254
+ stdout += text;
255
+ sendInstallLog(sender, text);
256
+ acceptLicenseIfPrompted(text);
257
+ });
258
+
259
+ child.stderr.on("data", (chunk) => {
260
+ const text = chunk.toString();
261
+ stderr += text;
262
+ sendInstallLog(sender, text, "error");
263
+ acceptLicenseIfPrompted(text);
264
+ });
265
+
266
+ child.on("error", (error) => {
267
+ reject(new Error(`Failed to run "${pretty}": ${error.message}`));
268
+ });
269
+
270
+ child.on("close", (code) => {
271
+ if (code === 0) {
272
+ resolve({ code, stdout, stderr });
273
+ return;
274
+ }
275
+
276
+ const error = new Error(`Command failed (${code}): ${pretty}`);
277
+ error.code = code;
278
+ error.stdout = stdout;
279
+ error.stderr = stderr;
280
+ reject(error);
281
+ });
282
+ });
283
+ }
284
+
285
+ async function askAndroidInstallPermission() {
286
+ const result = await dialog.showMessageBox(mainWindow, {
287
+ type: "question",
288
+ buttons: ["Instalar", "Cancelar"],
289
+ defaultId: 0,
290
+ cancelId: 1,
291
+ title: "html2apk",
292
+ message: "Instalar pacotes Android necessarios?",
293
+ detail: [
294
+ "O html2apk precisa do Android SDK com platform-tools, Android Platform 36, Build Tools 36.0.0 e command line tools.",
295
+ "Ao continuar, o instalador pode baixar pacotes e aceitar as licencas Android necessarias para o build."
296
+ ].join("\n\n")
297
+ });
298
+
299
+ return result.response === 0;
300
+ }
301
+
302
+ async function installWithSdkManager(sender, sdkManager, runtime) {
303
+ const args = androidPackageArgs();
304
+ await runInstallCommand(sender, sdkManager, ["--licenses"], {
305
+ env: runtime.env,
306
+ acceptLicenses: true
307
+ }).catch((error) => {
308
+ sendInstallLog(sender, error.message, "error");
309
+ });
310
+
311
+ await runInstallCommand(sender, sdkManager, args, {
312
+ env: runtime.env,
313
+ acceptLicenses: true
314
+ });
315
+ }
316
+
317
+ async function installWithWingetAndroidCli(sender) {
318
+ if (process.platform !== "win32") {
319
+ throw new Error("Automatic Android CLI installation is only available on Windows in this desktop app.");
320
+ }
321
+
322
+ try {
323
+ await runInstallCommand(sender, "winget", [
324
+ "install",
325
+ "--id",
326
+ "Google.AndroidCLI",
327
+ "-e",
328
+ "--accept-package-agreements",
329
+ "--accept-source-agreements"
330
+ ]);
331
+ } catch (error) {
332
+ sendInstallLog(sender, error.message, "error");
333
+ }
334
+
335
+ const androidCli = await findAndroidCli();
336
+ if (!androidCli) {
337
+ throw new Error("Android CLI was not found after the Winget step.");
338
+ }
339
+
340
+ await runInstallCommand(sender, androidCli, ["sdk", "install", ...androidPackageArgs()], {
341
+ acceptLicenses: true
342
+ });
343
+ }
344
+
345
+ app.whenReady().then(() => {
346
+ createWindow();
347
+
348
+ app.on("activate", () => {
349
+ if (BrowserWindow.getAllWindows().length === 0) {
350
+ createWindow();
351
+ }
352
+ });
353
+ });
354
+
355
+ app.on("window-all-closed", () => {
356
+ if (process.platform !== "darwin") {
357
+ app.quit();
358
+ }
359
+ });
360
+
361
+ ipcMain.handle("app:info", () => ({
362
+ name: "html2apk",
363
+ version: app.getVersion(),
364
+ credit: "Dev Caio Multiversando",
365
+ iconPath: iconPath()
366
+ }));
367
+
368
+ ipcMain.handle("window:minimize", () => {
369
+ BrowserWindow.getFocusedWindow()?.minimize();
370
+ });
371
+
372
+ ipcMain.handle("window:toggle-maximize", () => {
373
+ const window = BrowserWindow.getFocusedWindow();
374
+ if (!window) {
375
+ return false;
376
+ }
377
+
378
+ if (window.isMaximized()) {
379
+ window.unmaximize();
380
+ return false;
381
+ }
382
+
383
+ window.maximize();
384
+ return true;
385
+ });
386
+
387
+ ipcMain.handle("window:close", () => {
388
+ BrowserWindow.getFocusedWindow()?.close();
389
+ });
390
+
391
+ ipcMain.handle("window:set-theme", (_event, theme) => {
392
+ const nextTheme = theme === "dark" ? "dark" : "light";
393
+ BrowserWindow.getFocusedWindow()?.setBackgroundColor(WINDOW_BACKGROUNDS[nextTheme]);
394
+ return nextTheme;
395
+ });
396
+
397
+ ipcMain.handle("dialog:select-folder", async () => {
398
+ const result = await dialog.showOpenDialog(mainWindow, {
399
+ title: "Select project folder",
400
+ properties: ["openDirectory"]
401
+ });
402
+
403
+ if (result.canceled || !result.filePaths.length) {
404
+ return null;
405
+ }
406
+
407
+ return inspectProject(result.filePaths[0]);
408
+ });
409
+
410
+ ipcMain.handle("dialog:select-icon", async () => {
411
+ const result = await dialog.showOpenDialog(mainWindow, {
412
+ title: "Select app icon",
413
+ properties: ["openFile"],
414
+ filters: [
415
+ { name: "PNG images", extensions: ["png"] },
416
+ { name: "All files", extensions: ["*"] }
417
+ ]
418
+ });
419
+
420
+ if (result.canceled || !result.filePaths.length) {
421
+ return null;
422
+ }
423
+
424
+ return result.filePaths[0];
425
+ });
426
+
427
+ ipcMain.handle("project:inspect", async (_event, projectRoot) => {
428
+ return inspectProject(projectRoot);
429
+ });
430
+
431
+ ipcMain.handle("doctor:run", async (_event, projectRoot) => {
432
+ const report = await runDoctor({ projectRoot });
433
+ return {
434
+ ok: report.ok,
435
+ report,
436
+ text: formatDoctorReport(report)
437
+ };
438
+ });
439
+
440
+ ipcMain.handle("install:android-requirements", async (event) => {
441
+ const approved = await askAndroidInstallPermission();
442
+ if (!approved) {
443
+ return {
444
+ ok: false,
445
+ canceled: true,
446
+ message: "Installation canceled by user."
447
+ };
448
+ }
449
+
450
+ const sender = event.sender;
451
+
452
+ try {
453
+ const runtime = getRuntimeEnvironment();
454
+ const sdkManager = sdkManagerPath(runtime);
455
+ sendInstallLog(sender, "Preparing Android SDK requirements.", "system");
456
+
457
+ if (sdkManager && await pathExists(sdkManager)) {
458
+ await installWithSdkManager(sender, sdkManager, runtime);
459
+ } else {
460
+ await installWithWingetAndroidCli(sender);
461
+ }
462
+
463
+ return {
464
+ ok: true,
465
+ message: "Android SDK requirements installed."
466
+ };
467
+ } catch (error) {
468
+ sendInstallLog(sender, error.message, "error");
469
+ return {
470
+ ok: false,
471
+ message: error.message
472
+ };
473
+ }
474
+ });
475
+
476
+ ipcMain.handle("build:run", async (event, options) => {
477
+ const buildOptions = cleanBuildOptions(options);
478
+ const sendLog = (line, kind = "raw") => {
479
+ event.sender.send("build:log", {
480
+ line,
481
+ kind,
482
+ time: new Date().toISOString()
483
+ });
484
+ };
485
+
486
+ try {
487
+ sendLog("Starting html2apk build.", "system");
488
+ const result = await buildApk({
489
+ ...buildOptions,
490
+ onLog: (line) => sendLog(line)
491
+ });
492
+ sendLog(`APK generated: ${result.apkPath}`, "success");
493
+ return {
494
+ ok: true,
495
+ result
496
+ };
497
+ } catch (error) {
498
+ sendLog(error.message, "error");
499
+ return {
500
+ ok: false,
501
+ message: error.message,
502
+ logs: error.logs || [],
503
+ buildDir: error.buildDir || null
504
+ };
505
+ }
506
+ });
507
+
508
+ ipcMain.handle("shell:open-path", async (_event, targetPath) => {
509
+ if (!targetPath) {
510
+ return false;
511
+ }
512
+ await shell.openPath(targetPath);
513
+ return true;
514
+ });
515
+
516
+ ipcMain.handle("shell:show-item", async (_event, targetPath) => {
517
+ if (!targetPath) {
518
+ return false;
519
+ }
520
+ shell.showItemInFolder(targetPath);
521
+ return true;
522
+ });
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+
3
+ const { contextBridge, ipcRenderer, webUtils } = require("electron");
4
+
5
+ contextBridge.exposeInMainWorld("html2apkDesktop", {
6
+ appInfo: () => ipcRenderer.invoke("app:info"),
7
+ selectFolder: () => ipcRenderer.invoke("dialog:select-folder"),
8
+ selectIcon: () => ipcRenderer.invoke("dialog:select-icon"),
9
+ inspectProject: (projectRoot) => ipcRenderer.invoke("project:inspect", projectRoot),
10
+ runDoctor: (projectRoot) => ipcRenderer.invoke("doctor:run", projectRoot),
11
+ installAndroidRequirements: () => ipcRenderer.invoke("install:android-requirements"),
12
+ runBuild: (options) => ipcRenderer.invoke("build:run", options),
13
+ openPath: (targetPath) => ipcRenderer.invoke("shell:open-path", targetPath),
14
+ showItem: (targetPath) => ipcRenderer.invoke("shell:show-item", targetPath),
15
+ minimizeWindow: () => ipcRenderer.invoke("window:minimize"),
16
+ toggleMaximizeWindow: () => ipcRenderer.invoke("window:toggle-maximize"),
17
+ closeWindow: () => ipcRenderer.invoke("window:close"),
18
+ setWindowTheme: (theme) => ipcRenderer.invoke("window:set-theme", theme),
19
+ pathForFile: (file) => webUtils.getPathForFile(file),
20
+ onBuildLog: (listener) => {
21
+ const handler = (_event, payload) => listener(payload);
22
+ ipcRenderer.on("build:log", handler);
23
+ return () => ipcRenderer.removeListener("build:log", handler);
24
+ },
25
+ onInstallLog: (listener) => {
26
+ const handler = (_event, payload) => listener(payload);
27
+ ipcRenderer.on("install:log", handler);
28
+ return () => ipcRenderer.removeListener("install:log", handler);
29
+ }
30
+ });