html2apk 0.4.0 → 0.7.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.
@@ -10,6 +10,7 @@ const i18n = {
10
10
  navSettings: "Configuracoes",
11
11
  navAppearance: "Aparencia",
12
12
  navBuild: "Build",
13
+ navFiles: "Arquivos",
13
14
  navCodes: "Codigos",
14
15
  navLogs: "Logs",
15
16
  navHelp: "Ajuda",
@@ -32,6 +33,10 @@ const i18n = {
32
33
  appName: "Nome do app",
33
34
  packageId: "Package ID",
34
35
  appVersion: "Versao do app",
36
+ buildFormat: "Formato",
37
+ formatApk: "APK",
38
+ formatAab: "AAB",
39
+ buildFormatHint: "APK para instalar direto; AAB para loja.",
35
40
  mode: "Modo",
36
41
  chooseMode: "Escolha o modo",
37
42
  modeFullscreen: "Tela cheia",
@@ -51,13 +56,35 @@ const i18n = {
51
56
  oneSignalAppIdHint: "Opcional. Use o App ID do OneSignal, nao a REST API Key.",
52
57
  androidPermissions: "Permissoes Android",
53
58
  chooseIcon: "Trocar icone PNG",
59
+ keystoreTitle: "Assinatura / keystore",
60
+ keystoreFile: "Arquivo keystore",
61
+ chooseKeystore: "Escolher",
62
+ keystoreAlias: "Alias",
63
+ keystoreStorePassword: "Senha da store",
64
+ keystoreKeyPassword: "Senha da key",
65
+ keystoreHint: "Opcional para APK debug. Para AAB/release, preencha para assinar o arquivo.",
54
66
  reviewBuild: "Revisar build",
55
67
  debugBuild: "Debug tecnico",
56
68
  debugBuildText: "Mantem a pasta Cordova temporaria para inspecao.",
69
+ runtimeLogsBuild: "Console no APK",
70
+ runtimeLogsBuildText: "Mostra um modal Console no app gerado para depurar erros e funcoes interpretadas.",
57
71
  releaseBuild: "Release",
58
72
  releaseBuildText: "Usa configuracao de assinatura se houver keystore.",
59
73
  appearanceEyebrow: "Preferencias",
60
74
  appearanceTitle: "Idioma e tema",
75
+ filesEyebrow: "Editor visual",
76
+ filesTitle: "Arquivos do projeto",
77
+ fileTreeTitle: "Pastas e arquivos",
78
+ newFile: "Novo arquivo",
79
+ saveFile: "Salvar",
80
+ noFileSelected: "Nenhum arquivo selecionado",
81
+ syntaxPreview: "Previa com sintaxe",
82
+ newFilePrompt: "Digite o caminho do novo arquivo dentro do projeto:",
83
+ fileSaved: "Arquivo salvo",
84
+ fileCreated: "Arquivo criado",
85
+ fileOpenFail: "Nao foi possivel abrir o arquivo",
86
+ fileSaveFail: "Nao foi possivel salvar o arquivo",
87
+ unsavedFileConfirm: "Ha alteracoes nao salvas. Deseja trocar de arquivo mesmo assim?",
61
88
  language: "Idioma",
62
89
  languageText: "Escolha como os feedbacks aparecem durante o build.",
63
90
  themeText: "O botao tambem fica na barra lateral para acesso rapido.",
@@ -133,7 +160,10 @@ const i18n = {
133
160
  invalidThemeColor: "Use uma cor hexadecimal valida, exemplo: #126fff.",
134
161
  invalidOneSignalAppId: "Use um OneSignal App ID valido ou deixe vazio.",
135
162
  invalidMinSdkVersion: "Escolha uma versao minima do Android entre API 24 e API 36.",
163
+ missingKeystoreForAab: "Para gerar AAB, informe o arquivo keystore, alias e senhas.",
164
+ incompleteKeystore: "Complete arquivo keystore, alias, senha da store e senha da key.",
136
165
  iconSelected: "Icone selecionado",
166
+ keystoreSelected: "Keystore selecionado",
137
167
  progressLabel: "Progresso",
138
168
  progressIdle: "Aguardando pasta",
139
169
  progressFolder: "Pasta recebida",
@@ -166,6 +196,7 @@ const i18n = {
166
196
  navSettings: "Settings",
167
197
  navAppearance: "Appearance",
168
198
  navBuild: "Build",
199
+ navFiles: "Files",
169
200
  navCodes: "Code",
170
201
  navLogs: "Logs",
171
202
  navHelp: "Help",
@@ -188,6 +219,10 @@ const i18n = {
188
219
  appName: "App name",
189
220
  packageId: "Package ID",
190
221
  appVersion: "App version",
222
+ buildFormat: "Format",
223
+ formatApk: "APK",
224
+ formatAab: "AAB",
225
+ buildFormatHint: "APK to install directly; AAB for stores.",
191
226
  mode: "Mode",
192
227
  chooseMode: "Choose mode",
193
228
  modeFullscreen: "Fullscreen",
@@ -207,13 +242,35 @@ const i18n = {
207
242
  oneSignalAppIdHint: "Optional. Use the OneSignal App ID, not the REST API Key.",
208
243
  androidPermissions: "Android permissions",
209
244
  chooseIcon: "Change PNG icon",
245
+ keystoreTitle: "Signing / keystore",
246
+ keystoreFile: "Keystore file",
247
+ chooseKeystore: "Choose",
248
+ keystoreAlias: "Alias",
249
+ keystoreStorePassword: "Store password",
250
+ keystoreKeyPassword: "Key password",
251
+ keystoreHint: "Optional for debug APK. For AAB/release, fill this to sign the file.",
210
252
  reviewBuild: "Review build",
211
253
  debugBuild: "Technical debug",
212
254
  debugBuildText: "Keeps the temporary Cordova folder for inspection.",
255
+ runtimeLogsBuild: "APK console",
256
+ runtimeLogsBuildText: "Shows a Console modal in the generated app to debug errors and interpreted functions.",
213
257
  releaseBuild: "Release",
214
258
  releaseBuildText: "Uses signing configuration when a keystore exists.",
215
259
  appearanceEyebrow: "Preferences",
216
260
  appearanceTitle: "Language and theme",
261
+ filesEyebrow: "Visual editor",
262
+ filesTitle: "Project files",
263
+ fileTreeTitle: "Folders and files",
264
+ newFile: "New file",
265
+ saveFile: "Save",
266
+ noFileSelected: "No file selected",
267
+ syntaxPreview: "Syntax preview",
268
+ newFilePrompt: "Enter the new file path inside the project:",
269
+ fileSaved: "File saved",
270
+ fileCreated: "File created",
271
+ fileOpenFail: "Could not open the file",
272
+ fileSaveFail: "Could not save the file",
273
+ unsavedFileConfirm: "There are unsaved changes. Switch files anyway?",
217
274
  language: "Language",
218
275
  languageText: "Choose how feedback appears during the build.",
219
276
  themeText: "The button also stays in the sidebar for quick access.",
@@ -289,7 +346,10 @@ const i18n = {
289
346
  invalidThemeColor: "Use a valid hex color, example: #126fff.",
290
347
  invalidOneSignalAppId: "Use a valid OneSignal App ID or leave it empty.",
291
348
  invalidMinSdkVersion: "Choose a minimum Android version between API 24 and API 36.",
349
+ missingKeystoreForAab: "To build AAB, enter the keystore file, alias and passwords.",
350
+ incompleteKeystore: "Complete keystore file, alias, store password and key password.",
292
351
  iconSelected: "Icon selected",
352
+ keystoreSelected: "Keystore selected",
293
353
  progressLabel: "Progress",
294
354
  progressIdle: "Waiting for folder",
295
355
  progressFolder: "Folder received",
@@ -391,11 +451,11 @@ const nativeCodeEntries = [
391
451
  handling: { pt: "Limite duracoes longas e deixe a permissao `VIBRATE` no app; se falhar, siga sem bloquear o fluxo principal.", en: "Keep long durations under control and include the `VIBRATE` permission; if it fails, continue without blocking the main flow." }
392
452
  },
393
453
  {
394
- syntax: { pt: "notificar({ titulo, texto, acoes })", en: "notify({ title, text, actions })" },
454
+ syntax: { pt: "notificar({ titulo, texto, aoClicar?, acoes?, open? })", en: "notify({ title, text, onClick?, actions?, open? })" },
395
455
  java: "notify",
396
- description: { pt: "Cria notificacao Android imediata. `acoes` ou `actions` viram botoes clicaveis.", en: "Creates an immediate Android notification. `actions` or `acoes` become clickable buttons." },
397
- returns: { pt: "Promise<void>; cliques chegam em `aoEvento('notificacao:clicada')` ou `aoClicarNotificacao()`.", en: "Promise<void>; clicks arrive through `onEvent('notification:clicked')` or `onNotificationClick()`." },
398
- handling: { pt: "Ao chamar, o html2apk pede permissao automaticamente no Android 13+. Se o Android nao puder mostrar o pop-up, ele abre as configuracoes e retorna `settingsOpened`.", en: "When called, html2apk automatically requests permission on Android 13+. If Android cannot show the prompt, it opens settings and returns `settingsOpened`." }
456
+ description: { pt: "Cria notificacao Android imediata. So `titulo` e `texto` ja bastam; `aoClicar`, `acoes` e `open` sao opcionais.", en: "Creates an immediate Android notification. `title` and `text` are enough; `onClick`, `actions` and `open` are optional." },
457
+ returns: { pt: "Promise<void>; cliques chegam em `aoEvento('notificacao:clicada')` ou `aoClicarNotificacao()` e tambem podem executar `aoClicar` automaticamente.", en: "Promise<void>; clicks arrive through `onEvent('notification:clicked')` or `onNotificationClick()` and can also run `onClick` automatically." },
458
+ handling: { pt: "Use `aoClicar: () => funcao()` enquanto o app esta vivo. Para agendada ou app fechado, prefira `aoClicar: { funcao, argumentos }`. `open:false` evita abrir a tela; JavaScript so roda assim se o app ainda estiver vivo, mas acoes externas como `abrirForaDoApp` funcionam por fallback nativo.", en: "Use `onClick: () => functionName()` while the app process is alive. For scheduled notifications or a closed app, prefer `onClick: { functionName, args }`. `open:false` avoids opening the screen; JavaScript only runs this way if the app is still alive, but external actions like `openOutsideApp` work through a native fallback." }
399
459
  },
400
460
  {
401
461
  syntax: { pt: "agendarNotificacao({ titulo, texto, quando }) / agendarNotificacoes([...])", en: "scheduleNotification({ title, text, when }) / scheduleNotifications([...])" },
@@ -409,7 +469,7 @@ const nativeCodeEntries = [
409
469
  java: "AlarmManager + NotificationStore",
410
470
  description: { pt: "Cria um loop de notificacoes que continua funcionando com o app fechado e alterna os itens da lista.", en: "Creates a notification loop that keeps working with the app closed and rotates through the list items." },
411
471
  returns: { pt: "{ id, when, repeating, loop }. Guarde o `id` para cancelar depois.", en: "{ id, when, repeating, loop }. Store the `id` to cancel later." },
412
- handling: { pt: "Use `aoClicarNotificacao()` ou `aoEvento('notificacao:clicada')` para chamar funcoes conforme `aoClicar`. Cancele com `cancelarNotificacao(id)`.", en: "Use `onNotificationClick()` or `onEvent('notification:clicked')` to call functions from `onClick`. Cancel with `cancelNotification(id)`." }
472
+ handling: { pt: "Cancele com `cancelarNotificacao(id)`. Se o clique precisar chamar funcao automaticamente mesmo apos o Android reabrir o app, use `funcao` + `argumentos` no `aoClicar`.", en: "Cancel with `cancelNotification(id)`. If the click must call a function automatically after Android reopens the app, use `functionName` + `args` in `onClick`." }
413
473
  },
414
474
  {
415
475
  syntax: { pt: "solicitarPermissaoNotificacoes()", en: "requestNotificationPermission()" },
@@ -668,17 +728,102 @@ const nativeCodeRecipes = [
668
728
  }
669
729
  },
670
730
  {
671
- when: { pt: "Para mostrar uma notificacao agora. A permissao aparece automaticamente se o Android exigir.", en: "To show a notification now. Permission appears automatically if Android requires it." },
731
+ when: { pt: "Para mostrar uma notificacao simples agora. `aoClicar`, `acoes` e `open` sao opcionais.", en: "To show a simple notification now. `onClick`, `actions` and `open` are optional." },
732
+ example: {
733
+ pt: `await notificar({
734
+ titulo: "Pedido aprovado",
735
+ texto: "Toque para abrir o app"
736
+ });`,
737
+ en: `await notify({
738
+ title: "Order approved",
739
+ text: "Tap to open the app"
740
+ });`
741
+ }
742
+ },
743
+ {
744
+ when: { pt: "Para executar algo quando a notificacao for clicada.", en: "To run something when the notification is clicked." },
672
745
  example: {
673
746
  pt: `await notificar({
674
747
  titulo: "Pedido aprovado",
675
748
  texto: "Toque para abrir os detalhes",
676
- aoClicar: { acao: "abrir-pedido", id: 123 }
749
+ aoClicar: () => abrirForaDoApp("https://exemplo.com/pedidos/123")
677
750
  });`,
678
751
  en: `await notify({
679
752
  title: "Order approved",
680
753
  text: "Tap to open details",
681
- onClick: { action: "open-order", id: 123 }
754
+ onClick: () => openOutsideApp("https://example.com/orders/123")
755
+ });`
756
+ }
757
+ },
758
+ {
759
+ when: { pt: "Para colocar botoes na notificacao, cada um chamando uma funcao.", en: "To add buttons to the notification, each one calling a function." },
760
+ example: {
761
+ pt: `window.marcarPedidoLido = (id) => {
762
+ localStorage.setItem("pedido:" + id, "lido");
763
+ };
764
+
765
+ await notificar({
766
+ titulo: "Pedido aprovado",
767
+ texto: "Escolha uma acao",
768
+ acoes: [
769
+ {
770
+ id: "abrir",
771
+ titulo: "Abrir",
772
+ open: true,
773
+ aoClicar: { funcao: "abrirNoApp", argumentos: ["#/pedido/123"] }
774
+ },
775
+ {
776
+ id: "lido",
777
+ titulo: "Marcar lido",
778
+ open: false,
779
+ aoClicar: { funcao: "marcarPedidoLido", argumentos: [123], open: false }
780
+ }
781
+ ]
782
+ });`,
783
+ en: `window.markOrderRead = (id) => {
784
+ localStorage.setItem("order:" + id, "read");
785
+ };
786
+
787
+ await notify({
788
+ title: "Order approved",
789
+ text: "Choose an action",
790
+ actions: [
791
+ {
792
+ id: "open",
793
+ title: "Open",
794
+ open: true,
795
+ onClick: { functionName: "openInApp", args: ["#/order/123"] }
796
+ },
797
+ {
798
+ id: "read",
799
+ title: "Mark read",
800
+ open: false,
801
+ onClick: { functionName: "markOrderRead", args: [123], open: false }
802
+ }
803
+ ]
804
+ });`
805
+ }
806
+ },
807
+ {
808
+ when: { pt: "Para clique de notificacao agendada ou com app fechado.", en: "For scheduled notification clicks or when the app is closed." },
809
+ example: {
810
+ pt: `await agendarNotificacao({
811
+ titulo: "Pedido aprovado",
812
+ texto: "Toque para abrir os detalhes",
813
+ quando: Date.now() + 60 * 1000,
814
+ aoClicar: {
815
+ funcao: "abrirForaDoApp",
816
+ argumentos: ["https://exemplo.com/pedidos/123"]
817
+ }
818
+ });`,
819
+ en: `await scheduleNotification({
820
+ title: "Order approved",
821
+ text: "Tap to open details",
822
+ when: Date.now() + 60 * 1000,
823
+ onClick: {
824
+ functionName: "openOutsideApp",
825
+ args: ["https://example.com/orders/123"]
826
+ }
682
827
  });`
683
828
  }
684
829
  },
@@ -1274,6 +1419,10 @@ const state = {
1274
1419
  lastApkPath: null,
1275
1420
  lastDistPath: null,
1276
1421
  defaultIconPath: "",
1422
+ fileTree: [],
1423
+ currentFilePath: "",
1424
+ currentFileLanguage: "text",
1425
+ currentFileDirty: false,
1277
1426
  animationTimer: null,
1278
1427
  progress: 0,
1279
1428
  logsVisible: localStorage.getItem("html2apk.logsVisible") === "true"
@@ -1313,6 +1462,7 @@ function collectElements() {
1313
1462
  "appNameInput",
1314
1463
  "packageIdInput",
1315
1464
  "versionInput",
1465
+ "buildFormatInput",
1316
1466
  "modeInput",
1317
1467
  "orientationInput",
1318
1468
  "minSdkVersionInput",
@@ -1325,9 +1475,15 @@ function collectElements() {
1325
1475
  "iconPathInput",
1326
1476
  "iconPreview",
1327
1477
  "selectIconButton",
1478
+ "keystorePathInput",
1479
+ "selectKeystoreButton",
1480
+ "keystoreAliasInput",
1481
+ "keystoreStorePasswordInput",
1482
+ "keystoreKeyPasswordInput",
1328
1483
  "settingsValidation",
1329
1484
  "settingsNextButton",
1330
1485
  "debugInput",
1486
+ "runtimeLogsInput",
1331
1487
  "releaseInput",
1332
1488
  "stepFolderText",
1333
1489
  "stepSettingsText",
@@ -1348,6 +1504,13 @@ function collectElements() {
1348
1504
  "successOpenDistButton",
1349
1505
  "successShowApkButton",
1350
1506
  "newBuildButton",
1507
+ "newFileButton",
1508
+ "saveFileButton",
1509
+ "fileTree",
1510
+ "currentFileName",
1511
+ "fileLanguageBadge",
1512
+ "fileEditorInput",
1513
+ "fileHighlight",
1351
1514
  "logConsole",
1352
1515
  "bottomLogConsole",
1353
1516
  "clearLogsButton",
@@ -1384,6 +1547,12 @@ function applyLanguage() {
1384
1547
  applyLogBarVisibility();
1385
1548
  renderPermissionOptions(selectedPermissions());
1386
1549
  renderNativeCodeGrid();
1550
+ renderFileTree();
1551
+ if (state.currentFilePath && elements.currentFileName) {
1552
+ elements.currentFileName.textContent = `${state.currentFilePath}${state.currentFileDirty ? " *" : ""}`;
1553
+ } else if (elements.currentFileName) {
1554
+ elements.currentFileName.textContent = text("noFileSelected");
1555
+ }
1387
1556
  if (state.project) {
1388
1557
  validateSettings();
1389
1558
  renderReview();
@@ -1522,6 +1691,7 @@ function updateActionButtons() {
1522
1691
  elements.nextSettingsButton.disabled = !hasProject || isBusy || !state.environmentOk;
1523
1692
  elements.doctorButton.disabled = !hasProject || isBusy;
1524
1693
  elements.settingsNextButton.disabled = !hasProject || !state.settingsValid || !state.environmentOk || isBusy;
1694
+ elements.newFileButton.disabled = !hasProject;
1525
1695
  setBuildButtons(hasProject && state.settingsValid && state.environmentOk && !isBusy);
1526
1696
  }
1527
1697
 
@@ -1592,6 +1762,182 @@ function escapeHtml(value) {
1592
1762
  .replace(/"/g, "&quot;");
1593
1763
  }
1594
1764
 
1765
+ function highlightSource(value, language) {
1766
+ let html = escapeHtml(value);
1767
+ const lang = String(language || "").toLowerCase();
1768
+
1769
+ if (["html", "xml", "svg"].includes(lang)) {
1770
+ html = html
1771
+ .replace(/(&lt;!--[\s\S]*?--&gt;)/g, "<span class=\"syntax-token-comment\">$1</span>")
1772
+ .replace(/(&lt;\/?[a-zA-Z][^&]*?&gt;)/g, "<span class=\"syntax-token-tag\">$1</span>");
1773
+ return html;
1774
+ }
1775
+
1776
+ if (["js", "mjs", "cjs", "ts", "tsx", "jsx"].includes(lang)) {
1777
+ html = html
1778
+ .replace(/\b(await|async|const|let|var|function|return|if|else|for|while|try|catch|throw|new|class|import|export|from|true|false|null|undefined)\b/g, "<span class=\"syntax-token-keyword\">$1</span>")
1779
+ .replace(/\b(\d+(?:\.\d+)?)\b/g, "<span class=\"syntax-token-number\">$1</span>")
1780
+ .replace(/(\/\*[\s\S]*?\*\/|\/\/[^\n\r]*)/g, "<span class=\"syntax-token-comment\">$1</span>")
1781
+ .replace(/(&quot;.*?&quot;|'.*?'|`[\s\S]*?`)/g, "<span class=\"syntax-token-string\">$1</span>");
1782
+ return html;
1783
+ }
1784
+
1785
+ if (lang === "css") {
1786
+ html = html
1787
+ .replace(/(\/\*[\s\S]*?\*\/)/g, "<span class=\"syntax-token-comment\">$1</span>")
1788
+ .replace(/([.#]?[a-zA-Z0-9_-]+)(\s*\{)/g, "<span class=\"syntax-token-tag\">$1</span>$2")
1789
+ .replace(/(:\s*)([^;\n]+)(;?)/g, "$1<span class=\"syntax-token-string\">$2</span>$3");
1790
+ return html;
1791
+ }
1792
+
1793
+ if (lang === "json") {
1794
+ html = html
1795
+ .replace(/(&quot;[^&]+&quot;)(\s*:)/g, "<span class=\"syntax-token-keyword\">$1</span>$2")
1796
+ .replace(/(:\s*)(&quot;.*?&quot;)/g, "$1<span class=\"syntax-token-string\">$2</span>")
1797
+ .replace(/\b(true|false|null)\b/g, "<span class=\"syntax-token-keyword\">$1</span>")
1798
+ .replace(/\b(\d+(?:\.\d+)?)\b/g, "<span class=\"syntax-token-number\">$1</span>");
1799
+ }
1800
+
1801
+ return html;
1802
+ }
1803
+
1804
+ function updateFilePreview() {
1805
+ if (!elements.fileHighlight) {
1806
+ return;
1807
+ }
1808
+
1809
+ elements.fileHighlight.innerHTML = `<code>${highlightSource(elements.fileEditorInput.value, state.currentFileLanguage)}</code>`;
1810
+ }
1811
+
1812
+ function setCurrentFileDirty(value) {
1813
+ state.currentFileDirty = Boolean(value);
1814
+ elements.saveFileButton.disabled = !state.currentFilePath || !state.currentFileDirty;
1815
+ if (state.currentFilePath) {
1816
+ elements.currentFileName.textContent = `${state.currentFilePath}${state.currentFileDirty ? " *" : ""}`;
1817
+ }
1818
+ }
1819
+
1820
+ function renderFileNodes(nodes, depth = 0) {
1821
+ return (nodes || []).map((node) => {
1822
+ const padding = 8 + (depth * 16);
1823
+ if (node.type === "directory") {
1824
+ return `
1825
+ <div class="folder-row" style="padding-left:${padding}px">${escapeHtml(node.name)}</div>
1826
+ ${renderFileNodes(node.children, depth + 1)}
1827
+ `;
1828
+ }
1829
+
1830
+ const active = node.path === state.currentFilePath ? " active" : "";
1831
+ const disabled = node.editable ? "" : " disabled";
1832
+ const marker = node.editable ? "file" : "bin";
1833
+ return `
1834
+ <button class="file-row${active}" type="button" style="padding-left:${padding}px" data-file-path="${escapeHtml(node.path)}"${disabled}>
1835
+ <span>${marker}</span>
1836
+ <strong>${escapeHtml(node.name)}</strong>
1837
+ </button>
1838
+ `;
1839
+ }).join("");
1840
+ }
1841
+
1842
+ function renderFileTree() {
1843
+ if (!elements.fileTree) {
1844
+ return;
1845
+ }
1846
+
1847
+ if (!state.project) {
1848
+ elements.fileTree.className = "file-tree-empty";
1849
+ elements.fileTree.textContent = text("chooseProjectFirst");
1850
+ return;
1851
+ }
1852
+
1853
+ if (!state.fileTree.length) {
1854
+ elements.fileTree.className = "file-tree-empty";
1855
+ elements.fileTree.textContent = text("missing");
1856
+ return;
1857
+ }
1858
+
1859
+ elements.fileTree.className = "file-tree";
1860
+ elements.fileTree.innerHTML = renderFileNodes(state.fileTree);
1861
+ }
1862
+
1863
+ async function refreshProjectFiles() {
1864
+ if (!state.project || !api.listProjectFiles) {
1865
+ renderFileTree();
1866
+ return;
1867
+ }
1868
+
1869
+ try {
1870
+ state.fileTree = await api.listProjectFiles(state.project.projectRoot);
1871
+ renderFileTree();
1872
+ } catch (error) {
1873
+ appendLog(`${text("projectWatcherFail")}: ${error.message}`, "error");
1874
+ }
1875
+ }
1876
+
1877
+ async function openProjectFile(relativePath) {
1878
+ if (!state.project || !relativePath) {
1879
+ return;
1880
+ }
1881
+
1882
+ if (state.currentFileDirty && !window.confirm(text("unsavedFileConfirm"))) {
1883
+ return;
1884
+ }
1885
+
1886
+ try {
1887
+ const file = await api.readProjectFile(state.project.projectRoot, relativePath);
1888
+ state.currentFilePath = file.path;
1889
+ state.currentFileLanguage = file.language || "text";
1890
+ elements.currentFileName.textContent = file.path;
1891
+ elements.fileLanguageBadge.textContent = state.currentFileLanguage;
1892
+ elements.fileEditorInput.disabled = false;
1893
+ elements.fileEditorInput.value = file.content || "";
1894
+ setCurrentFileDirty(false);
1895
+ updateFilePreview();
1896
+ renderFileTree();
1897
+ } catch (error) {
1898
+ appendLog(`${text("fileOpenFail")}: ${error.message}`, "error");
1899
+ setStatus("error", text("fileOpenFail"));
1900
+ }
1901
+ }
1902
+
1903
+ async function saveCurrentFile() {
1904
+ if (!state.project || !state.currentFilePath) {
1905
+ return;
1906
+ }
1907
+
1908
+ try {
1909
+ await api.writeProjectFile(state.project.projectRoot, state.currentFilePath, elements.fileEditorInput.value);
1910
+ setCurrentFileDirty(false);
1911
+ appendLog(`${text("fileSaved")}: ${state.currentFilePath}`, "success");
1912
+ await refreshProjectFiles();
1913
+ } catch (error) {
1914
+ appendLog(`${text("fileSaveFail")}: ${error.message}`, "error");
1915
+ setStatus("error", text("fileSaveFail"));
1916
+ }
1917
+ }
1918
+
1919
+ async function createNewProjectFile() {
1920
+ if (!state.project) {
1921
+ setStatus("error", text("chooseProjectFirst"));
1922
+ return;
1923
+ }
1924
+
1925
+ const relativePath = window.prompt(text("newFilePrompt"), "js/app.js");
1926
+ if (!relativePath) {
1927
+ return;
1928
+ }
1929
+
1930
+ try {
1931
+ const file = await api.createProjectFile(state.project.projectRoot, relativePath);
1932
+ appendLog(`${text("fileCreated")}: ${file.path}`, "success");
1933
+ await refreshProjectFiles();
1934
+ await openProjectFile(file.path);
1935
+ } catch (error) {
1936
+ appendLog(`${text("fileSaveFail")}: ${error.message}`, "error");
1937
+ setStatus("error", text("fileSaveFail"));
1938
+ }
1939
+ }
1940
+
1595
1941
  function recipeForCode(index) {
1596
1942
  const language = currentLanguage();
1597
1943
  const recipe = nativeCodeRecipes[index] || {};
@@ -1723,10 +2069,41 @@ function normalizeThemeMode(value) {
1723
2069
  return String(value || "").trim().toLowerCase() === "auto" ? "auto" : "fixed";
1724
2070
  }
1725
2071
 
2072
+ function normalizeBuildFormat(value) {
2073
+ return String(value || "").trim().toLowerCase() === "aab" ? "aab" : "apk";
2074
+ }
2075
+
1726
2076
  function normalizeOneSignalAppId(value) {
1727
2077
  return String(value || "").trim();
1728
2078
  }
1729
2079
 
2080
+ function keystoreFromConfig(config = {}) {
2081
+ const keystore = config.keystore && typeof config.keystore === "object" ? config.keystore : {};
2082
+ return {
2083
+ path: String(keystore.path || "").trim(),
2084
+ alias: String(keystore.alias || "").trim(),
2085
+ storePassword: String(keystore.storePassword || keystore.password || "").trim(),
2086
+ keyPassword: String(keystore.keyPassword || keystore.password || "").trim()
2087
+ };
2088
+ }
2089
+
2090
+ function keystoreFromInputs() {
2091
+ return {
2092
+ path: elements.keystorePathInput.value.trim(),
2093
+ alias: elements.keystoreAliasInput.value.trim(),
2094
+ storePassword: elements.keystoreStorePasswordInput.value,
2095
+ keyPassword: elements.keystoreKeyPasswordInput.value
2096
+ };
2097
+ }
2098
+
2099
+ function hasAnyKeystoreField(keystore) {
2100
+ return Boolean(keystore.path || keystore.alias || keystore.storePassword || keystore.keyPassword);
2101
+ }
2102
+
2103
+ function hasCompleteKeystore(keystore) {
2104
+ return Boolean(keystore.path && keystore.alias && keystore.storePassword && keystore.keyPassword);
2105
+ }
2106
+
1730
2107
  function oneSignalAppIdFromConfig(config = {}) {
1731
2108
  return normalizeOneSignalAppId(
1732
2109
  config.oneSignalAppId
@@ -1789,6 +2166,7 @@ function populateSettings(config = {}, project = state.project) {
1789
2166
  elements.appNameInput.value = config.appName || projectName || "";
1790
2167
  elements.packageIdInput.value = config.packageId || `com.html2apk.${packageSegment(projectName)}`;
1791
2168
  elements.versionInput.value = config.version || "1.0.0";
2169
+ elements.buildFormatInput.value = normalizeBuildFormat(config.buildFormat || config.outputFormat || config.artifactType || config.packageType);
1792
2170
  elements.modeInput.value = config.mode || "fullscreen";
1793
2171
  elements.orientationInput.value = normalizeOrientationInputValue(config.orientation);
1794
2172
  elements.minSdkVersionInput.value = String(normalizeMinSdkVersion(config.minSdkVersion || config.androidMinSdkVersion));
@@ -1802,14 +2180,22 @@ function populateSettings(config = {}, project = state.project) {
1802
2180
  const iconPath = String(config.icon || "").trim() || defaultIconPath();
1803
2181
  elements.iconPathInput.value = iconPath;
1804
2182
  elements.iconPreview.src = iconPreviewPath(iconPath);
2183
+ const keystore = keystoreFromConfig(config);
2184
+ elements.keystorePathInput.value = keystore.path;
2185
+ elements.keystoreAliasInput.value = keystore.alias;
2186
+ elements.keystoreStorePasswordInput.value = keystore.storePassword;
2187
+ elements.keystoreKeyPasswordInput.value = keystore.keyPassword;
1805
2188
  elements.debugInput.checked = Boolean(config.debug);
1806
- elements.releaseInput.checked = Boolean(config.release);
2189
+ elements.runtimeLogsInput.checked = Boolean(config.showRuntimeLogs || config.mostrarLogs || config.runtimeLogs || config.debugConsole || config.console);
2190
+ elements.releaseInput.checked = Boolean(config.release || elements.buildFormatInput.value === "aab");
1807
2191
  }
1808
2192
 
1809
2193
  function validateSettings() {
1810
2194
  const errors = [];
1811
2195
  const packagePattern = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
1812
2196
  const versionPattern = /^\d+\.\d+\.\d+([-.+][0-9A-Za-z.-]+)?$/;
2197
+ const buildFormat = normalizeBuildFormat(elements.buildFormatInput.value);
2198
+ const keystore = keystoreFromInputs();
1813
2199
 
1814
2200
  if (!state.project) {
1815
2201
  errors.push(text("missingProject"));
@@ -1835,6 +2221,11 @@ function validateSettings() {
1835
2221
  if (!isValidOptionalOneSignalAppId(elements.oneSignalAppIdInput.value)) {
1836
2222
  errors.push(text("invalidOneSignalAppId"));
1837
2223
  }
2224
+ if (buildFormat === "aab" && !hasCompleteKeystore(keystore)) {
2225
+ errors.push(text("missingKeystoreForAab"));
2226
+ } else if (hasAnyKeystoreField(keystore) && !hasCompleteKeystore(keystore)) {
2227
+ errors.push(text("incompleteKeystore"));
2228
+ }
1838
2229
  if (elements.iconPathInput.value.trim() && !/\.png$/i.test(elements.iconPathInput.value.trim())) {
1839
2230
  errors.push(text("invalidIconType"));
1840
2231
  }
@@ -1875,6 +2266,7 @@ function renderReview() {
1875
2266
  [text("appName"), elements.appNameInput.value.trim()],
1876
2267
  [text("packageId"), elements.packageIdInput.value.trim()],
1877
2268
  [text("appVersion"), elements.versionInput.value.trim()],
2269
+ [text("buildFormat"), selectedOptionText(elements.buildFormatInput)],
1878
2270
  [text("mode"), selectedOptionText(elements.modeInput)],
1879
2271
  [text("orientation"), selectedOptionText(elements.orientationInput)],
1880
2272
  [text("minSdkVersion"), selectedOptionText(elements.minSdkVersionInput)],
@@ -1882,7 +2274,10 @@ function renderReview() {
1882
2274
  [text("appThemeColor"), elements.themeColorTextInput.value.trim()],
1883
2275
  [text("oneSignalAppId"), elements.oneSignalAppIdInput.value.trim() || "-"],
1884
2276
  [text("androidPermissions"), selectedPermissions().join(", ")],
1885
- [text("appIcon"), displayIconValue(elements.iconPathInput.value.trim())]
2277
+ [text("appIcon"), displayIconValue(elements.iconPathInput.value.trim())],
2278
+ [text("keystoreTitle"), elements.keystorePathInput.value.trim() ? elements.keystorePathInput.value.trim() : "-"],
2279
+ [text("runtimeLogsBuild"), elements.runtimeLogsInput.checked ? text("selected") : "-"],
2280
+ [text("releaseBuild"), elements.releaseInput.checked || normalizeBuildFormat(elements.buildFormatInput.value) === "aab" ? text("selected") : "-"]
1886
2281
  ];
1887
2282
 
1888
2283
  elements.reviewGrid.innerHTML = items.map(([label, value]) => `
@@ -2081,6 +2476,7 @@ function applyProjectChange(payload) {
2081
2476
  }
2082
2477
 
2083
2478
  renderProjectSnapshot(payload.project);
2479
+ refreshProjectFiles();
2084
2480
 
2085
2481
  const reloadSettings = isConfigFilePath(payload.changedPath);
2086
2482
  if (reloadSettings && !state.buildRunning) {
@@ -2095,6 +2491,14 @@ function applyProjectChange(payload) {
2095
2491
  if (document.querySelector(".nav-item.active")?.dataset.view === "build") {
2096
2492
  renderReview();
2097
2493
  }
2494
+ if (
2495
+ state.currentFilePath &&
2496
+ payload.changedPath &&
2497
+ String(payload.changedPath).replace(/\\/g, "/").endsWith(`/${state.currentFilePath}`) &&
2498
+ !state.currentFileDirty
2499
+ ) {
2500
+ openProjectFile(state.currentFilePath);
2501
+ }
2098
2502
  }
2099
2503
 
2100
2504
  async function summarizeProject(project) {
@@ -2109,6 +2513,16 @@ async function summarizeProject(project) {
2109
2513
  elements.nextSettingsButton.disabled = false;
2110
2514
  elements.doctorButton.disabled = true;
2111
2515
  setBuildButtons(false);
2516
+ state.fileTree = [];
2517
+ state.currentFilePath = "";
2518
+ state.currentFileLanguage = "text";
2519
+ state.currentFileDirty = false;
2520
+ elements.currentFileName.textContent = text("noFileSelected");
2521
+ elements.fileLanguageBadge.textContent = "text";
2522
+ elements.fileEditorInput.value = "";
2523
+ elements.fileEditorInput.disabled = true;
2524
+ elements.fileHighlight.innerHTML = "<code></code>";
2525
+ elements.saveFileButton.disabled = true;
2112
2526
  populateSettings(project.config || {}, project);
2113
2527
  setStep("folder", project.hasEntryFile ? "done" : "active", project.hasEntryFile ? text("folderReady") : text("missing"));
2114
2528
  setStep("settings", "active", text("settingsMissing"));
@@ -2118,6 +2532,7 @@ async function summarizeProject(project) {
2118
2532
  setStatus("ready", text("projectLoaded"));
2119
2533
  appendLog(`${text("droppedFolder")}: ${project.projectRoot}`, "system");
2120
2534
  validateSettings();
2535
+ await refreshProjectFiles();
2121
2536
  setProgress(project.hasEntryFile ? 25 : 15, project.hasEntryFile ? text("progressFolder") : text("missing"), project.hasEntryFile ? "" : "error");
2122
2537
  await watchCurrentProject();
2123
2538
  await ensureEnvironmentBeforeSettings();
@@ -2155,11 +2570,14 @@ async function runDoctorOnly() {
2155
2570
  }
2156
2571
 
2157
2572
  function buildOptions() {
2158
- return {
2573
+ const buildFormat = normalizeBuildFormat(elements.buildFormatInput.value);
2574
+ const keystore = keystoreFromInputs();
2575
+ const options = {
2159
2576
  projectRoot: state.project.projectRoot,
2160
2577
  appName: elements.appNameInput.value.trim(),
2161
2578
  packageId: elements.packageIdInput.value.trim(),
2162
2579
  version: elements.versionInput.value.trim(),
2580
+ buildFormat,
2163
2581
  mode: elements.modeInput.value,
2164
2582
  orientation: elements.orientationInput.value,
2165
2583
  minSdkVersion: normalizeMinSdkVersion(elements.minSdkVersionInput.value),
@@ -2171,8 +2589,15 @@ function buildOptions() {
2171
2589
  icon: elements.iconPathInput.value.trim(),
2172
2590
  androidPlatform: elements.androidPlatformInput.value.trim(),
2173
2591
  debug: elements.debugInput.checked,
2174
- release: elements.releaseInput.checked
2592
+ showRuntimeLogs: elements.runtimeLogsInput.checked,
2593
+ release: elements.releaseInput.checked || buildFormat === "aab"
2175
2594
  };
2595
+
2596
+ if (hasAnyKeystoreField(keystore)) {
2597
+ options.keystore = keystore;
2598
+ }
2599
+
2600
+ return options;
2176
2601
  }
2177
2602
 
2178
2603
  function startAnimatedLogs() {
@@ -2360,6 +2785,9 @@ function bindEvents() {
2360
2785
  if (button.dataset.view === "build" && !goToReview()) {
2361
2786
  return;
2362
2787
  }
2788
+ if (button.dataset.view === "files") {
2789
+ await refreshProjectFiles();
2790
+ }
2363
2791
  setView(button.dataset.view);
2364
2792
  });
2365
2793
  });
@@ -2376,6 +2804,18 @@ function bindEvents() {
2376
2804
  elements.doctorButton.addEventListener("click", runDoctorOnly);
2377
2805
  elements.buildButton.addEventListener("click", runBuildFlow);
2378
2806
  elements.usbDebugButton.addEventListener("click", runUsbDebugFlow);
2807
+ elements.newFileButton.addEventListener("click", createNewProjectFile);
2808
+ elements.saveFileButton.addEventListener("click", saveCurrentFile);
2809
+ elements.fileTree.addEventListener("click", (event) => {
2810
+ const button = event.target.closest("[data-file-path]");
2811
+ if (button && !button.disabled) {
2812
+ openProjectFile(button.dataset.filePath);
2813
+ }
2814
+ });
2815
+ elements.fileEditorInput.addEventListener("input", () => {
2816
+ setCurrentFileDirty(true);
2817
+ updateFilePreview();
2818
+ });
2379
2819
  elements.clearLogsButton.addEventListener("click", clearLogs);
2380
2820
  elements.toggleLogsButton.addEventListener("click", toggleLogBar);
2381
2821
  elements.bottomToggleLogsButton.addEventListener("click", toggleLogBar);
@@ -2390,6 +2830,21 @@ function bindEvents() {
2390
2830
  appendLog(`${text("iconSelected")}: ${iconPath}`, "system");
2391
2831
  validateSettings();
2392
2832
  });
2833
+ elements.selectKeystoreButton.addEventListener("click", async () => {
2834
+ const keystorePath = await api.selectKeystore();
2835
+ if (!keystorePath) {
2836
+ return;
2837
+ }
2838
+ elements.keystorePathInput.value = keystorePath;
2839
+ appendLog(`${text("keystoreSelected")}: ${keystorePath}`, "system");
2840
+ validateSettings();
2841
+ });
2842
+ elements.buildFormatInput.addEventListener("change", () => {
2843
+ if (normalizeBuildFormat(elements.buildFormatInput.value) === "aab") {
2844
+ elements.releaseInput.checked = true;
2845
+ }
2846
+ validateSettings();
2847
+ });
2393
2848
  elements.themeColorInput.addEventListener("input", () => {
2394
2849
  elements.themeColorTextInput.value = elements.themeColorInput.value;
2395
2850
  validateSettings();
@@ -2415,12 +2870,18 @@ function bindEvents() {
2415
2870
  elements.appNameInput,
2416
2871
  elements.packageIdInput,
2417
2872
  elements.versionInput,
2873
+ elements.buildFormatInput,
2418
2874
  elements.orientationInput,
2419
2875
  elements.minSdkVersionInput,
2420
2876
  elements.androidPlatformInput,
2421
2877
  elements.themeModeInput,
2422
2878
  elements.oneSignalAppIdInput,
2879
+ elements.keystorePathInput,
2880
+ elements.keystoreAliasInput,
2881
+ elements.keystoreStorePasswordInput,
2882
+ elements.keystoreKeyPasswordInput,
2423
2883
  elements.debugInput,
2884
+ elements.runtimeLogsInput,
2424
2885
  elements.releaseInput
2425
2886
  ].forEach((input) => {
2426
2887
  input.addEventListener("input", validateSettings);