Arx / app /renderer /app.js
umer-wasim's picture
Arx 0.5.6: password/lockout fixes on Mac, Linux, Windows; Fix problems on Windows (fix.ps1); Linux pwquality read order
458b1d7 verified
Raw History Blame Contribute Delete
24.9 kB
// Screen flow: prepare model -> approval -> live progress -> findings -> results + save popup (60 s auto-save).
import { Library, findingPrompt, findingTemplate, summaryPrompt, summaryTemplate, aiPrompt, aiTemplate, writeAnswer } from './auditor-core.js';
import { buildReportHtml } from './report.js';
const $ = id => document.getElementById(id);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const SAVE_TIMEOUT_SECONDS = 60;
let plan = null, lib = null, fixes = {}, model = null, lastReport = null, countdownTimer = null, lastEvidence = null, pendingFix = null;
function show(name) {
for (const screen of document.querySelectorAll('.screen')) screen.classList.toggle('hidden', screen.id !== `screen-${name}`);
}
function setBar(id, fraction) { $(id).style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`; }
function stopped(title, text) { $('stopped-title').textContent = title; $('stopped-text').textContent = text; show('stopped'); }
// 1. Prepare the auditor model (downloaded once, then cached by WebLLM)
async function prepare() {
show('model');
plan = await window.devseis.plan();
const libraryData = await window.devseis.library();
lib = new Library(libraryData);
fixes = libraryData.fixes || {};
$('device').textContent = `${plan.hostname} · ${plan.platform}`;
if (!model && !new URLSearchParams(location.search).has('smoke')) {
try {
const { loadModel } = await import('./model.js');
$('model-name').textContent = 'Devseis auditor model';
$('model-status').textContent = 'Downloading the auditor model (first run only)…';
const version = await window.devseis.modelVersion();
model = await loadModel(({ progress, text }) => {
setBar('model-bar', progress);
$('model-percent').textContent = `${Math.round(progress * 100)}%`;
$('model-status').textContent = text;
}, version);
$('model-name').textContent = model.name + (model.fineTuned ? '' : ' (base model)');
$('model-status').textContent = 'The auditor is ready and runs offline on this computer.';
setBar('model-bar', 1);
$('model-percent').textContent = '100%';
await sleep(800);
} catch (error) {
model = null;
$('model-name').textContent = 'Built-in report writer';
$('model-percent').textContent = '';
$('model-status').textContent = `${error.message} The audit will use the built-in report writer instead.`;
await sleep(2500);
}
}
showApproval();
}
// 2. Approval: show everything that will be checked (row 1: baseline + GDPR, row 2: AI Act + organisation)
const SECTION_INFO = {
baseline: { icon: '🛡️', title: 'Security baseline', sub: 'Passwords, accounts, antivirus, firewall, screen lock, USB, encryption, updates' },
gdpr: { icon: '🔐', title: 'GDPR technical controls', sub: 'Updates, logging, time, backup, guest and remote access, diagnostics' },
aiact: { icon: '🤖', title: 'EU AI Act', sub: 'AI tools on this computer and how they are governed' },
org: { icon: '🏢', title: 'Organisation', sub: 'Company records and policies, answered in organisational.conf' },
};
function showApproval() {
$('plan').innerHTML = '';
const total = plan.sections.reduce((n, s) => n + s.checks.length, 0);
$('plan-total').textContent = `${total} checks in ${plan.sections.length} groups`;
for (const section of plan.sections) {
const info = SECTION_INFO[section.id] || { icon: '•', title: section.title, sub: '' };
const box = document.createElement('div');
box.className = `section section-${section.id}`;
box.innerHTML = `<div class="section-head"><span class="section-icon"></span><div class="section-titles"><h3></h3><p></p></div><span class="count"></span></div><ul></ul>`;
box.querySelector('.section-icon').textContent = info.icon;
box.querySelector('h3').textContent = info.title;
box.querySelector('p').textContent = info.sub;
box.querySelector('.count').textContent = `${section.checks.length} checks`;
const list = box.querySelector('ul');
for (const check of section.checks) {
const item = document.createElement('li');
item.textContent = check.name;
list.appendChild(item);
}
$('plan').appendChild(box);
}
show('approve');
}
// 3. Run the audit and show live progress. fix = { action: 'apply'|'undo', ids }: fix first, then audit again.
async function runAudit(fix = null) {
const elevated = fix ? true : $('elevated').checked;
show('progress');
$('progress-title').textContent = fix ? (fix.action === 'undo' ? 'Undoing fixes and checking again' : 'Fixing and checking again') : 'Auditing this computer';
$('progress-step').textContent = 'Collecting evidence';
$('progress-current').textContent = elevated ? 'Waiting for administrator approval…' : 'Starting…';
setBar('progress-bar', 0);
const rows = new Map();
const order = [];
$('checklist').innerHTML = '';
for (const section of plan.sections) {
for (const check of section.checks) {
const row = document.createElement('div');
row.className = 'check';
row.innerHTML = '<span class="icon"></span><span class="name"></span><span class="group"></span>';
row.querySelector('.name').textContent = check.name;
row.querySelector('.group').textContent = section.title.replace(' CONTROLS', '').replace('COMPLIANCE RESULTS', 'BASELINE');
$('checklist').appendChild(row);
rows.set(check.id, row);
order.push(check.id);
}
}
const total = order.length;
let finished = 0;
$('progress-count').textContent = `0 / ${total}`;
const markRunning = () => { const next = order.find(id => !rows.get(id).classList.contains('done')); if (next) rows.get(next).classList.add('running'); };
const markDone = (id, status) => {
const row = rows.get(id);
if (!row || row.classList.contains('done')) return;
row.classList.remove('running');
row.classList.add('done', `s-${status}`);
row.querySelector('.group').textContent = status;
finished += 1;
$('progress-count').textContent = `${finished} / ${total}`;
setBar('progress-bar', finished / total);
};
const unsubscribe = window.devseis.onProgress(item => {
markDone(item.id, item.status);
$('progress-current').textContent = `Checked: ${item.name}`;
markRunning();
});
let result;
try {
result = await window.devseis.runAudit(elevated, fix);
} catch (error) {
unsubscribe();
const cancelled = /cancelled/i.test(error.message);
stopped(cancelled ? (fix ? 'Fix cancelled' : 'Audit cancelled') : 'The audit stopped', cancelled ? 'Administrator approval was not given. Nothing was checked and nothing was changed.' : error.message);
return;
}
unsubscribe();
const evidence = result.evidence;
for (const r of evidence.results) markDone(r.id, r.status);
// 4. Write the findings (AI model when available, otherwise the built-in writer)
const targets = evidence.results.filter(r => ['Non-Compliant', 'Error', 'Pending'].includes(r.status));
const ai = aiPrompt(evidence, lib);
const steps = targets.length + 1 + (ai ? 1 : 0);
let written = 0;
const sources = { model: 0, template: 0 };
const generate = model ? model.generate : null;
const tick = label => {
written += 1;
$('progress-count').textContent = `${written} / ${steps}`;
setBar('progress-bar', written / steps);
$('progress-current').textContent = label;
};
$('progress-title').textContent = 'Writing the audit report';
$('progress-step').textContent = model ? 'AI auditor is writing findings' : 'Writing findings';
$('progress-count').textContent = `0 / ${steps}`;
setBar('progress-bar', 0);
const findings = {}, findingSource = {};
for (const r of targets) {
$('progress-current').textContent = `Writing: ${r.name}`;
const { answer, source } = await writeAnswer('finding', findingPrompt(evidence, r, lib), findingTemplate(evidence, r, lib), generate);
findings[r.id] = answer;
findingSource[r.id] = source;
sources[source] += 1;
tick(`Written: ${r.name}`);
}
// Score, counts and top gaps always come from the evidence; the model may only phrase the text.
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 tool classification');
}
const html = buildReportHtml({ evidence, findings, summary, ai: aiResult, sha256: result.sha256, model: model ? model.name : null, sources });
lastReport = { html, evidenceText: result.evidenceText, hostname: evidence.host.hostname };
lastEvidence = evidence;
showResults(evidence, summary, sources);
renderAllResults(evidence, findings, findingSource);
await renderFixes(evidence, result.fixResults);
openSaveModal();
}
// 6. Fixes: one-click fixes (tested scripts, undo saved) and settings pages for the rest. The AI never runs commands.
const OUTCOME = { applied: '✅', undone: '↩️', unchanged: '✔️', skipped: '⚠️', failed: '❌' };
async function renderFixes(evidence, fixResults) {
const os = plan.os;
const name = id => (evidence.results.find(r => r.id === id) || {}).name || id;
if (fixResults && fixResults.length) {
$('fix-banner').innerHTML = `<b>${fixResults.some(r => r.outcome === 'undone') ? 'Undo finished' : 'Fixes finished'}</b><ul class="fix-results">${fixResults.map(r =>
`<li>${OUTCOME[r.outcome] || '•'} <b>${escapeHtml(name(r.id))}</b>: ${escapeHtml(r.message)}</li>`).join('')}</ul><p class="muted">The results below come from checking this computer again after the change.</p>`;
$('fix-banner').classList.remove('hidden');
} else $('fix-banner').classList.add('hidden');
// A fix can depend on software being present (e.g. ClamAV updates only where ClamAV runs): requires.check's result must contain requires.contains.
const usable = f => !f.requires || ((evidence.results.find(x => x.id === f.requires.check) || {}).actual || '').toLowerCase().includes(f.requires.contains.toLowerCase());
const gaps = evidence.results.filter(r => r.status === 'Non-Compliant' && fixes[r.id] && fixes[r.id][os] && usable(fixes[r.id][os]));
const auto = gaps.filter(r => fixes[r.id][os].kind === 'auto');
// Several checks can share one fix (lockout threshold, duration and observation): show it once, fixing all of them.
const autoGroups = [];
for (const r of auto) {
const f = fixes[r.id][os], g = autoGroups.find(x => x.f.title === f.title && x.f.changes === f.changes);
if (g) g.ids.push(r.id); else autoGroups.push({ f, ids: [r.id] });
}
// Several checks can share one settings page and the same steps (screen lock): show them once.
const seen = new Set();
const guided = gaps.filter(r => fixes[r.id][os].kind === 'settings')
.filter(r => { const f = fixes[r.id][os], key = f.url + f.steps.join('|'); if (seen.has(key)) return false; seen.add(key); return true; });
const history = await window.devseis.fixHistory().catch(() => []);
const last = [...history].reverse().find(h => h.action === 'apply');
const undoable = last ? last.results.filter(r => r.outcome === 'applied' && fixes[r.id]?.[os]?.undo).map(r => r.id) : [];
if (!auto.length && !guided.length && !undoable.length) { $('fixes').classList.add('hidden'); return; }
$('fixes').innerHTML = `<h2>🛠 Fix problems</h2>
${auto.length ? `<p class="muted">Arx can fix these for you. Most changes can be undone.</p>
<div class="fix-auto">${autoGroups.map(({ f, ids }) => `<label class="fix-item"><input type="checkbox" data-fix="${ids.join(',')}" checked>
<span><b>${escapeHtml(f.title)}</b><br><span class="muted">${escapeHtml(f.changes)}${f.note ? ` ${escapeHtml(f.note)}` : ''}</span></span></label>`).join('')}</div>
<div class="actions"><button class="primary" id="fix-selected">Fix selected (${autoGroups.length})</button></div>` : ''}
${guided.length ? `<h3>${os === 'macos' ? 'Fix in System Settings' : 'Fix in Settings'}</h3><p class="muted">These need your password, a recovery key or a restart, so you make the change. Arx opens the right page.</p>
<div class="fix-guided">${guided.map(r => { const f = fixes[r.id][os]; return `<div class="fix-item"><span><b>${escapeHtml(f.title)}</b>
<ol>${f.steps.map(x => `<li>${escapeHtml(x)}</li>`).join('')}</ol></span>${f.url ? `<button class="secondary" data-settings="${r.id}">Open Settings</button>` : ''}</div>`; }).join('')}</div>
<p class="muted">When you are done, choose <b>Run another audit</b> to check again.</p>` : ''}
${undoable.length ? `<div class="undo-row"><span class="muted">Last fixes (${new Date(last.at).toLocaleString()}): ${[...new Set(undoable.map(id => fixes[id][os].title))].map(escapeHtml).join(', ')}</span>
<button class="secondary" id="fix-undo">Undo last fixes</button></div>` : ''}`;
$('fixes').classList.remove('hidden');
const selected = () => [...$('fixes').querySelectorAll('input[data-fix]:checked')].flatMap(i => i.dataset.fix.split(','));
const updateCount = () => { const b = $('fix-selected'); if (b) { const n = $('fixes').querySelectorAll('input[data-fix]:checked').length; b.textContent = `Fix selected (${n})`; b.disabled = !n; } };
$('fixes').querySelectorAll('input[data-fix]').forEach(i => i.addEventListener('change', updateCount));
$('fixes').querySelectorAll('[data-settings]').forEach(b => b.addEventListener('click', () => window.devseis.openFixSettings(b.dataset.settings)));
if ($('fix-selected')) $('fix-selected').addEventListener('click', () => confirmFix('apply', selected()));
if ($('fix-undo')) $('fix-undo').addEventListener('click', () => confirmFix('undo', undoable));
}
function confirmFix(action, ids) {
if (!ids.length) return;
const os = plan.os;
pendingFix = { action, ids };
const shown = [...new Map(ids.map(id => [fixes[id][os].title, fixes[id][os]])).values()]; // shared fixes listed once
$('fix-title').textContent = action === 'undo' ? 'Undo these fixes?' : `Fix ${shown.length} problem${shown.length > 1 ? 's' : ''}?`;
$('fix-intro').textContent = action === 'undo' ? 'Arx will put these settings back as they were before, then check this computer again.'
: 'Arx will change these settings, then check this computer again. You can undo most fixes afterwards.';
$('fix-confirm-list').innerHTML = shown.map(f => { return `<li><b>${escapeHtml(f.title)}</b><br><span class="muted">${escapeHtml(action === 'undo' ? 'Back to the previous setting.' : f.changes)}</span></li>`; }).join('');
$('fix-password-note').textContent = { macos: 'macOS will ask for your administrator password.', linux: 'Your system will ask for your password (administrator rights).', windows: 'Windows will ask you to allow the change (User Account Control).' }[os] || 'Your computer will ask for your administrator password.';
$('fix-modal').classList.remove('hidden');
}
$('fix-cancel').addEventListener('click', () => { pendingFix = null; $('fix-modal').classList.add('hidden'); });
$('fix-go').addEventListener('click', () => {
const fix = pendingFix; pendingFix = null; $('fix-modal').classList.add('hidden');
clearInterval(countdownTimer); $('save-modal').classList.add('hidden');
if (fix) runAudit(fix);
});
// 5. Results and saving
function showResults(evidence, summary, sources) {
const pills = Object.entries(evidence.summary).map(([k, v]) => `<button type="button" class="pill s-${k}" data-filter="${k}" title="Show these checks">${v} ${STATUS_LABEL[k] || k}</button>`).join('');
const gaps = summary.top_gaps.map(g => `<li><b class="s-Non-Compliant">${g.risk}</b> — ${escapeHtml(g.name)}: ${escapeHtml(g.found)}</li>`).join('');
$('result-summary').innerHTML = `
<div class="row"><div><div class="score">${summary.score.percent}%</div>
<div class="muted">${summary.score.compliant} of ${summary.score.assessed} assessed checks compliant</div></div></div>
<div class="pills">${pills}</div>
<p>${escapeHtml(summary.summary)}</p>
${gaps ? `<b>Top gaps</b><ul class="gaps">${gaps}</ul>` : ''}
<p class="muted">Findings: ${sources.model} written by the AI auditor, ${sources.template} by the built-in writer.</p>`;
$('saved-note').textContent = '';
$('result-summary').querySelectorAll('[data-filter]').forEach(b => b.addEventListener('click', () => {
setResultFilter(b.dataset.filter);
$('all-results').scrollIntoView({ behavior: 'smooth', block: 'start' });
}));
show('done');
}
// 7. All results: every check with its status, and on click the reasons behind it.
const STATUS_LABEL = { Compliant: 'Compliant', 'Non-Compliant': 'Non-Compliant', Pending: 'Pending', Error: 'Error', NotApplicable: 'Not applicable' };
const STATUS_ORDER = ['Non-Compliant', 'Error', 'Pending', 'Compliant', 'NotApplicable'];
const RISK_RANK = { High: 0, Medium: 1, Low: 2 };
let resultFilter = 'all';
function whyText(r) {
const found = r.actual || 'nothing reported', need = r.expected || 'the baseline value';
if (r.status === 'Compliant') return 'What the audit found meets the requirement, so this check passes.';
if (r.status === 'Non-Compliant') return 'What the audit found does not meet the requirement, so this check fails.';
if (r.status === 'Pending') return `Not decided yet: ${r.detail || 'it needs a decision or an answer from you or your organisation'}. Current state: ${found}.`;
if (r.status === 'Error') return `This setting could not be read${r.detail ? `: ${r.detail}` : ''}. Running the audit with administrator rights usually fixes this.`;
return `This check does not apply here: ${r.detail || found}.`;
}
function resultDetails(evidence, r, finding, source) {
let f = finding;
if (!f) { try { f = findingTemplate(evidence, r, lib); } catch { f = null; } }
const guide = lib.guide(r.id) || {};
const fix = (fixes[r.id] || {})[plan.os];
const parts = [`<div class="rd-block"><h4>Why it is ${escapeHtml((STATUS_LABEL[r.status] || r.status).toLowerCase())}</h4><p>${escapeHtml(whyText(r))}</p>
<div class="rd-kv"><span>Found</span><b>${escapeHtml(r.actual || '—')}</b><span>Required</span><b>${escapeHtml(r.expected || '—')}</b>${r.detail && r.status !== 'Error' && r.status !== 'NotApplicable' && r.status !== 'Pending' ? `<span>Note</span><b>${escapeHtml(r.detail)}</b>` : ''}</div></div>`];
if (f && f.finding && r.status !== 'Compliant') parts.push(`<div class="rd-block"><h4>Finding${source === 'model' ? ' <span class="rd-tag">written by the AI auditor</span>' : ''}</h4><p>${escapeHtml(f.finding)}</p></div>`);
if (guide.why && !(f && f.finding && r.status !== 'Compliant' && f.finding.includes(guide.why))) parts.push(`<div class="rd-block"><h4>Why it matters</h4><p>${escapeHtml(guide.why)}</p></div>`);
if (f && f.risk && f.risk.level && !['None', 'Unknown'].includes(f.risk.level)) parts.push(`<div class="rd-block"><h4>Risk</h4><p><b class="risk risk-${escapeHtml(f.risk.level)}">${escapeHtml(f.risk.level)}</b> ${escapeHtml(f.risk.reason || '')}</p></div>`);
if (f && f.recommendation && f.recommendation.length && r.status !== 'Compliant')
parts.push(`<div class="rd-block"><h4>How to fix</h4><ol>${f.recommendation.map(x => `<li>${escapeHtml(x)}</li>`).join('')}</ol>${fix && r.status === 'Non-Compliant' ? `<p class="rd-fixnote">🛠 Arx can help: <b>${escapeHtml(fix.title)}</b> in <i>Fix problems</i> above.</p>` : ''}</div>`);
if (f && f.verification && r.status !== 'Compliant') parts.push(`<div class="rd-block"><h4>Check it worked</h4><p>${escapeHtml(f.verification)}</p></div>`);
const refs = (f && f.references && f.references.length) ? f.references : lib.references(r.id).map(([label]) => label);
if (refs.length) parts.push(`<div class="rd-refs">${refs.map(x => `<span class="ref">${escapeHtml(x)}</span>`).join('')}</div>`);
parts.push(`<p class="muted rd-evidence">${r.evidence === 'self-reported' ? 'Self-reported by the user, not verified on this computer.' : 'Read from this computer by the audit.'} Check id: <code>${escapeHtml(r.id)}</code></p>`);
return parts.join('');
}
function setResultFilter(filter) {
resultFilter = filter;
$('all-results').querySelectorAll('[data-rf]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.rf === filter)));
$('all-results').querySelectorAll('.res').forEach(d => d.classList.toggle('hidden', filter !== 'all' && d.dataset.status !== filter));
}
function renderAllResults(evidence, findings, findingSource) {
const risk = r => RISK_RANK[(lib.guide(r.id) || {}).risk?.level] ?? 3;
const order = new Map(evidence.results.map((r, i) => [r.id, i]));
const rows = [...evidence.results].sort((a, b) => (STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status)) || (risk(a) - risk(b)) || (order.get(a.id) - order.get(b.id)));
const counts = Object.fromEntries(STATUS_ORDER.map(s => [s, rows.filter(r => r.status === s).length]));
$('all-results').innerHTML = `<h2>All results</h2><p class="muted">Every check of this audit. Click a check to see why it has this status and what to do.</p>
<div class="rf-tabs"><button type="button" data-rf="all">All <span>${rows.length}</span></button>${STATUS_ORDER.filter(s => counts[s]).map(s => `<button type="button" class="rf-${s}" data-rf="${s}">${STATUS_LABEL[s]} <span>${counts[s]}</span></button>`).join('')}</div>
<div class="res-list">${rows.map(r => `<details class="res res-${r.status}" data-status="${r.status}" data-id="${escapeHtml(r.id)}"><summary>
<span class="res-badge">${escapeHtml(STATUS_LABEL[r.status] || r.status)}</span><span class="res-name">${escapeHtml(r.name)}</span><span class="res-found">${escapeHtml(r.actual || '')}</span></summary><div class="res-body"></div></details>`).join('')}</div>`;
// Details are built when a check is opened (fast with 60+ checks).
$('all-results').querySelectorAll('details.res').forEach(d => d.addEventListener('toggle', () => {
const body = d.querySelector('.res-body');
if (d.open && !body.innerHTML) { const r = evidence.results.find(x => x.id === d.dataset.id); body.innerHTML = resultDetails(evidence, r, findings[r.id], findingSource[r.id]); }
}));
$('all-results').querySelectorAll('[data-rf]').forEach(b => b.addEventListener('click', () => setResultFilter(b.dataset.rf)));
setResultFilter('all');
$('all-results').classList.remove('hidden');
}
function escapeHtml(value) { const d = document.createElement('div'); d.textContent = String(value ?? ''); return d.innerHTML; }
function openSaveModal() {
let remaining = SAVE_TIMEOUT_SECONDS;
$('countdown').textContent = remaining;
setBar('countdown-bar', 1);
$('save-modal').classList.remove('hidden');
clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
remaining -= 1;
$('countdown').textContent = remaining;
setBar('countdown-bar', remaining / SAVE_TIMEOUT_SECONDS);
if (remaining <= 0) save('auto', true);
}, 1000);
}
async function save(mode, automatic = false) {
clearInterval(countdownTimer);
$('save-modal').classList.add('hidden');
if (!lastReport) return;
$('saved-note').textContent = 'Saving…';
try {
const result = await window.devseis.saveReport({ ...lastReport, mode });
if (!result.saved) {
$('saved-note').textContent = 'The report was not saved. Use "Save report as PDF" to save it.';
return;
}
$('saved-note').innerHTML = '';
const prefix = document.createTextNode(automatic ? 'No answer after 60 seconds — saved automatically to ' : 'Saved to ');
const link = document.createElement('a');
link.href = '#';
link.textContent = result.pdfPath;
link.addEventListener('click', event => { event.preventDefault(); window.devseis.showInFolder(result.pdfPath); });
$('saved-note').append(prefix, link);
} catch (error) {
$('saved-note').textContent = `Saving failed: ${error.message}`;
}
}
$('run').addEventListener('click', () => runAudit());
$('cancel').addEventListener('click', () => stopped('Audit cancelled', 'Nothing was checked and nothing was changed.'));
$('restart').addEventListener('click', showApproval);
$('again').addEventListener('click', showApproval);
$('save-again').addEventListener('click', () => save('choose'));
$('ask-chat').addEventListener('click', () => { if (lastReport) window.devseis.openChat(lastReport.evidenceText); });
$('save-docs').addEventListener('click', () => save('auto'));
$('save-choose').addEventListener('click', () => save('choose'));
prepare();