html2apk 0.3.0 → 0.4.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 CHANGED
@@ -252,6 +252,7 @@ Campos principais:
252
252
  | `orientation` | `default`, `vertical`, `horizontal`, `portrait` ou `landscape`. |
253
253
  | `minSdkVersion` | Versao minima do Android em API level. Padrao: `24`. |
254
254
  | `themeColor` | Cor base do tema/splash Android, em hexadecimal. Tambem vira fallback do modo automatico. |
255
+ | `icon` | Icone PNG do app. Se ficar vazio, o html2apk usa automaticamente o icone padrao da ferramenta. |
255
256
  | `themeMode` | `fixed` usa a cor fixa. `auto` adapta as barras Android a cor visivel na tela do APK. Tambem aceita `theme: "auto"`. |
256
257
  | `oneSignalAppId` | Opcional. App ID publico do OneSignal para push remoto. Nao coloque REST API Key no APK. |
257
258
  | `deepLinks` | URLs que podem abrir o APK, como `meuapp://rota` ou `https://meusite.com/produto/1`. |
@@ -355,6 +356,8 @@ Retorno:
355
356
 
356
357
  A v0.1 instala um plugin Cordova local com uma API global simples para recursos Android. Todas as funcoes retornam `Promise`, exceto os ouvintes `aoEvento`/atalhos, que retornam uma funcao para cancelar a escuta.
357
358
 
359
+ O html2apk injeta `cordova.js` automaticamente no HTML inicial do APK. Se uma funcao nativa for chamada antes do `deviceready`, a bridge espera o Android ficar pronto antes de executar.
360
+
358
361
  Cada funcao em portugues tambem tem alias em ingles. A interface grafica mostra a sintaxe PT-BR quando o idioma esta em portugues e mostra a sintaxe em ingles quando o usuario troca o idioma para English.
359
362
 
360
363
  Exemplos de aliases:
@@ -403,15 +406,15 @@ Como tratar retornos:
403
406
 
404
407
  - Use `await` dentro de `try/catch` quando a acao depender de Android, permissao ou outro app instalado.
405
408
  - Seletores de arquivo podem retornar `null`, array vazio ou objeto vazio quando o usuario cancela.
406
- - APIs de permissao retornam objetos com `granted`; se vier `false`, desative o recurso ou mostre uma etapa de permissao.
409
+ - Funcoes que precisam de permissao pedem automaticamente quando sao chamadas, como `lanterna()`, `notificar()` e `ouvirMic()`.
410
+ - Se o Android nao puder mostrar o pop-up de permissao, o html2apk abre a tela correta de configuracoes e retorna `settingsOpened: true`.
411
+ - APIs manuais de permissao continuam existindo para quem quer explicar antes; elas retornam objetos com `granted`, `requiresSettings` e `settingsOpened`.
407
412
  - Eventos retornam uma funcao de cancelamento; guarde essa funcao e chame quando a tela/componente sair.
408
413
  - Medidas de memoria, armazenamento e apps abertos sao diagnosticas. Android moderno pode limitar dados de outros apps por privacidade.
409
414
 
410
415
  No seu JavaScript do app:
411
416
 
412
417
  ```js
413
- await solicitarPermissaoNotificacoes();
414
-
415
418
  toast("Mensagem");
416
419
  vibrar(250);
417
420
 
@@ -532,19 +535,20 @@ O retorno de arquivos tem este formato:
532
535
  Microfone:
533
536
 
534
537
  ```js
535
- await solicitarPermissaoMicrofone();
536
-
537
- await ouvirMic();
538
-
539
- // ... depois, quando quiser parar
540
- const audio = await pararMic();
541
- const audioUrl = `data:${audio.mimeType};base64,${audio.base64}`;
542
-
543
- const player = new Audio(audioUrl);
544
- player.play();
538
+ const inicio = await ouvirMic();
539
+ if (inicio.settingsOpened) {
540
+ console.log("Libere Microfone nas configuracoes e tente novamente");
541
+ } else {
542
+ // ... depois, quando quiser parar
543
+ const audio = await pararMic();
544
+ const audioUrl = `data:${audio.mimeType};base64,${audio.base64}`;
545
+
546
+ const player = new Audio(audioUrl);
547
+ player.play();
548
+ }
545
549
  ```
546
550
 
547
- `ouvirMic()` comeca a gravar e tambem pede permissao se ela ainda nao foi concedida. Para uma experiencia mais clara, prefira chamar `solicitarPermissaoMicrofone()` antes e explicar ao usuario por que o app precisa do microfone.
551
+ `ouvirMic()` comeca a gravar e tambem pede permissao se ela ainda nao foi concedida. Se a permissao estiver bloqueada pelo Android, a tela de configuracoes do app e aberta automaticamente e o retorno traz `settingsOpened: true`.
548
552
 
549
553
  `pararMic()` encerra a gravacao e retorna:
550
554
 
@@ -563,7 +567,6 @@ Para tratar o retorno, use `mimeType` e `base64` juntos em um Data URL quando qu
563
567
  Lanterna, tela, clipboard e intents:
564
568
 
565
569
  ```js
566
- await solicitarPermissaoCamera();
567
570
  const lanternaStatus = await lanterna(true);
568
571
  await alternarLanterna();
569
572
 
@@ -667,9 +670,7 @@ Permissoes e alarmes:
667
670
 
668
671
  ```js
669
672
  const status = await statusPermissaoNotificacoes();
670
- if (!status.granted) {
671
- await solicitarPermissaoNotificacoes();
672
- }
673
+ console.log(status.granted);
673
674
 
674
675
  const podeUsarAlarmeExato = await podeAgendarNotificacaoExata();
675
676
  if (!podeUsarAlarmeExato) {
@@ -677,7 +678,7 @@ if (!podeUsarAlarmeExato) {
677
678
  }
678
679
  ```
679
680
 
680
- A bridge cria canal de notificacao, solicita/consulta `POST_NOTIFICATIONS` no Android 13+, abre o app com payload quando a notificacao e clicada, persiste notificacoes agendadas e tenta reagendar apos reboot ou update do app.
681
+ A bridge cria canal de notificacao, solicita `POST_NOTIFICATIONS` automaticamente quando `notificar()`/`agendarNotificacao()` precisam, abre configuracoes se o Android bloquear o pop-up, abre o app com payload quando a notificacao e clicada, persiste notificacoes agendadas e tenta reagendar apos reboot ou update do app. Se voce usar `exato: true`/`exact: true` em uma notificacao agendada e o Android exigir liberacao manual de alarme exato, o html2apk abre essa tela automaticamente.
681
682
 
682
683
  ## Problemas Comuns
683
684
 
@@ -748,11 +749,15 @@ Fluxo da interface:
748
749
  1. Arraste ou escolha a pasta do projeto.
749
750
  2. O app mostra `verificando ambiente` antes de liberar as proximas etapas.
750
751
  3. Se faltarem pacotes do Android SDK, ele pede permissao e tenta baixar/instalar mostrando logs.
751
- 4. Preencha as configuracoes obrigatorias: nome do app, Package ID, versao, modo e icone PNG.
752
- 5. Revise, clique em `Gerar APK` e acompanhe a barra de progresso.
752
+ 4. Preencha as configuracoes obrigatorias: nome do app, Package ID, versao e modo. Se voce nao escolher icone, o html2apk usa o icone padrao da ferramenta.
753
+ 5. Revise e clique em `Gerar APK` para salvar o APK em `dist`, ou `Testar no USB` para gerar debug, instalar e abrir direto em um celular conectado.
753
754
  6. Ao concluir, a tela final mostra o APK gerado e botoes para abrir a pasta `dist` ou localizar o arquivo.
754
755
 
755
- O PNG escolhido e usado como icone do aplicativo e tambem como imagem da tela inicial do Android, evitando o splash padrao do Cordova.
756
+ O PNG escolhido e usado como icone do aplicativo e tambem como imagem da tela inicial do Android, evitando o splash padrao do Cordova. Quando nenhum PNG e escolhido, o `html2apk.png` da propria ferramenta entra como fallback.
757
+
758
+ Depois que a pasta foi escolhida, a interface acompanha mudancas nela automaticamente. Edicoes em HTML, CSS, JS e assets entram no proximo build sem arrastar a pasta de novo. Se `app.json` ou `config.json` mudar, os campos da tela de configuracoes sao recarregados.
759
+
760
+ Para `Testar no USB`, o celular precisa estar com `Opcoes do desenvolvedor > Depuracao USB` ativa. Ao conectar, desbloqueie o aparelho e aceite a chave RSA. Se o Android aparecer como `unauthorized` ou `offline`, a interface mostra o que fazer nos logs.
756
761
 
757
762
  Os logs podem ser abertos em uma barra inferior durante qualquer etapa pelo botao `Mostrar logs`. Se atrapalhar a visualizacao, use `Ocultar logs` e a area principal volta a ocupar a altura da janela.
758
763
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "html2apk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Node CLI and library to turn an HTML/CSS/JS folder into an Android APK through Cordova.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -47,10 +47,28 @@ async function buildAndroid(buildDir, options, buildJsonPath, runner) {
47
47
  });
48
48
  }
49
49
 
50
+ async function runAndroidDevice(buildDir, options, buildJsonPath, runner, deviceId) {
51
+ const args = ["run", "android", "--device", "--debug", "--no-telemetry"];
52
+
53
+ if (deviceId) {
54
+ args.push(`--target=${deviceId}`);
55
+ }
56
+
57
+ if (buildJsonPath) {
58
+ args.push("--buildConfig", buildJsonPath);
59
+ }
60
+
61
+ await runner.run("cordova", args, {
62
+ cwd: buildDir,
63
+ pipeOutput: options.debug
64
+ });
65
+ }
66
+
50
67
  module.exports = {
51
68
  DEFAULT_ANDROID_PLATFORM,
52
69
  createCordovaProject,
53
70
  addCordovaPlugin,
54
71
  addAndroidPlatform,
55
- buildAndroid
72
+ buildAndroid,
73
+ runAndroidDevice
56
74
  };
@@ -5,7 +5,7 @@ const os = require("os");
5
5
  const path = require("path");
6
6
  const { resolveBuildOptions } = require("./config");
7
7
  const { validateEntryFile, validateRequiredOptions } = require("./validation");
8
- const { createCordovaProject, addAndroidPlatform, buildAndroid, addCordovaPlugin } = require("../cordova/project");
8
+ const { createCordovaProject, addAndroidPlatform, buildAndroid, runAndroidDevice, addCordovaPlugin } = require("../cordova/project");
9
9
  const { writeConfigXml } = require("../cordova/config-xml");
10
10
  const { findApk } = require("../cordova/apk-finder");
11
11
  const { copyWebAssets, ensureDir, removePath, copyFile, copyDirectory } = require("../utils/fs-extra");
@@ -16,6 +16,15 @@ const { getRuntimeEnvironment } = require("../runtime-manager");
16
16
  const AUTO_THEME_SCRIPT_NAME = "html2apk-auto-theme.js";
17
17
  const ONESIGNAL_SCRIPT_NAME = "html2apk-onesignal.js";
18
18
  const ONESIGNAL_PLUGIN_PACKAGE = "onesignal-cordova-plugin";
19
+ const DEFAULT_APP_ICON_NAME = "html2apk.png";
20
+
21
+ function defaultAppIconPath() {
22
+ return path.resolve(__dirname, "..", "..", DEFAULT_APP_ICON_NAME);
23
+ }
24
+
25
+ function withDefaultAppIcon(assetPath) {
26
+ return String(assetPath || "").trim() || defaultAppIconPath();
27
+ }
19
28
 
20
29
  function isRemoteAsset(assetPath) {
21
30
  return /^https?:\/\//i.test(String(assetPath || ""));
@@ -54,6 +63,37 @@ function scriptTag(scriptPath) {
54
63
  return `<script src="${scriptPath}"></script>`;
55
64
  }
56
65
 
66
+ async function injectCordovaRuntimeIntoHtml(htmlPath, scriptPath = "cordova.js") {
67
+ let html = await fs.readFile(htmlPath, "utf8");
68
+ if (/<script\b[^>]*\bsrc=["'][^"']*cordova\.js["'][^>]*>/i.test(html)) {
69
+ return false;
70
+ }
71
+
72
+ const tag = scriptTag(scriptPath);
73
+ if (/<head\b[^>]*>/i.test(html)) {
74
+ html = html.replace(/<head\b[^>]*>/i, (match) => `${match}\n ${tag}`);
75
+ } else if (/<html\b[^>]*>/i.test(html)) {
76
+ html = html.replace(/<html\b[^>]*>/i, (match) => `${match}\n ${tag}`);
77
+ } else {
78
+ html = `${tag}\n${html}`;
79
+ }
80
+
81
+ await fs.writeFile(htmlPath, html, "utf8");
82
+ return true;
83
+ }
84
+
85
+ async function installCordovaRuntimeScript(buildDir, options) {
86
+ const wwwDir = path.join(buildDir, "www");
87
+ const entryHtmlPath = path.resolve(wwwDir, options.entryFile || "index.html");
88
+ const scriptPath = toCordovaPath(path.relative(path.dirname(entryHtmlPath), path.join(wwwDir, "cordova.js"))) || "cordova.js";
89
+
90
+ if (!isInside(wwwDir, entryHtmlPath)) {
91
+ throw new Error(`Entry file must stay inside the Cordova www folder: ${options.entryFile}`);
92
+ }
93
+
94
+ return injectCordovaRuntimeIntoHtml(entryHtmlPath, scriptPath);
95
+ }
96
+
57
97
  async function injectScriptIntoHtml(htmlPath, scriptPath) {
58
98
  let html = await fs.readFile(htmlPath, "utf8");
59
99
  if (html.includes(scriptPath)) {
@@ -238,11 +278,147 @@ function outputApkName(options) {
238
278
  return `${safeName}-${options.version}-${flavor}.apk`;
239
279
  }
240
280
 
281
+ function parseAdbDevices(output) {
282
+ return String(output || "")
283
+ .split(/\r?\n/)
284
+ .map((line) => line.trim())
285
+ .filter((line) => line
286
+ && !/^list of devices/i.test(line)
287
+ && !/^\*/.test(line)
288
+ && !/^adb server/i.test(line))
289
+ .map((line) => {
290
+ const parts = line.split(/\s+/);
291
+ return {
292
+ id: parts[0],
293
+ status: parts[1] || "unknown"
294
+ };
295
+ })
296
+ .filter((device) => device.id);
297
+ }
298
+
299
+ function deviceTargetId(device) {
300
+ return device && device.id ? String(device.id) : "";
301
+ }
302
+
303
+ async function ensureUsbDebugDevice(runner) {
304
+ let result;
305
+ try {
306
+ result = await runner.run("adb", ["devices"]);
307
+ } catch (error) {
308
+ throw new Error("ADB nao foi encontrado. Instale Android platform-tools pelo ambiente do html2apk e tente novamente.");
309
+ }
310
+
311
+ const devices = parseAdbDevices(result.stdout);
312
+ const physicalDevices = devices.filter((device) => !/^emulator-/i.test(device.id));
313
+ const ready = physicalDevices.find((device) => device.status === "device");
314
+ if (ready) {
315
+ return ready;
316
+ }
317
+
318
+ if (physicalDevices.some((device) => device.status === "unauthorized")) {
319
+ throw new Error("Celular encontrado, mas a depuracao USB ainda nao foi autorizada. Desbloqueie o celular e aceite a chave RSA de depuracao USB.");
320
+ }
321
+
322
+ if (physicalDevices.some((device) => device.status === "offline")) {
323
+ throw new Error("Celular USB encontrado, mas esta offline. Reconecte o cabo USB, desbloqueie o celular e confirme a depuracao USB.");
324
+ }
325
+
326
+ throw new Error("Nenhum celular USB autorizado foi encontrado. Ative Opcoes do desenvolvedor > Depuracao USB, conecte o celular e aceite a permissao RSA.");
327
+ }
328
+
329
+ async function prepareCordovaProject(projectRoot, buildDir, options, runner, log) {
330
+ await createCordovaProject(buildDir, options, runner);
331
+ const cordovaOptions = { ...options };
332
+ const effectiveIcon = withDefaultAppIcon(options.icon);
333
+ const effectiveSplash = options.splash || effectiveIcon;
334
+ if (!String(options.icon || "").trim()) {
335
+ log("Icon: using the default html2apk icon.");
336
+ }
337
+ cordovaOptions.icon = await copyCordovaAsset(projectRoot, buildDir, effectiveIcon, "icon");
338
+ cordovaOptions.splash = await copyCordovaAsset(projectRoot, buildDir, effectiveSplash, "splash");
339
+ cordovaOptions.androidSplashScreenAnimatedIcon = toBuildAssetPath(buildDir, cordovaOptions.splash);
340
+ await writeConfigXml(path.join(buildDir, "config.xml"), cordovaOptions);
341
+ await copyWebAssets(path.resolve(projectRoot, options.webRoot || "."), path.join(buildDir, "www"), options, projectRoot);
342
+ if (await installCordovaRuntimeScript(buildDir, options)) {
343
+ log("Cordova runtime: injected cordova.js into the entry HTML.");
344
+ }
345
+ if (await installAutoThemeScript(buildDir, options)) {
346
+ log("Theme mode: auto (system bars follow the visible screen color).");
347
+ }
348
+ if (await installOneSignalScript(buildDir, options)) {
349
+ log("OneSignal: enabled for remote push notifications.");
350
+ }
351
+
352
+ const bridgePluginPath = await installBridgePlugin(buildDir);
353
+ await addCordovaPlugin(buildDir, bridgePluginPath, runner);
354
+
355
+ if (hasOneSignal(options) && !hasPlugin(options.plugins, ONESIGNAL_PLUGIN_PACKAGE)) {
356
+ await addCordovaPlugin(buildDir, await prepareBundledPlugin(buildDir, ONESIGNAL_PLUGIN_PACKAGE), runner);
357
+ }
358
+
359
+ for (const plugin of options.plugins) {
360
+ await addCordovaPlugin(buildDir, plugin, runner);
361
+ }
362
+
363
+ await addAndroidPlatform(buildDir, options, runner);
364
+ }
365
+
366
+ async function copyBuiltApk(projectRoot, buildDir, options) {
367
+ const apkPathInBuild = await findApk(buildDir, options);
368
+ const outputDir = path.resolve(projectRoot, options.outputDir || "dist");
369
+ await ensureDir(outputDir);
370
+
371
+ const apkPath = path.join(outputDir, outputApkName(options));
372
+ await copyFile(apkPathInBuild, apkPath);
373
+ return apkPath;
374
+ }
375
+
376
+ async function ensureBuiltDebugApk(buildDir, options, runner, log) {
377
+ try {
378
+ return await findApk(buildDir, options);
379
+ } catch {
380
+ log("USB debug: building debug APK for direct ADB install.");
381
+ await buildAndroid(buildDir, options, null, runner);
382
+ return findApk(buildDir, options);
383
+ }
384
+ }
385
+
386
+ async function installDebugApkWithAdb(buildDir, options, runner, device, log) {
387
+ const deviceId = deviceTargetId(device);
388
+ const apkPath = await ensureBuiltDebugApk(buildDir, options, runner, log);
389
+ log(`USB debug fallback: installing APK with ADB on ${deviceId}.`);
390
+
391
+ try {
392
+ await runner.run("adb", ["-s", deviceId, "install", "-r", "-d", apkPath]);
393
+ } catch (error) {
394
+ const output = `${error.stdout || ""}\n${error.stderr || ""}\n${error.message || ""}`;
395
+ if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/i.test(output)) {
396
+ throw new Error("O app ja esta instalado nesse celular com outra assinatura. Desinstale a versao antiga no Android e clique em Testar no USB novamente.");
397
+ }
398
+ throw error;
399
+ }
400
+
401
+ log(`USB debug fallback: opening ${options.packageId}.`);
402
+ await runner.run("adb", [
403
+ "-s",
404
+ deviceId,
405
+ "shell",
406
+ "monkey",
407
+ "-p",
408
+ options.packageId,
409
+ "-c",
410
+ "android.intent.category.LAUNCHER",
411
+ "1"
412
+ ]);
413
+
414
+ return apkPath;
415
+ }
416
+
241
417
  async function buildApk(overrides = {}) {
242
418
  const onLog = typeof overrides.onLog === "function" ? overrides.onLog : null;
243
419
  const { projectRoot, configPath, options } = await resolveBuildOptions(overrides);
244
420
  validateRequiredOptions(options);
245
- const { webRoot } = await validateEntryFile(projectRoot, options);
421
+ await validateEntryFile(projectRoot, options);
246
422
 
247
423
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "html2apk-"));
248
424
  const buildDir = path.join(tempRoot, "cordova-project");
@@ -268,41 +444,86 @@ async function buildApk(overrides = {}) {
268
444
  log(`JAVA_HOME: ${runtime.javaHome}`);
269
445
  }
270
446
 
271
- await createCordovaProject(buildDir, options, runner);
272
- const cordovaOptions = { ...options };
273
- cordovaOptions.icon = await copyCordovaAsset(projectRoot, buildDir, options.icon, "icon");
274
- cordovaOptions.splash = await copyCordovaAsset(projectRoot, buildDir, options.splash || options.icon, "splash");
275
- cordovaOptions.androidSplashScreenAnimatedIcon = toBuildAssetPath(buildDir, cordovaOptions.splash);
276
- await writeConfigXml(path.join(buildDir, "config.xml"), cordovaOptions);
277
- await copyWebAssets(webRoot, path.join(buildDir, "www"), options, projectRoot);
278
- if (await installAutoThemeScript(buildDir, options)) {
279
- log("Theme mode: auto (system bars follow the visible screen color).");
280
- }
281
- if (await installOneSignalScript(buildDir, options)) {
282
- log("OneSignal: enabled for remote push notifications.");
283
- }
447
+ await prepareCordovaProject(projectRoot, buildDir, options, runner, log);
448
+ const buildJsonPath = await createBuildJson(buildDir, options, projectRoot);
449
+ await buildAndroid(buildDir, options, buildJsonPath, runner);
284
450
 
285
- const bridgePluginPath = await installBridgePlugin(buildDir);
286
- await addCordovaPlugin(buildDir, bridgePluginPath, runner);
451
+ const apkPath = await copyBuiltApk(projectRoot, buildDir, options);
287
452
 
288
- if (hasOneSignal(options) && !hasPlugin(options.plugins, ONESIGNAL_PLUGIN_PACKAGE)) {
289
- await addCordovaPlugin(buildDir, await prepareBundledPlugin(buildDir, ONESIGNAL_PLUGIN_PACKAGE), runner);
453
+ if (!options.debug) {
454
+ await removePath(tempRoot);
455
+ tempCleaned = true;
290
456
  }
291
457
 
292
- for (const plugin of options.plugins) {
293
- await addCordovaPlugin(buildDir, plugin, runner);
458
+ return {
459
+ apkPath,
460
+ buildDir: options.debug ? buildDir : null,
461
+ logs,
462
+ status: "success",
463
+ tempCleaned
464
+ };
465
+ } catch (error) {
466
+ log(`Error: ${error.message}`);
467
+ if (!options.debug) {
468
+ await removePath(tempRoot).catch(() => {});
469
+ tempCleaned = true;
470
+ } else {
471
+ log(`Debug mode enabled. Temporary build kept at: ${buildDir}`);
294
472
  }
295
473
 
296
- await addAndroidPlatform(buildDir, options, runner);
297
- const buildJsonPath = await createBuildJson(buildDir, options, projectRoot);
298
- await buildAndroid(buildDir, options, buildJsonPath, runner);
474
+ error.logs = logs;
475
+ error.buildDir = options.debug ? buildDir : null;
476
+ error.tempCleaned = tempCleaned;
477
+ error.status = "error";
478
+ throw error;
479
+ }
480
+ }
481
+
482
+ async function runDebugUsb(overrides = {}) {
483
+ const onLog = typeof overrides.onLog === "function" ? overrides.onLog : null;
484
+ const resolved = await resolveBuildOptions(overrides);
485
+ const projectRoot = resolved.projectRoot;
486
+ const configPath = resolved.configPath;
487
+ const options = {
488
+ ...resolved.options,
489
+ release: false
490
+ };
491
+ validateRequiredOptions(options);
492
+ await validateEntryFile(projectRoot, options);
493
+
494
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "html2apk-"));
495
+ const buildDir = path.join(tempRoot, "cordova-project");
496
+ const logs = [];
497
+ const runtime = getRuntimeEnvironment();
498
+ const runner = createCommandRunner({ logs, env: runtime.env, onLog });
499
+ let tempCleaned = false;
299
500
 
300
- const apkPathInBuild = await findApk(buildDir, options);
301
- const outputDir = path.resolve(projectRoot, options.outputDir || "dist");
302
- await ensureDir(outputDir);
501
+ function log(line) {
502
+ logs.push(line);
503
+ if (onLog) {
504
+ onLog(line);
505
+ }
506
+ }
507
+
508
+ try {
509
+ log(`Project root: ${projectRoot}`);
510
+ log(configPath ? `Config: ${configPath}` : "Config: defaults only");
511
+ log("USB debug: checking connected Android device.");
512
+ const device = await ensureUsbDebugDevice(runner);
513
+ log(`USB debug device: ${device.id}`);
514
+
515
+ await prepareCordovaProject(projectRoot, buildDir, options, runner, log);
516
+ try {
517
+ await runAndroidDevice(buildDir, options, null, runner, deviceTargetId(device));
518
+ } catch (error) {
519
+ log("USB debug: Cordova run failed. Trying direct ADB install fallback.");
520
+ if (error.stdout || error.stderr) {
521
+ log([error.stdout, error.stderr].filter(Boolean).join("\n").trim().slice(-4000));
522
+ }
523
+ await installDebugApkWithAdb(buildDir, options, runner, device, log);
524
+ }
303
525
 
304
- const apkPath = path.join(outputDir, outputApkName(options));
305
- await copyFile(apkPathInBuild, apkPath);
526
+ const apkPath = await copyBuiltApk(projectRoot, buildDir, options);
306
527
 
307
528
  if (!options.debug) {
308
529
  await removePath(tempRoot);
@@ -312,8 +533,10 @@ async function buildApk(overrides = {}) {
312
533
  return {
313
534
  apkPath,
314
535
  buildDir: options.debug ? buildDir : null,
536
+ device,
315
537
  logs,
316
538
  status: "success",
539
+ usbDebug: true,
317
540
  tempCleaned
318
541
  };
319
542
  } catch (error) {
@@ -334,5 +557,9 @@ async function buildApk(overrides = {}) {
334
557
  }
335
558
 
336
559
  module.exports = {
337
- buildApk
560
+ buildApk,
561
+ defaultAppIconPath,
562
+ injectCordovaRuntimeIntoHtml,
563
+ parseAdbDevices,
564
+ runDebugUsb
338
565
  };
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
 
3
+ const nodeFs = require("fs");
3
4
  const fs = require("fs/promises");
4
5
  const path = require("path");
5
6
  const { spawn } = require("child_process");
6
7
  const { app, BrowserWindow, dialog, ipcMain, screen, shell } = require("electron");
7
- const { buildApk } = require("../core/build-apk");
8
+ const { buildApk, runDebugUsb } = require("../core/build-apk");
8
9
  const {
9
10
  REQUIRED_ANDROID_BUILD_TOOLS,
10
11
  REQUIRED_ANDROID_PLATFORM,
@@ -13,6 +14,9 @@ const {
13
14
  const { runDoctor, formatDoctorReport } = require("../runtime-manager/doctor");
14
15
 
15
16
  let mainWindow = null;
17
+ let projectWatcher = null;
18
+ let projectWatchTimer = null;
19
+ let watchedProjectRoot = null;
16
20
  const smokeTest = process.env.HTML2APK_DESKTOP_SMOKE === "1";
17
21
  const APP_ID = "dev.caiomultiversando.html2apk";
18
22
  const APP_NAME = "html2apk";
@@ -135,6 +139,90 @@ async function inspectProject(projectRoot) {
135
139
  };
136
140
  }
137
141
 
142
+ function shouldIgnoreProjectWatchPath(fileName) {
143
+ const normalized = String(fileName || "").replace(/\\/g, "/");
144
+ return normalized
145
+ && (normalized.includes(".html2apk-doctor-")
146
+ || normalized.startsWith("dist/")
147
+ || normalized === "dist"
148
+ || normalized.startsWith("node_modules/")
149
+ || normalized === "node_modules"
150
+ || normalized.startsWith(".git/")
151
+ || normalized === ".git");
152
+ }
153
+
154
+ function stopProjectWatcher() {
155
+ if (projectWatchTimer) {
156
+ clearTimeout(projectWatchTimer);
157
+ projectWatchTimer = null;
158
+ }
159
+
160
+ if (projectWatcher) {
161
+ projectWatcher.close();
162
+ projectWatcher = null;
163
+ }
164
+
165
+ watchedProjectRoot = null;
166
+ }
167
+
168
+ function startProjectWatcher(projectRoot, sender) {
169
+ stopProjectWatcher();
170
+ watchedProjectRoot = projectRoot;
171
+
172
+ try {
173
+ projectWatcher = nodeFs.watch(projectRoot, { recursive: true }, (eventType, fileName) => {
174
+ if (shouldIgnoreProjectWatchPath(fileName)) {
175
+ return;
176
+ }
177
+
178
+ const changedPath = path.join(projectRoot, String(fileName || ""));
179
+ if (projectWatchTimer) {
180
+ clearTimeout(projectWatchTimer);
181
+ }
182
+
183
+ projectWatchTimer = setTimeout(async () => {
184
+ projectWatchTimer = null;
185
+ try {
186
+ if (watchedProjectRoot !== projectRoot) {
187
+ return;
188
+ }
189
+
190
+ const project = await inspectProject(projectRoot);
191
+ sender.send("project:changed", {
192
+ eventType,
193
+ changedPath,
194
+ project,
195
+ time: new Date().toISOString()
196
+ });
197
+ } catch (error) {
198
+ sender.send("project:watch-error", {
199
+ message: error.message,
200
+ time: new Date().toISOString()
201
+ });
202
+ }
203
+ }, 350);
204
+ });
205
+
206
+ projectWatcher.on("error", (error) => {
207
+ sender.send("project:watch-error", {
208
+ message: error.message,
209
+ time: new Date().toISOString()
210
+ });
211
+ });
212
+
213
+ return {
214
+ ok: true,
215
+ projectRoot
216
+ };
217
+ } catch (error) {
218
+ stopProjectWatcher();
219
+ return {
220
+ ok: false,
221
+ message: error.message
222
+ };
223
+ }
224
+ }
225
+
138
226
  function cleanBuildOptions(options = {}) {
139
227
  const output = {
140
228
  projectRoot: options.projectRoot,
@@ -365,6 +453,7 @@ app.whenReady().then(() => {
365
453
  });
366
454
 
367
455
  app.on("window-all-closed", () => {
456
+ stopProjectWatcher();
368
457
  if (process.platform !== "darwin") {
369
458
  app.quit();
370
459
  }
@@ -440,6 +529,15 @@ ipcMain.handle("project:inspect", async (_event, projectRoot) => {
440
529
  return inspectProject(projectRoot);
441
530
  });
442
531
 
532
+ ipcMain.handle("project:watch", async (event, projectRoot) => {
533
+ return startProjectWatcher(projectRoot, event.sender);
534
+ });
535
+
536
+ ipcMain.handle("project:unwatch", async () => {
537
+ stopProjectWatcher();
538
+ return { ok: true };
539
+ });
540
+
443
541
  ipcMain.handle("doctor:run", async (_event, projectRoot) => {
444
542
  const report = await runDoctor({ projectRoot });
445
543
  return {
@@ -517,6 +615,39 @@ ipcMain.handle("build:run", async (event, options) => {
517
615
  }
518
616
  });
519
617
 
618
+ ipcMain.handle("build:run-usb-debug", async (event, options) => {
619
+ const buildOptions = cleanBuildOptions(options);
620
+ const sendLog = (line, kind = "raw") => {
621
+ event.sender.send("build:log", {
622
+ line,
623
+ kind,
624
+ time: new Date().toISOString()
625
+ });
626
+ };
627
+
628
+ try {
629
+ sendLog("Starting html2apk USB debug build.", "system");
630
+ const result = await runDebugUsb({
631
+ ...buildOptions,
632
+ release: false,
633
+ onLog: (line) => sendLog(line)
634
+ });
635
+ sendLog(`USB debug installed on device: ${result.device?.id || "Android device"}`, "success");
636
+ return {
637
+ ok: true,
638
+ result
639
+ };
640
+ } catch (error) {
641
+ sendLog(error.message, "error");
642
+ return {
643
+ ok: false,
644
+ message: error.message,
645
+ logs: error.logs || [],
646
+ buildDir: error.buildDir || null
647
+ };
648
+ }
649
+ });
650
+
520
651
  ipcMain.handle("shell:open-path", async (_event, targetPath) => {
521
652
  if (!targetPath) {
522
653
  return false;