// Devseis Phone Check: browser detection + guided questions -> evidence (mobile-core.js) -> findings written by // the auditor model in WebLLM (or the built-in writer) -> report the user can save or share as PDF. // Nothing about the phone leaves the browser; only the model and the vulnerability data are downloaded. import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer } from '../app/renderer/auditor-core.js'; import { buildReportHtml } from '../app/renderer/report.js'; import { loadModelWith } from '../app/renderer/model-core.js'; import { buildMobileEvidence, questionsFor } from './mobile-core.js'; import { detectDevice } from './detect.js'; import { loadPhoneVulnData } from './vuln-web.js'; const WEBLLM_URL = 'https://esm.run/@mlc-ai/web-llm@0.2.85'; const HTML2PDF_URL = 'https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js'; const MODEL_SIZE = 'about 350 MB, downloaded once'; const $ = id => document.getElementById(id); const show = id => { document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); $(`s-${id}`).classList.remove('hidden'); window.scrollTo(0, 0); }; const setBar = (id, fraction) => { $(id).style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`; }; const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const state = { device: null, data: null, lib: null, steps: [], index: 0, answers: {}, aiApps: [], version: null, versionEdited: false, model: null, modelPromise: null, modelStatus: '', vulnPromise: null, report: null }; function readConf(text) { const conf = {}; for (const line of text.split('\n')) { if (!line.includes('=') || line.trimStart().startsWith('#')) continue; const i = line.indexOf('='); conf[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^"|"$/g, ''); } return conf; } // Organisation answers can be passed by the employer in the link: #org= function orgFromLink() { const m = /[#&]org=([^&]+)/.exec(location.hash); if (!m) return null; try { return JSON.parse(atob(decodeURIComponent(m[1]).replace(/-/g, '+').replace(/_/g, '/'))); } catch { return null; } } async function loadData() { const get = async (path, type = 'json') => { const r = await fetch(path); if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`); return type === 'json' ? r.json() : r.text(); }; const [catalog, questions, controls, guidance, aiTools, baselineText] = await Promise.all([ get('catalog/checks.json'), get('catalog/mobile_questions.json'), get('library/controls.json'), get('library/check_guidance.json'), get('library/ai_tools.json'), get('baseline.conf', 'text')]); return { catalog, questions, baseline: readConf(baselineText), library: { catalog, controls, guidance, aiTools } }; } // --- 1. Start --------------------------------------------------------------------------------- function renderDevice() { const d = state.device; const rows = [['Device', d.device_model || (d.os_family ? 'Phone' : 'Computer')], ['System', d.os_name ? `${d.os_name} ${d.os_version || '(version not visible)'}` : 'Not a phone'], ['Browser', d.browser], ['Screen lock', d.platform_authenticator === true ? 'Unlock method detected' : d.platform_authenticator === false ? 'Not detected (will ask)' : 'Unknown (will ask)'], ['AI on this device', d.webgpu ? 'Supported (WebGPU)' : 'Not supported; the built-in writer is used']]; $('device-card').innerHTML = rows.map(([k, v]) => `${esc(k)}${esc(v)}`).join(''); $('desktop-note').classList.toggle('hidden', !d.desktop); if (d.desktop && !d.os_family) { // On a computer, lead with the desktop app: a web page cannot read a computer's settings. const mac = /Macintosh|Mac OS X/.test(navigator.userAgent), win = /Windows/.test(navigator.userAgent); const system = mac ? 'macOS' : win ? 'Windows' : 'Linux'; $('hero-title').textContent = 'Audit this computer'; $('hero-lead').innerHTML = 'A free, read-only audit against ISO 27001, GDPR and the EU AI Act. The Devseis AI auditor writes the report on your computer; nothing about it is uploaded.'; $('device-card').innerHTML = [['Device', 'Computer'], ['System', system], ['Browser', d.browser]].map(([k, v]) => `${esc(k)}${esc(v)}`).join(''); $('facts').classList.add('hidden'); $('start').classList.add('hidden'); $('dl-desktop').textContent = `Download the desktop app for ${mac ? 'Mac' : system}${win ? ' (beta)' : ''}`; $('dl-desktop').href = `download.html#${mac ? 'mac' : win ? 'windows' : 'linux'}`; $('desktop-other').innerHTML = (mac ? 'macOS 13 or later, Apple silicon and Intel.' : win ? 'Windows 10 / 11, 64-bit. Beta: not yet tested on a real Windows PC.' : 'x86_64 AppImage or .deb, tested on Ubuntu 24.04.') + ' All downloads and install steps.'; } else if (d.os_family) { $('hero-title').textContent = 'Is this phone ready for work?'; $('hero-lead').innerHTML = 'A free check against ISO 27001, GDPR and the EU AI Act, written by the Devseis AI auditor inside your browser.'; $('facts').classList.remove('hidden'); $('start').classList.remove('hidden'); } $('start').disabled = !d.os_family; } function demo(osFamily) { Object.assign(state.device, osFamily === 'ios' ? { os_family: 'ios', os_name: 'iOS', os_version: null, version_precise: false, device_model: 'iPhone (demo on a computer)', platform_authenticator: null } : { os_family: 'android', os_name: 'Android', os_version: null, version_precise: false, device_model: 'Android phone (demo on a computer)', platform_authenticator: null }); renderDevice(); } // --- 2. Approval -------------------------------------------------------------------------------- function showConsent() { const { catalog } = state.data; const os = state.device.os_family; const checks = catalog.checks.filter(c => c.os.includes(os)); $('check-count').textContent = checks.length; $('plan').innerHTML = checks.map(c => `
  • ${esc((c.names && c.names[os]) || c.name)}
  • `).join(''); const ai = $('use-ai'); ai.disabled = !state.device.webgpu; ai.checked = state.device.webgpu && !state.device.cellular; $('ai-note').textContent = !state.device.webgpu ? 'This browser has no WebGPU, so the built-in report writer is used.' : `Runs on this phone (${MODEL_SIZE}; Wi-Fi recommended). Off = built-in report writer.`; show('consent'); } function startBackgroundWork() { state.vulnPromise = loadPhoneVulnData(); if ($('use-ai').checked) { const chip = $('model-chip'); chip.classList.remove('hidden'); chip.textContent = 'AI auditor: starting download'; const version = fetch('app/model-version.json', { cache: 'no-cache' }).then(r => r.json()).then(j => j.version).catch(() => undefined); state.modelPromise = Promise.all([import(WEBLLM_URL), version]) .then(([webllm, v]) => loadModelWith(webllm, p => { chip.textContent = `AI auditor: ${Math.round((p.progress || 0) * 100)}%`; }, 'compact', v)) .then(model => { state.model = model; chip.textContent = 'AI auditor ready'; return model; }) .catch(error => { chip.textContent = 'AI unavailable: built-in writer'; state.modelStatus = error.message; return null; }); } else { state.modelPromise = Promise.resolve(null); } } // --- 3. Questions ------------------------------------------------------------------------------- function buildSteps() { const { catalog, questions } = state.data; const d = state.device; const steps = [{ kind: 'version', ...questions.os_version[d.os_family] }]; for (const q of questionsFor(d.os_family, catalog, questions, { platform_authenticator: d.platform_authenticator })) steps.push({ kind: 'check', ...q }); steps.push({ kind: 'ai', name: 'AI software inventory', ...questions.ai_apps }); return steps; } function renderStep() { const step = state.steps[state.index]; const total = state.steps.length; setBar('q-bar', state.index / total); $('q-count').textContent = `Question ${state.index + 1} of ${total}`; $('q-check').textContent = step.kind === 'version' ? 'System version' : step.name; $('q-text').textContent = step.question; $('q-where').textContent = step.where; const box = $('q-answers'); box.innerHTML = ''; const next = () => { state.index += 1; state.index < total ? renderStep() : finish(); }; if (step.kind === 'version') { const input = document.createElement('input'); input.type = 'text'; input.inputMode = 'decimal'; input.placeholder = state.device.os_family === 'ios' ? 'for example 27.0.1' : 'for example 15'; input.value = state.version || state.device.os_version || ''; const hint = document.createElement('p'); hint.className = 'muted'; hint.textContent = state.device.os_version ? `Detected by the browser: ${state.device.os_version}. Correct it if Settings shows more detail.` : 'The browser could not see the version; please type it.'; const ok = button('Continue', 'primary', () => { const value = input.value.trim(); if (!/^\d+(\.\d+){0,2}$/.test(value)) { input.focus(); return; } state.version = value; state.versionEdited = value !== state.device.os_version; next(); }); box.append(hint, input, ok); return; } if (step.kind === 'ai') { for (const app of step.options) { const label = document.createElement('label'); label.className = 'multi'; label.innerHTML = `${esc(app)}`; box.append(label); } box.append(button('Continue', 'primary', () => { state.aiApps = [...box.querySelectorAll('input:checked')].map(i => i.value); next(); })); return; } if (step.input === 'month') { const input = document.createElement('input'); input.type = 'month'; input.value = typeof state.answers[step.id] === 'string' && state.answers[step.id] !== 'not_sure' ? state.answers[step.id] : ''; box.append(input, button('Continue', 'primary', () => { if (!/^\d{4}-\d{2}$/.test(input.value)) { input.focus(); return; } state.answers[step.id] = input.value; next(); })); } else { step.answers.forEach((answer, i) => box.append(button(answer.label, 'secondary', () => { state.answers[step.id] = i; next(); }))); } box.append(button(state.data.questions.not_sure.label, 'secondary not-sure', () => { state.answers[step.id] = 'not_sure'; next(); })); } function button(text, className, onClick) { const b = document.createElement('button'); b.className = className; b.textContent = text; b.type = 'button'; b.addEventListener('click', onClick); return b; } // --- 4. Evidence and report ----------------------------------------------------------------------- async function finish() { show('work'); const d = state.device; $('w-step').textContent = 'Checking the vulnerability data'; setBar('w-bar', 0.02); const vuln = await state.vulnPromise; const now = new Date(); const offset = -now.getTimezoneOffset(); const pad = n => String(Math.abs(n)).padStart(2, '0'); const started = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${offset >= 0 ? '+' : '-'}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`; const input = { os_family: d.os_family, os_name: d.os_name, os_version: state.version, version_source: state.versionEdited || !d.os_version ? 'user' : 'browser', browser: d.browser, device_model: d.device_model || (d.os_family === 'ios' ? 'iPhone' : 'Android phone'), hostname: d.device_model || d.os_name, started_at: started, platform_authenticator: d.platform_authenticator, answers: state.answers, ai_apps: state.aiApps, org: orgFromLink(), }; const evidence = buildMobileEvidence(input, { catalog: state.data.catalog, questions: state.data.questions, baseline: state.data.baseline, vuln, now: now.getTime() }); const evidenceText = JSON.stringify(evidence, null, 2); const sha256 = [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(evidenceText)))].map(b => b.toString(16).padStart(2, '0')).join(''); if (state.modelPromise && !state.model) { $('w-title').textContent = 'Getting the AI auditor ready'; $('w-step').textContent = 'Finishing the model download (only the first time)…'; await state.modelPromise; } const model = state.model; const lib = state.lib; const targets = evidence.results.filter(r => ['Non-Compliant', 'Error', 'Pending'].includes(r.status) && r.section !== 'org'); const ai = aiPrompt(evidence, lib); const total = targets.length + 1 + (ai ? 1 : 0); let done = 0; const sources = { model: 0, template: 0 }; const generate = model ? model.generate : null; const tick = label => { done += 1; setBar('w-bar', done / total); $('w-current').textContent = label; }; $('w-title').textContent = 'Writing your report'; $('w-step').textContent = model ? `The AI auditor is writing ${total} parts on this phone` : 'The built-in writer is writing the findings'; const findings = {}; for (const r of evidence.results.filter(x => x.section === 'org' && x.status !== 'Compliant')) { findings[r.id] = findingTemplate(evidence, r, lib); // organisational answers: short, fixed wording } for (const r of targets) { $('w-current').textContent = `Writing: ${r.name}`; const out = await writeAnswer('finding', findingPrompt(evidence, r, lib), findingTemplate(evidence, r, lib), generate); findings[r.id] = out.answer; sources[out.source] += 1; tick(`Written: ${r.name}`); } const summaryBase = summaryTemplate(evidence, lib); const summaryOut = await writeAnswer('summary', summaryPrompt(evidence, lib), summaryBase, generate); const summary = summaryOut.source === 'model' ? { ...summaryBase, summary: summaryOut.answer.summary, next_steps: summaryOut.answer.next_steps } : summaryBase; sources[summaryOut.source] += 1; tick('Written: summary'); let aiResult = null; if (ai) { const out = await writeAnswer('ai_classification', ai, aiTemplate(evidence, lib), generate); aiResult = out.answer; sources[out.source] += 1; tick('Written: AI app classification'); } const html = buildReportHtml({ evidence, findings, summary, ai: aiResult, sha256, model: model ? model.name : null, sources }); state.report = { html, evidence, evidenceText, summary, sources }; showResult(); } // --- 5. Result, PDF and sharing ---------------------------------------------------------------- function showResult() { const { evidence, summary, sources } = state.report; const pills = Object.entries(evidence.summary).filter(([, v]) => v).map(([k, v]) => `${v} ${k}`).join(''); const gaps = summary.top_gaps.map(g => `
  • ${esc(g.risk)} — ${esc(g.name)}: ${esc(g.found)}
  • `).join(''); const reported = evidence.results.filter(r => r.evidence === 'self-reported').length; const detected = evidence.results.filter(r => r.evidence === 'detected').length; $('result').innerHTML = `

    Your phone check

    ${summary.score.percent}%

    ${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant · ${detected} detected, ${reported} self-reported

    ${pills}

    ${esc(summary.summary)}

    ${gaps ? `Top gaps` : ''}

    Written ${sources.model ? `by the AI auditor (${sources.model}) and ` : ''}by the built-in writer (${sources.template}).

    `; $('pdf-note').textContent = 'The report stays on this phone until you save or share it.'; try { localStorage.setItem('devseis.lastAudit', state.report.evidenceText); } catch { /* private mode */ } if (!document.getElementById('ask-assistant')) { document.querySelector('#s-done .actions').insertAdjacentHTML('afterbegin', '💬 Ask Arx about this report'); } show('done'); } function loadScript(src) { return new Promise((resolve, reject) => { if ([...document.scripts].some(s => s.src === src)) return resolve(); const s = document.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('could not load the PDF tool')); document.head.append(s); }); } async function makePdf() { await loadScript(HTML2PDF_URL); const doc = new DOMParser().parseFromString(state.report.html, 'text/html'); const holder = document.createElement('div'); holder.style.cssText = 'position:fixed;left:-10000px;top:0;width:794px;background:#fff;color:#16181d'; const style = document.createElement('style'); style.textContent = doc.querySelector('style').textContent.replace(/(^|\n)\s*body\s*\{/g, '$1.devseis-report {'); const body = document.createElement('div'); body.className = 'devseis-report'; body.innerHTML = doc.body.innerHTML; holder.append(style, body); document.body.append(holder); try { return await window.html2pdf().set({ margin: 10, image: { type: 'jpeg', quality: 0.92 }, html2canvas: { scale: 1.5, useCORS: true }, jsPDF: { unit: 'mm', format: 'a4' }, pagebreak: { mode: ['css', 'legacy'] } }).from(body).outputPdf('blob'); } finally { holder.remove(); } } function fileName(ext) { const e = state.report.evidence; return `devseis-phone-check-${e.host.os_family}-${e.audit.started_at.slice(0, 10)}.${ext}`; } function download(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 30000); } async function sharePdf() { $('pdf-note').textContent = 'Preparing the PDF…'; try { const blob = await makePdf(); const file = new File([blob], fileName('pdf'), { type: 'application/pdf' }); if (navigator.canShare && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], title: 'Arx phone check by Devseis' }); $('pdf-note').textContent = 'Shared. Choose "Save to Files" in the share sheet to keep a copy on the phone.'; return; } catch (error) { if (error.name === 'AbortError') { $('pdf-note').textContent = 'Sharing cancelled.'; return; } } } download(blob, file.name); $('pdf-note').textContent = `Saved as ${file.name} (Downloads).`; } catch (error) { $('pdf-note').textContent = `The PDF could not be made (${error.message}). Use "View full report" and your browser's Share › Print to save it as PDF.`; } } function viewReport() { const url = URL.createObjectURL(new Blob([state.report.html], { type: 'text/html' })); window.open(url, '_blank'); setTimeout(() => URL.revokeObjectURL(url), 60000); } // --- Wiring ---------------------------------------------------------------------------------------- async function init() { try { [state.device, state.data] = await Promise.all([detectDevice(), loadData()]); } catch (error) { $('device-card').innerHTML = `The check could not start: ${esc(error.message)}`; return; } state.lib = new Library(state.data.library); renderDevice(); } $('start').addEventListener('click', showConsent); document.querySelectorAll('[data-demo]').forEach(b => b.addEventListener('click', () => demo(b.dataset.demo))); $('consent-back').addEventListener('click', () => show('start')); $('consent-ok').addEventListener('click', () => { startBackgroundWork(); state.steps = buildSteps(); state.index = 0; show('question'); renderStep(); }); $('q-back').addEventListener('click', () => { if (state.index > 0) { state.index -= 1; renderStep(); } else show('consent'); }); $('share-pdf').addEventListener('click', sharePdf); $('view-report').addEventListener('click', viewReport); $('save-json').addEventListener('click', () => download(new Blob([state.report.evidenceText], { type: 'application/json' }), fileName('json'))); $('again').addEventListener('click', () => { state.answers = {}; state.aiApps = []; state.version = null; show('start'); }); init(); // Automated tests only (?test in the URL): expose state and the PDF maker. if (new URLSearchParams(location.search).has('test')) Object.assign(window, { __devseisState: state, __devseisMakePdf: makePdf });