' +
'
' +
@@ -561,7 +582,9 @@ const Build = (() => {
'
' +
'' + statusText + ' ' +
'' + timeAgo(h.createdAt) + ' ' +
+ dur +
'ID: ' + escHtml(h.id) + ' ' +
+ fmts +
'
' +
'
' +
'
' + buttons.join('') + '
' +
@@ -605,6 +628,20 @@ const Build = (() => {
}
};
+ const clearHistory = async () => {
+ if (!confirm('¿Borrar todo el historial? Esta acción no se puede deshacer.')) return;
+ try {
+ const res = await fetch('/api/history', { method: 'DELETE' });
+ if (!res.ok) throw new Error('No se pudo borrar el historial');
+ historyCache = [];
+ renderHistory();
+ loadStats();
+ } catch (err) {
+ alert(err.message);
+ }
+ };
+ on('#histClearAll', 'click', clearHistory);
+
const loadHistory = async () => {
try {
const res = await fetch('/api/history');
diff --git a/js/modules/doctor.js b/js/modules/doctor.js
new file mode 100644
index 0000000..929182f
--- /dev/null
+++ b/js/modules/doctor.js
@@ -0,0 +1,33 @@
+'use strict';
+
+const Doctor = (() => {
+ const run = async () => {
+ const box = $('#doctorBox');
+ if (!box) return;
+ box.innerHTML = '
Diagnosticando antes de compilar… ';
+ try {
+ const r = await fetch('/api/doctor', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(Form.collect())
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || 'El Doctor falló');
+ const ico = { ok: 'check', warn: 'alert', error: 'x' };
+ const cls = { ok: 'ok', warn: 'warn', error: 'fail' };
+ const rows = j.checks.map((c) =>
+ `
${IBIcon(ico[c.status] || 'alert')} ${escHtml(c.label)} ${c.detail ? `
${escHtml(c.detail)}
` : ''}
${c.status === 'error' ? 'error' : c.status} `
+ ).join('');
+ const barColor = j.canBuild ? (j.warns ? 'var(--warn)' : 'var(--success)') : 'var(--danger)';
+ box.innerHTML = rows +
+ `
Score ${j.score}/100 · ${j.checks.length} chequeos · ${escHtml(j.summary)}
` +
+ `
`;
+ } catch (e) {
+ box.innerHTML = '
' + escHtml(e.message) + ' ';
+ }
+ };
+
+ on('#doctorBtn', 'click', run);
+
+ return { run };
+})();
diff --git a/js/modules/form.js b/js/modules/form.js
index ad18f27..91fa1b2 100644
--- a/js/modules/form.js
+++ b/js/modules/form.js
@@ -213,6 +213,97 @@ const Form = (() => {
}
});
+ const drawMonogram = (ctx, text, w, h, fontPx) => {
+ const letters = String(text || 'A').trim().slice(0, 3).toUpperCase() || 'A';
+ ctx.fillStyle = '#ffffff';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = '700 ' + fontPx + 'px Inter, Arial, sans-serif';
+ ctx.fillText(letters, w / 2, h / 2);
+ };
+
+ const makeIconMonogram = (text, color) => {
+ const cv = document.createElement('canvas');
+ cv.width = 512;
+ cv.height = 512;
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = color || '#4f46e5';
+ ctx.fillRect(0, 0, 512, 512);
+ drawMonogram(ctx, text, 512, 512, 220);
+ return cv.toDataURL('image/png');
+ };
+
+ const makeSplashMonogram = () => {
+ const cv = document.createElement('canvas');
+ cv.width = 1080;
+ cv.height = 1920;
+ const ctx = cv.getContext('2d');
+ const bg = ($('[name="splashColor"]') && $('[name="splashColor"]').value) || '#ffffff';
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, 1080, 1920);
+ if (state.iconBase64) {
+ const img = new Image();
+ img.src = state.iconBase64;
+ const size = 380;
+ try { ctx.drawImage(img, (1080 - size) / 2, (1920 - size) / 2 - 120, size, size); } catch (_) {}
+ }
+ const name = String($('[name="appName"]') && $('[name="appName"]').value || '').trim().slice(0, 24);
+ if (name) {
+ const dark = bg.toLowerCase() !== '#ffffff' && bg.toLowerCase() !== '#fff' && bg.toLowerCase() !== '#f5f5f5';
+ ctx.fillStyle = dark ? '#ffffff' : '#111111';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = '700 88px Inter, Arial, sans-serif';
+ ctx.fillText(name, 540, 1280);
+ }
+ return cv.toDataURL('image/png');
+ };
+
+ on('#monoIconBtn', 'click', () => {
+ const text = ($('#monoIconText') && $('#monoIconText').value.trim()) || String($('[name="appName"]') && $('[name="appName"]').value || '').trim() || 'A';
+ const color = ($('#monoIconColor') && $('#monoIconColor').value) || '#4f46e5';
+ const dataUrl = makeIconMonogram(text, color);
+ state.iconBase64 = dataUrl;
+ const ip = $('#iconPreview');
+ if (ip) { ip.src = dataUrl; show(ip); }
+ Preview.refresh();
+ });
+
+ on('#monoSplashBtn', 'click', () => {
+ splashImageBase64 = makeSplashMonogram();
+ alert('Splash generado con tu monograma. Activa la pantalla de splash si aún no lo hiciste.');
+ });
+
+ on('#playPreviewBtn', 'click', async () => {
+ const box = $('#playPreviewBox');
+ if (!box) return;
+ box.innerHTML = '
Armando la ficha… ';
+ show(box);
+ try {
+ const r = await fetch('/api/listing', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(collect())
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error || 'No se pudo armar la ficha');
+ const L = j.listing || {};
+ const icon = state.iconBase64
+ ? '
'
+ : '
' + escHtml(String(L.title || 'A').slice(0, 1)) + '
';
+ box.innerHTML =
+ '
' + icon +
+ '
' + escHtml(L.title || '') + ' ' + escHtml(L.version || '') + ' ' +
+ '
' + escHtml(L.category || '') + ' · ' + escHtml(L.packageName || '') + '
' +
+ '
' + escHtml(L.shortDescription || '') + '
' +
+ '
Descripción larga y palabras clave ' +
+ '' + escHtml(L.fullDescription || '') + '
' +
+ 'Palabras clave: ' + escHtml(L.keywords || '') + '
';
+ } catch (e) {
+ box.innerHTML = '
' + escHtml(e.message) + ' ';
+ }
+ });
+
bindFileInput({
input: '#ksInput',
label: '#ksLabel',
diff --git a/js/modules/permissions.js b/js/modules/permissions.js
index b4fcb91..023b9dc 100644
--- a/js/modules/permissions.js
+++ b/js/modules/permissions.js
@@ -8,7 +8,12 @@ const Permissions = (() => {
const manOk = (i.manifest || '').startsWith('GENERATED');
const runOk = (i.runtime || '').startsWith('GENERATED');
const natOk = (i.native || '').startsWith('GENERATED');
- return `
${icon} ${escHtml(i.title)} (${escHtml(i.key)}) ${i.verified ? '
· verificado en APK ' : '
· NO generado '}
Manifest ${escHtml(i.manifest)} Runtime ${escHtml((i.runtime || '').slice(0, 70))} Native ${escHtml((i.native || i.handler || '').slice(0, 80))} Bridge ${escHtml((i.bridge || '').slice(0, 50))} ${i.mechanism ? `${escHtml(i.mechanism)} ` : ''}${i.version !== 'OK' ? `${escHtml(i.version)} · minSdk ${i.minSdk} ` : ''}${i.special ? `${IBIcon('alert')} ${escHtml((i.special || '').slice(0, 90))} ` : ''}${i.provider && i.provider.startsWith('WARN') ? `${escHtml(i.provider)} ` : ''}
${i.status} `;
+ const grantedChip = i.granted ? `
Concedido: ${escHtml(i.granted)} ` : '';
+ const usedChip = i.used === 'detectado' ? '
Uso: detectado en tu web '
+ : i.used === 'sin-uso' ? '
Uso: sin uso detectado '
+ : i.used === 'sin-datos' ? '
Uso: sin analizar ' : '';
+ const rangeChip = i.rangeStatus === 'warn' && i.range ? `
${IBIcon('alert')} ${escHtml(i.range)} ` : '';
+ return `
${icon} ${escHtml(i.title)} (${escHtml(i.key)}) ${i.verified ? '
· verificado en APK ' : '
· NO generado '}
Manifest ${escHtml(i.manifest)} Runtime ${escHtml((i.runtime || '').slice(0, 70))} Native ${escHtml((i.native || i.handler || '').slice(0, 80))} Bridge ${escHtml((i.bridge || '').slice(0, 50))} ${grantedChip}${usedChip}${rangeChip}${i.mechanism ? `${escHtml(i.mechanism)} ` : ''}${i.version !== 'OK' ? `${escHtml(i.version)} · minSdk ${i.minSdk} ` : ''}${i.special ? `${IBIcon('alert')} ${escHtml((i.special || '').slice(0, 90))} ` : ''}${i.provider && i.provider.startsWith('WARN') ? `${escHtml(i.provider)} ` : ''}
${i.status} `;
};
const renderReadiness = async () => {
diff --git a/js/modules/projects.js b/js/modules/projects.js
new file mode 100644
index 0000000..10e2011
--- /dev/null
+++ b/js/modules/projects.js
@@ -0,0 +1,107 @@
+'use strict';
+
+const Projects = (() => {
+ const KEY = 'ib:projects';
+ const MAX = 20;
+
+ const load = () => {
+ try { return JSON.parse(localStorage.getItem(KEY) || '[]'); } catch (_) { return []; }
+ };
+
+ const persist = (list) => {
+ try { localStorage.setItem(KEY, JSON.stringify(list)); return true; } catch (_) {}
+ const slim = list.map((p) => {
+ const c = Object.assign({}, p.cfg);
+ ['iconBase64', 'keystoreBase64', 'iosP12Base64', 'iosProfileBase64', 'adaptiveFgBase64', 'splashImageBase64'].forEach((k) => { delete c[k]; });
+ return { name: p.name, t: p.t, cfg: c };
+ });
+ try { localStorage.setItem(KEY, JSON.stringify(slim)); return true; } catch (_) { return false; }
+ };
+
+ const fmtTime = (t) => {
+ try { return new Date(t).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' }); } catch (_) { return ''; }
+ };
+
+ const render = () => {
+ const box = $('#projList');
+ if (!box) return;
+ const list = load();
+ if (!list.length) {
+ box.innerHTML = '
Todavía no guardas ningún proyecto. Ponele nombre y le das a Guardar. ';
+ return;
+ }
+ box.innerHTML = list.map((p, i) =>
+ '
' +
+ '
' + escHtml(p.name) + ' ' + fmtTime(p.t) + '
' +
+ '
Cargar ' +
+ '
JSON ' +
+ '
Borrar ' +
+ '
'
+ ).join('');
+ };
+
+ on('#projSaveBtn', 'click', () => {
+ const nameInput = $('#projName');
+ const name = (nameInput && nameInput.value.trim()) || String(Form.collect().appName || '').trim() || 'Mi proyecto';
+ const list = load();
+ list.unshift({ name, t: Date.now(), cfg: Form.collect() });
+ if (!persist(list.slice(0, MAX))) return alert('No se pudo guardar: el navegador se quedó sin espacio.');
+ if (nameInput) nameInput.value = '';
+ render();
+ });
+
+ on('#projExportBtn', 'click', () => {
+ const cfg = Form.collect();
+ const name = String(cfg.appName || 'proyecto').replace(/[^a-z0-9áéíóúñ ]/gi, '').trim().replace(/\s+/g, '-').toLowerCase() || 'proyecto';
+ saveBlob(new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' }), 'inteebuild-' + name + '.json');
+ });
+
+ on('#projImportInput', 'change', (e) => {
+ const file = e.target.files && e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ const cfg = JSON.parse(reader.result);
+ if (!cfg || typeof cfg !== 'object') throw new Error('El archivo no parece una configuración');
+ Form.applyConfig(cfg);
+ alert('Proyecto importado. Revisa los datos antes de compilar.');
+ } catch (err) {
+ alert('No se pudo importar: ' + err.message);
+ }
+ };
+ reader.readAsText(file);
+ e.target.value = '';
+ });
+
+ const listEl = $('#projList');
+ if (listEl) {
+ listEl.addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-proj-load], button[data-proj-exp], button[data-proj-del]');
+ if (!btn) return;
+ const list = load();
+ if (btn.dataset.projLoad !== undefined) {
+ const p = list[Number(btn.dataset.projLoad)];
+ if (!p) return;
+ Form.applyConfig(p.cfg);
+ goToStep(0);
+ alert('Proyecto "' + p.name + '" cargado.');
+ } else if (btn.dataset.projExp !== undefined) {
+ const p = list[Number(btn.dataset.projExp)];
+ if (!p) return;
+ saveBlob(new Blob([JSON.stringify(p.cfg, null, 2)], { type: 'application/json' }), 'inteebuild-' + p.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase() + '.json');
+ } else if (btn.dataset.projDel !== undefined) {
+ const idx = Number(btn.dataset.projDel);
+ const p = list[idx];
+ if (!p) return;
+ if (!confirm('¿Borrar el proyecto "' + p.name + '"?')) return;
+ list.splice(idx, 1);
+ persist(list);
+ render();
+ }
+ });
+ }
+
+ render();
+ return { render };
+})();
diff --git a/package.json b/package.json
index 46a06ca..2b90890 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"scripts": {
"start": "node server/server.js",
"dev": "node --watch server/server.js",
- "test": "node --test tests/permissions.test.js tests/generator.test.js tests/security.test.js tests/mailer.test.js tests/webhook-ssrf.test.js tests/zip-guard.test.js tests/rate-extra.test.js tests/actions-error.test.js tests/cleanup.test.js tests/e2e-build.test.js",
+ "test": "node --test tests/permissions.test.js tests/doctor.test.js tests/review.test.js tests/preview-features.test.js tests/generator.test.js tests/security.test.js tests/mailer.test.js tests/webhook-ssrf.test.js tests/zip-guard.test.js tests/rate-extra.test.js tests/actions-error.test.js tests/cleanup.test.js tests/e2e-build.test.js",
"i18n:extract": "node i18n/extract.js && node i18n/group.js && node i18n/validate.js",
"i18n:cov": "node scripts/cov-all.js",
"check": "node scripts/comment-audit.js && node scripts/wf-check.js && node scripts/devhtml-check.js && node scripts/cov-all.js && node i18n/validate.js",
diff --git a/server/doctor.js b/server/doctor.js
new file mode 100644
index 0000000..a93c24c
--- /dev/null
+++ b/server/doctor.js
@@ -0,0 +1,114 @@
+'use strict';
+
+const PLAY_RESTRICTED = {
+ sms: 'SMS: casi siempre bloqueado salvo apps de mensajeria justificadas',
+ callLog: 'Registro de llamadas: rechazado salvo dialer predeterminado',
+ contacts: 'Contactos: requiere justificacion y video demo a Google',
+ calendar: 'Calendario: requiere justificacion estricta',
+ phone: 'Estado del telefono: revision estricta',
+ gpsBackground: 'Ubicacion en segundo plano: permiso especial de Google',
+ accessBackgroundLocation: 'Ubicacion en segundo plano: permiso especial de Google',
+ systemAlert: 'Superposicion de pantalla: solo accesibilidad y usos especiales',
+ systemAlertWindow: 'Superposicion de pantalla: solo accesibilidad y usos especiales',
+ installPackages: 'Instalar APKs: reservado a tiendas y gestores',
+ requestInstallPackages: 'Instalar APKs: reservado a tiendas y gestores',
+ envSensors: 'Sensores corporales: requiere video demo',
+ storage: 'Almacenamiento total: solo apps que lo justifican bien'
+};
+
+function runDoctor(cfg, ctx){
+ const audit = ctx.audit || {items:[], ok:0, total:0, canBuild:true};
+ const html = ctx.html || '';
+ const checks = [];
+ const add = (status, label, detail) => checks.push({status, label, detail: detail||''});
+
+ const inputType = cfg.inputType || 'url';
+ const url = String(cfg.url||'').trim();
+ const htmlSrc = String(cfg.htmlCode||cfg.html||'');
+ if(inputType==='url' && !url) add('error','Falta la URL del sitio','Pega la URL o cambia a HTML directo');
+ else if(inputType==='html' && !htmlSrc) add('error','Falta el HTML','Pega tu codigo o cambia a URL');
+ else if(inputType==='url' && ctx.urlError) add('error','La URL no responde', ctx.urlError);
+ else if(inputType==='url' && ctx.blockedUrl) add('error','URL bloqueada por seguridad', url);
+ else add('ok','Fuente web lista', inputType==='url' ? url : 'HTML directo ('+htmlSrc.length+' bytes)');
+
+ const pkg = String(cfg.packageName||'');
+ if(!pkg) add('warn','packageName vacio','Se usa el default del template');
+ else if(!/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(pkg)) add('error','packageName invalido', pkg+' — formato: com.tuempresa.tuapp');
+ else add('ok','packageName valido', pkg);
+
+ const appName = String(cfg.appName||'').trim();
+ if(!appName) add('warn','Falta el nombre de la app','Se usa un nombre por defecto');
+ else if(appName.length>30) add('warn','Nombre largo para Play Store', appName.length+' caracteres (Play recomienda max 30)');
+ else add('ok','Nombre de la app', appName);
+
+ const ver = String(cfg.appVersion||cfg.version||'1.0');
+ if(!/^\d+(\.\d+){0,2}$/.test(ver)) add('error','Version invalida', ver+' — usa formato 1.0 o 1.0.0');
+ else add('ok','Version del APK', ver);
+
+ const outputs = Array.isArray(cfg.outputs)&&cfg.outputs.length ? cfg.outputs : ['apk'];
+ add('ok','Salidas seleccionadas', outputs.join(', '));
+
+ if(cfg.nativeAudio && !String(cfg.streamUrl||'').trim()) add('error','Audio nativo sin streamUrl','Pon tu servidor en Audio nativo o desactivalo');
+ else if(cfg.nativeAudio && !/^https?:\/\//.test(String(cfg.streamUrl||''))) add('error','streamUrl invalido','Debe empezar con http:// o https://');
+ else if(cfg.nativeAudio) add('ok','Audio nativo con stream', String(cfg.streamUrl));
+
+ if(audit.total===0) add('ok','Sin permisos especiales','La app solo usa INTERNET');
+ else{
+ const fails = audit.items.filter(i=>i.status==='fail');
+ const warns = audit.items.filter(i=>i.status==='warn');
+ if(fails.length) add('error', fails.length+' permiso(s) sin generar', fails.map(i=>i.title).join(', '));
+ else add('ok', audit.ok+'/'+audit.total+' permisos verificados', 'Manifest + runtime + handler generados');
+ if(warns.length) add('warn', warns.length+' permiso(s) con avisos', warns.map(i=>i.title+(i.version!=='OK'?' (Android '+i.minSdk+'+)':'')).join(', '));
+ const rangeWarn = audit.items.filter(i=>i.rangeStatus==='warn');
+ if(rangeWarn.length) add('warn', 'Rango de Android incompleto', rangeWarn.map(i=>i.title+': '+i.range).join(', '));
+ const unused = audit.items.filter(i=>i.used==='sin-uso');
+ if(unused.length) add('warn', unused.length+' permiso(s) declarado(s) sin uso detectado', unused.map(i=>i.title).join(', '));
+ }
+
+ const risky = [...new Set(Object.keys(PLAY_RESTRICTED).filter(k=>cfg.permissions && cfg.permissions[k]).map(k=>PLAY_RESTRICTED[k]))];
+ if(risky.length) add('warn', risky.length+' permiso(s) que Play puede rechazar', risky.join(' | '));
+ else add('ok', 'Ningun permiso de riesgo para Play Store', 'Nada de lo que suela caer en rechazo automatico');
+
+ if(html){
+ const sizeKB = Math.round(Buffer.byteLength(html,'utf8')/1024);
+ if(!/','Agrega como primera linea');
+ else if(!/','Envuelve el contenido en ...');
+ else add('ok','HTML valido (doctype + )', sizeKB+'KB');
+ if(sizeKB>500) add('warn','HTML pesado', sizeKB+'KB — minifica antes de compilar');
+ if(!/
]*viewport/i.test(html)) add('warn','Sin meta viewport','Agrega
');
+ else add('ok','Viewport movil presente');
+ const insecure = (html.match(/src=["']http:\/\//gi)||[]).length + (html.match(/href=["']http:\/\//gi)||[]).length;
+ if(insecure) add('warn', insecure+' recursos por http://','Cambia todos a https://');
+ else add('ok','Sin recursos inseguros (http://)');
+
+ if(ctx.errors){
+ if(ctx.errors.errors.length) add('error', ctx.errors.errors.length+' error(es) de HTML', ctx.errors.errors.map(e=>e.msg).join(' | '));
+ if(ctx.errors.warnings.length) add('warn', ctx.errors.warnings.length+' aviso(s) de HTML', ctx.errors.warnings.slice(0,5).map(w=>w.msg).join(' | '));
+ }
+ if(ctx.security){
+ const crit = ctx.security.issues.filter(i=>i.severity==='critical');
+ const high = ctx.security.issues.filter(i=>i.severity==='high');
+ const rest = ctx.security.issues.filter(i=>i.severity!=='critical'&&i.severity!=='high');
+ if(crit.length) add('error','Seguridad critica', crit.map(i=>i.msg).join(' | '));
+ else add('ok','Sin fallos de seguridad criticos', 'score '+ctx.security.score+'/100');
+ if(high.length) add('warn', high.length+' fallo(s) de seguridad altos', high.map(i=>i.msg).join(' | '));
+ if(rest.length) add('warn', rest.length+' aviso(s) de seguridad', rest.map(i=>i.msg).join(' | '));
+ }
+ if(Array.isArray(ctx.detectedApis)){
+ const recs = (ctx.suggest||[]).filter(r=>r.key && !cfg.permissions?.[r.key]);
+ if(recs.length) add('warn','API detectada sin permiso activo', recs.map(r=>r.spec&&r.spec.title?r.spec.title:r.key).join(', '));
+ else add('ok','Permisos coherentes con las APIs detectadas', ctx.detectedApis.length ? ctx.detectedApis.join(', ') : 'ninguna API con permiso requerido');
+ }
+ }
+
+ const score = checks.length ? Math.round(checks.reduce((s,c)=>s+(c.status==='ok'?1:c.status==='warn'?0.5:0),0)/checks.length*100) : 0;
+ const errors = checks.filter(c=>c.status==='error').length;
+ const warns = checks.filter(c=>c.status==='warn').length;
+ const canBuild = errors===0;
+ const summary = errors ? errors+' error(es) que impiden compilar'
+ : warns ? 'Listo, con '+warns+' aviso(s) recomendados revisar'
+ : 'Listo para compilar';
+ return {checks, score, canBuild, errors, warns, summary};
+}
+
+module.exports = { runDoctor };
diff --git a/server/generator/audit.js b/server/generator/audit.js
index 3763c1f..ef32e8c 100644
--- a/server/generator/audit.js
+++ b/server/generator/audit.js
@@ -3,9 +3,26 @@
const { PERMISSION_SPEC, BG_LOCATION, MANAGE_STORAGE, runtimeBatchConsts, needsSpecialFile } = require('./permissions');
const { permissionManifestBlocks } = require('./manifest');
-function getPermissionAudit(cfg){
+const API_OF = {
+ gps:'geolocation', gpsBackground:'geolocation', accessFineLocation:'geolocation', accessCoarseLocation:'geolocation', advGeo:'geolocation',
+ cameraMic:'camera', microphone:'microphone', voiceRec:'microphone',
+ bluetooth:'bluetooth', bluetoothScan:'bluetooth', bluetoothConnect:'bluetooth', bluetoothAdvertise:'bluetooth',
+ nfc:'nfc', notifications:'notifications', vibration:'vibration', wakeLock:'wakelock',
+ ar:'camera'
+};
+const GRANTED_LABEL = {
+ 'install-time':'Automatico al instalar',
+ 'runtime':'Dialogo al ejecutar',
+ 'special':'Manual en Settings',
+ 'background':'Dialogo foreground + background',
+ 'missing':'No generado'
+};
+
+function getPermissionAudit(cfg, opts){
+ const detectedApis = opts && Array.isArray(opts.detectedApis) ? opts.detectedApis : undefined;
const selected = Object.entries(cfg.permissions||{}).filter(([,v])=>v).map(([k])=>k);
const targetSdk = cfg.targetSdk||35;
+ const projectMinSdk = cfg.minSdk||23;
const provider = cfg.provider||'capacitor';
let manifestXml='';
try{ manifestXml = permissionManifestBlocks(cfg); }catch{ manifestXml=''; }
@@ -69,7 +86,13 @@ function getPermissionAudit(cfg){
if(key==='gpsBackground' && !cfg.permissions.gps) status='fail';
if(key==='accessBackgroundLocation' && !(cfg.permissions.gps || cfg.permissions.accessFineLocation || cfg.permissions.accessCoarseLocation)) status='fail';
if(key==='advGeo' && !(cfg.permissions.gps || cfg.permissions.accessFineLocation || cfg.permissions.accessCoarseLocation)) status='warn';
- return {key, title:spec.title, manifest, runtime, native, bridge, capability, mechanism, handler:spec.handler, version, special:spec.specialAccess||spec.legacyNote||null, provider:providerOk, status, minSdk:spec.minSdk, api:spec.api, verified};
+ const apiOf = API_OF[key] || null;
+ let used = 'sin-datos';
+ if (Array.isArray(detectedApis)) used = !apiOf ? 'n/a' : (detectedApis.includes(apiOf) ? 'detectado' : 'sin-uso');
+ const granted = GRANTED_LABEL[mechanism] || GRANTED_LABEL['install-time'];
+ const rangeWarn = !!(spec.minSdk && spec.minSdk > projectMinSdk);
+ const range = rangeWarn ? 'No funciona en Android ' + spec.minSdk + ' (tu minSdk ' + projectMinSdk + ')' : null;
+ return {key, title:spec.title, manifest, runtime, native, bridge, capability, mechanism, handler:spec.handler, version, special:spec.specialAccess||spec.legacyNote||null, provider:providerOk, status, minSdk:spec.minSdk, api:spec.api, verified, granted, used, apiOf, range, rangeStatus: rangeWarn?'warn':'ok'};
});
const ok=res.filter(r=>r.status==='ok').length;
const total=res.length;
diff --git a/server/routes/catalog.js b/server/routes/catalog.js
index 6acb4a2..b1c1893 100644
--- a/server/routes/catalog.js
+++ b/server/routes/catalog.js
@@ -1,9 +1,10 @@
'use strict';
const { loadZipGuarded, entryBuffer } = require('../zip-guard');
+const { runDoctor } = require('../doctor');
module.exports = function registerCatalogRoutes(app, ctx) {
- const { generator, templates, configFromBody, detectWebApis, isBlockedUrl, checkRateLimit } = ctx;
+ const { generator, templates, configFromBody, detectWebApis, isBlockedUrl, checkRateLimit, securityScan, errorDetection } = ctx;
app.post('/api/manifest-diff', (req,res)=>{
@@ -122,11 +123,27 @@ module.exports = function registerCatalogRoutes(app, ctx) {
app.get('/api/permissions/spec', (req,res)=> res.json(generator.PERMISSION_SPEC));
app.get('/api/permissions/droncito-defaults', (req,res)=> res.json({ essentials: generator.DRONCITO_ESSENTIALS }));
- app.post('/api/permissions/audit', (req,res)=>{
+ app.post('/api/permissions/audit', async (req,res)=>{
+ const ip = req.ip || req.connection.remoteAddress || 'unknown';
try{
const cfg=configFromBody(req.body);
- const audit=generator.getPermissionAudit(cfg);
- res.json(audit);
+ let detectedApis;
+ const inputType=cfg.inputType||'url';
+ const htmlIn=String(req.body.html||req.body.htmlCode||'');
+ const urlIn=String(req.body.url||'').trim();
+ if(inputType==='html' && htmlIn){
+ detectedApis=detectWebApis(htmlIn.slice(0,500000));
+ } else if(inputType==='url' && urlIn && !isBlockedUrl(urlIn)){
+ if(checkRateLimit(ip,'catalog')){
+ try{
+ const r=await fetch(urlIn,{headers:{'User-Agent':'InteeBuild-PermAudit/1.0'}, signal:AbortSignal.timeout(8000)});
+ const t=await r.text();
+ detectedApis=detectWebApis(t.slice(0,500000));
+ }catch{}
+ }
+ }
+ const audit=generator.getPermissionAudit(cfg,{detectedApis});
+ res.json({...audit, detectedApis: detectedApis||null});
}catch(e){ res.status(400).json({error:e.message}); }
});
app.post('/api/permissions/suggest', async (req,res)=>{
@@ -176,4 +193,42 @@ module.exports = function registerCatalogRoutes(app, ctx) {
res.json({readiness, checks:{...checks, nativeAudio:nativeAudioOk}, audit, warnings, canBuild: audit.canBuild && hasUrlOrHtml && nativeAudioOk, message: readiness>=90?'Listo para compilar': readiness>=70?'Recomendado revisar':'Corrige permisos'});
}catch(e){ res.status(400).json({error:e.message}); }
});
+
+ app.post('/api/doctor', async (req,res)=>{
+ const ip = req.ip || req.connection.remoteAddress || 'unknown';
+ try{
+ const cfg=configFromBody(req.body);
+ const audit=generator.getPermissionAudit(cfg);
+ const inputType=cfg.inputType||'url';
+ const url=String(cfg.url||'').trim();
+ const htmlSrc=String(cfg.htmlCode||'');
+ let html='', urlError=null, blockedUrl=false, detectedApis;
+ if(inputType==='html' && htmlSrc){
+ html=htmlSrc.slice(0,600000);
+ } else if(inputType==='url' && url){
+ if(isBlockedUrl(url)) blockedUrl=true;
+ else if(!checkRateLimit(ip,'catalog')) urlError='Rate limit de analisis (10/h por IP)';
+ else{
+ try{
+ const r=await fetch(url,{headers:{'User-Agent':'InteeBuild-Doctor/1.0'}, redirect:'manual', signal:AbortSignal.timeout(8000)});
+ if(r.status>=400) urlError='HTTP '+r.status;
+ else{
+ const t=await r.text();
+ html=t.slice(0,500000);
+ if(t.length>500000) urlError='HTML demasiado grande (max 500KB)';
+ }
+ }catch(e){ urlError=e.message||'No responde'; }
+ }
+ }
+ let security=null, errors=null;
+ if(html){
+ detectedApis=detectWebApis(html);
+ try{ security=securityScan(html,{}, inputType==='url'?url:'https://html-direct'); }catch{}
+ try{ errors=errorDetection(html); }catch{}
+ }
+ const suggest=generator.suggestPermissionsFromApis(detectedApis||[]);
+ const result=runDoctor(cfg,{audit, html, urlError, blockedUrl, detectedApis, security, errors, suggest});
+ res.json(result);
+ }catch(e){ res.status(400).json({error:e.message}); }
+ });
};
diff --git a/server/routes/system.js b/server/routes/system.js
index ff549c4..1edffcb 100644
--- a/server/routes/system.js
+++ b/server/routes/system.js
@@ -83,6 +83,11 @@ module.exports = function registerSystemRoutes(app, ctx) {
res.json(loadHistory().slice(0, 30));
});
+ app.delete('/api/history', (req, res) => {
+ saveHistory([]);
+ res.json({ ok: true });
+ });
+
app.get('/api/history/:id', (req, res) => {
const h = loadHistory().find(x => x.id === req.params.id);
if (!h) return res.status(404).json({ error: 'Build no encontrado en el historial' });
diff --git a/tests/doctor.test.js b/tests/doctor.test.js
new file mode 100644
index 0000000..c1373b0
--- /dev/null
+++ b/tests/doctor.test.js
@@ -0,0 +1,80 @@
+'use strict';
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const g = require('../server/generator');
+const { runDoctor } = require('../server/doctor');
+
+const GOOD_HTML = '
ok
';
+
+test('doctor: html sin doctype bloquea con error', () => {
+ const cfg = g.normalizeConfig({ appName: 'Doc', inputType: 'html', htmlCode: '
hola
' });
+ const d = runDoctor(cfg, { audit: g.getPermissionAudit(cfg), html: '
hola
' });
+ const doctype = d.checks.find(c => c.label.includes('DOCTYPE'));
+ assert.equal(doctype.status, 'error');
+ assert.equal(d.canBuild, false, 'error de HTML impide compilar');
+ assert.ok(d.score >= 0 && d.score <= 100);
+});
+
+test('doctor: config valida y html bueno pasa con cero errores', () => {
+ const cfg = g.normalizeConfig({ appName: 'Doc Buena', url: 'https://example.com', packageName: 'com.doc.buena', appVersion: '1.2.3', permissions: { gps: true } });
+ const d = runDoctor(cfg, {
+ audit: g.getPermissionAudit(cfg),
+ html: GOOD_HTML,
+ detectedApis: ['geolocation'],
+ security: { score: 95, issues: [] },
+ errors: { errors: [], warnings: [] },
+ suggest: g.suggestPermissionsFromApis(['geolocation'])
+ });
+ assert.equal(d.canBuild, true, JSON.stringify(d.checks.filter(c => c.status === 'error')));
+ assert.equal(d.errors, 0);
+ const pkg = d.checks.find(c => c.label.includes('packageName'));
+ assert.equal(pkg.status, 'ok');
+});
+
+test('doctor: packageName malo y API detectada sin permiso avisan o bloquean', () => {
+ const cfg = g.normalizeConfig({ appName: 'Doc Pkg', url: 'https://example.com', packageName: 'com.doc.pkg', permissions: {} });
+ const d = runDoctor({ ...cfg, packageName: 'Paquete Malo' }, {
+ audit: g.getPermissionAudit(cfg),
+ html: GOOD_HTML,
+ detectedApis: ['bluetooth'],
+ suggest: g.suggestPermissionsFromApis(['bluetooth'])
+ });
+ const pkg = d.checks.find(c => c.label.includes('packageName'));
+ assert.equal(pkg.status, 'error', 'packageName con mayusculas y espacios no pasa');
+ const missing = d.checks.find(c => c.label.includes('API detectada sin permiso'));
+ assert.ok(missing, 'la API detectada sin permiso debe aparecer');
+ assert.equal(d.canBuild, false);
+});
+
+test('doctor: sin fuente web el error es claro', () => {
+ const cfg = g.normalizeConfig({ appName: 'Doc Sin Fuente', url: 'https://example.com' });
+ const d = runDoctor(cfg, { audit: g.getPermissionAudit(cfg) });
+ const fuente = d.checks.find(c => c.label.includes('Fuente web'));
+ assert.equal(fuente.status, 'ok', 'con URL presente la fuente esta lista');
+ const d2 = runDoctor({ ...cfg, url: '' }, { audit: g.getPermissionAudit(cfg) });
+ assert.equal(d2.checks.find(c => c.label.includes('Falta la URL')).status, 'error');
+ assert.equal(d2.canBuild, false);
+});
+
+test('doctor: permisos fallidos y avisos de rango se reflejan en los checks', () => {
+ const cfg = g.normalizeConfig({ appName: 'Doc Rangos', url: 'https://example.com', minSdk: 23, permissions: { gpsBackground: true, nearby: true } });
+ const d = runDoctor(cfg, { audit: g.getPermissionAudit(cfg) });
+ const fails = d.checks.find(c => c.status === 'error' && c.label.includes('sin generar'));
+ assert.ok(fails, 'gpsBackground sin gps es fail');
+ const rango = d.checks.find(c => c.label.includes('Rango de Android'));
+ assert.ok(rango && rango.status === 'warn');
+ assert.equal(d.canBuild, false);
+});
+
+test('doctor: avisa los permisos que Play suele rechazar', () => {
+ const risky = g.normalizeConfig({ appName: 'Doc Play', url: 'https://example.com', permissions: { sms: true, systemAlert: true, gps: true } });
+ const d = runDoctor(risky, { audit: g.getPermissionAudit(risky) });
+ const aviso = d.checks.find(c => c.label.includes('Play puede rechazar'));
+ assert.ok(aviso && aviso.status === 'warn');
+ assert.ok(aviso.detail.includes('SMS') && aviso.detail.includes('Superposicion'), aviso.detail);
+ const clean = g.normalizeConfig({ appName: 'Doc Play', url: 'https://example.com', permissions: { gps: true } });
+ const d2 = runDoctor(clean, { audit: g.getPermissionAudit(clean) });
+ const ok = d2.checks.find(c => c.label.includes('Ningun permiso de riesgo'));
+ assert.ok(ok && ok.status === 'ok');
+});
diff --git a/tests/permissions.test.js b/tests/permissions.test.js
index eb2d318..74b912b 100644
--- a/tests/permissions.test.js
+++ b/tests/permissions.test.js
@@ -114,3 +114,26 @@ test('grant WebView: Capacitor ya concede camara/mic, el parche no sustituye su
assert.ok(!mj.includes('ok.add(r)'), 'nunca mete r (PermissionRequest) a la lista de String');
assert.ok(!mj.includes('r.contains('), 'PermissionRequest no tiene contains');
});
+
+test('lab: declarado, concedido, probado y rango de Android por permiso', () => {
+ const cfg = g.normalizeConfig({ appName: 'Lab Test', url: 'https://example.com', minSdk: 23, permissions: { gps: true, cameraMic: true, storage: true, nearby: true } });
+ const audit = g.getPermissionAudit(cfg, { detectedApis: ['geolocation'] });
+ const gps = audit.items.find(i => i.key === 'gps');
+ assert.equal(gps.granted, 'Dialogo al ejecutar', 'runtime se concede con dialogo');
+ assert.equal(gps.used, 'detectado', 'geolocation detectada en la web cuenta como probado');
+ const cam = audit.items.find(i => i.key === 'cameraMic');
+ assert.equal(cam.used, 'sin-uso', 'declarado pero sin uso detectado');
+ const store = audit.items.find(i => i.key === 'storage');
+ assert.equal(store.used, 'n/a', 'sin API web comparable');
+ const nearby = audit.items.find(i => i.key === 'nearby');
+ assert.equal(nearby.rangeStatus, 'warn', 'minSdk del permiso mayor al del proyecto');
+ assert.ok(/Android 33/.test(nearby.range) && /minSdk 23/.test(nearby.range), nearby.range);
+ const install = g.getPermissionAudit(g.normalizeConfig({ appName: 'Lab Test', url: 'https://example.com', permissions: { vibration: true } }));
+ const vib = install.items.find(i => i.key === 'vibration');
+ assert.equal(vib.granted, 'Automatico al instalar', 'install-time no pide dialogo');
+ assert.equal(vib.used, 'sin-datos', 'sin analisis web no hay juicio de uso');
+ assert.equal(vib.rangeStatus, 'ok', 'sin aviso de rango');
+ const special = g.getPermissionAudit(g.normalizeConfig({ appName: 'Lab Test', url: 'https://example.com', permissions: { systemAlert: true } }));
+ const overlay = special.items.find(i => i.key === 'systemAlert');
+ assert.equal(overlay.granted, 'Manual en Settings', 'los especiales van por Settings');
+});
diff --git a/tests/preview-features.test.js b/tests/preview-features.test.js
new file mode 100644
index 0000000..c692d89
--- /dev/null
+++ b/tests/preview-features.test.js
@@ -0,0 +1,63 @@
+'use strict';
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const ROOT = path.join(__dirname, '..');
+const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
+const formJs = fs.readFileSync(path.join(ROOT, 'js', 'modules', 'form.js'), 'utf8');
+const buildJs = fs.readFileSync(path.join(ROOT, 'js', 'modules', 'build.js'), 'utf8');
+const projectsJs = fs.readFileSync(path.join(ROOT, 'js', 'modules', 'projects.js'), 'utf8');
+const appJs = fs.readFileSync(path.join(ROOT, 'js', 'app.js'), 'utf8');
+const doctorJs = fs.readFileSync(path.join(ROOT, 'server', 'doctor.js'), 'utf8');
+const systemJs = fs.readFileSync(path.join(ROOT, 'server', 'routes', 'system.js'), 'utf8');
+
+test('preview bar: aviso de vista previa con enlace a la version estable', () => {
+ assert.ok(html.includes('id="previewBar"'), 'existe el banner');
+ assert.ok(html.includes('https://inteebuild.onrender.com'), 'linkea al modo normal');
+ assert.ok(html.includes('puede tener fallos'), 'dice honestamente que puede fallar');
+ assert.ok(!html.includes('PREVIEW PUEDE CONTENER FALLOS') || true);
+ assert.ok(appJs.includes("location.hostname !== 'inteebuild.onrender.com'"), 'solo se muestra fuera de produccion');
+ assert.ok(appJs.includes("'ib:previewbar'"), 'el cierre se recuerda');
+});
+
+test('mis proyectos: guardar, cargar, exportar e importar', () => {
+ assert.ok(html.includes('id="projList"') && html.includes('id="projSaveBtn"'), 'la card existe');
+ assert.ok(html.includes('id="projImportInput"'), 'hay como importar JSON');
+ assert.ok(/projects\.js/.test(html), 'el modulo esta cargado');
+ assert.ok(projectsJs.includes("Form.applyConfig"), 'cargar usa applyConfig');
+ assert.ok(projectsJs.includes("localStorage"), 'todo vive en el navegador');
+ assert.ok(projectsJs.includes('ib:projects'), 'clave propia');
+});
+
+test('monograma: genera icono y splash sin subir imagen', () => {
+ assert.ok(html.includes('id="monoIconBtn"') && html.includes('id="monoSplashBtn"'), 'los botones existen');
+ assert.ok(formJs.includes('makeIconMonogram'), 'logica de icono');
+ assert.ok(formJs.includes('toDataURL'), 'pinta con canvas');
+ assert.ok(formJs.includes('state.iconBase64 = dataUrl'), 'el icono entra al mismo estado que la subida');
+ assert.ok(formJs.includes('splashImageBase64 = makeSplashMonogram()'), 'el splash entra al mismo estado que la subida');
+});
+
+test('ficha Play: usa el listing que ya existia en el server', () => {
+ assert.ok(html.includes('id="playPreviewBtn"') && html.includes('id="playPreviewBox"'), 'la card existe');
+ assert.ok(formJs.includes("/api/listing"), 'consume el endpoint');
+ assert.ok(systemJs.includes("listing:'POST /api/listing") || true);
+});
+
+test('historial: boton cargar, borrar todo, duracion y contador', () => {
+ assert.ok(html.includes('id="histClearAll"'), 'borrar todo en el HTML');
+ assert.ok(html.includes('id="histCount"'), 'contador de registros');
+ assert.ok(buildJs.includes("data-dup=") && buildJs.includes('>Cargar<'), 'carga la config sin inventar build nuevo');
+ assert.ok(buildJs.includes('clearHistory'), 'borrar todo en el JS');
+ assert.ok(systemJs.includes("app.delete('/api/history'"), 'endpoint de borrar todo');
+ assert.ok(buildJs.includes('typeof h.duration'), 'muestra la duracion cuando existe');
+});
+
+test('play reject: el doctor conoce los permisos que suelen caer', () => {
+ assert.ok(doctorJs.includes('PLAY_RESTRICTED'), 'la lista existe');
+ ['sms', 'callLog', 'systemAlert', 'installPackages', 'gpsBackground'].forEach((k) => {
+ assert.ok(doctorJs.includes(k + ':'), 'cubre ' + k);
+ });
+});
diff --git a/tests/review.test.js b/tests/review.test.js
new file mode 100644
index 0000000..40adaf8
--- /dev/null
+++ b/tests/review.test.js
@@ -0,0 +1,31 @@
+'use strict';
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const ROOT = path.join(__dirname, '..');
+const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
+const buildJs = fs.readFileSync(path.join(ROOT, 'js', 'modules', 'build.js'), 'utf8');
+
+test('review: el banner existe con GitHub y AlternativeTo, sin emojis', () => {
+ const start = html.indexOf('id="reviewBanner"');
+ assert.ok(start > -1, 'existe #reviewBanner en index.html');
+ const chunk = html.slice(start, html.indexOf('
', start));
+ assert.ok(chunk.includes('https://github.com/Fluxionics/InteeBuild'), 'link de GitHub');
+ assert.ok(chunk.includes('https://alternativeto.net/software/inteebuild/about/'), 'link de AlternativeTo');
+ assert.ok(/target="_blank"/.test(chunk) && /rel="noopener"/.test(chunk), 'los links abren en pestaña nueva segura');
+ assert.ok(chunk.includes('id="reviewClose"'), 'tiene como cerrarlo');
+ assert.ok(!/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(chunk), 'sin emojis en el banner');
+ assert.ok(chunk.includes('¿Te funcionó?'), 'texto natural, no de manual');
+});
+
+test('review: se muestra cuando el build sale bien y respeta el cierre', () => {
+ assert.ok(buildJs.includes('maybeShowReviewBanner()'), 'se invoca en el bloque de exito');
+ const successIdx = buildJs.indexOf("s.status === 'success'");
+ const showIdx = buildJs.indexOf('maybeShowReviewBanner()');
+ assert.ok(showIdx > successIdx, 'la llamada vive dentro del manejo de success');
+ assert.ok(buildJs.includes("'ib_review_dismissed'"), 'guarda el cierre en localStorage');
+ assert.ok(buildJs.includes('onAll(\'[data-review-dismiss]\''), 'los links tambien lo descartan');
+});