// Browser drop-in for the Electron preload's `window.api`. Same method // signatures and the same streaming-callback contract (onXProgress + an // awaitable trigger), implemented over fetch + NDJSON streaming. This lets // the shared renderer/app.js run unchanged in any browser. // // Differences absorbed here so the renderer doesn't have to care: // - Electron dialogs -> hidden pickers // - CSV "save to ~/Downloads" -> client-built Blob download (function () { const listeners = { fec: [], verify: [], upgrade: [], states: [], delete: [], 'olt-flood': [] }; async function postJSON(pathName, body) { let res; try { res = await fetch('/api/' + pathName, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), credentials: 'same-origin', }); } catch (e) { return { ok: false, error: { message: 'network error: ' + e.message } }; } let data = null; try { data = await res.json(); } catch (_) { /* non-JSON */ } if (data && typeof data === 'object' && 'ok' in data) return data; return { ok: res.ok, error: (data && data.error) || { message: 'HTTP ' + res.status } }; } // Streaming POST. The server replies NDJSON: progress objects, then a // single {__final:true, ok, data|error} line. Progress objects are routed // to the registered listeners for `channel` (mirrors ipcRenderer events). async function streamJSON(pathName, body, channel) { let res; try { res = await fetch('/api/' + pathName, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), credentials: 'same-origin', }); } catch (e) { return { ok: false, error: { message: 'network error: ' + e.message } }; } if (res.status === 401) return { ok: false, error: { message: 'Not logged in.' } }; if (!res.body || !res.body.getReader) { // No streaming support — fall back to a plain JSON read. const data = await res.json().catch(() => null); return data || { ok: false, error: { message: 'no response body' } }; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; let final = null; const handleLine = (line) => { const t = line.trim(); if (!t) return; let obj; try { obj = JSON.parse(t); } catch (_) { return; } if (obj && obj.__final) { final = obj; return; } if (channel && listeners[channel]) { for (const h of listeners[channel].slice()) { try { h(obj); } catch (_) { /* ignore */ } } } }; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let nl; while ((nl = buf.indexOf('\n')) >= 0) { handleLine(buf.slice(0, nl)); buf = buf.slice(nl + 1); } } if (buf) handleLine(buf); if (final) return { ok: final.ok, data: final.data, error: final.error }; return { ok: false, error: { message: 'stream ended without a result' } }; } function sub(channel) { return (handler) => { listeners[channel].push(handler); return () => { const i = listeners[channel].indexOf(handler); if (i >= 0) listeners[channel].splice(i, 1); }; }; } // ---- Browser file pickers (replace Electron dialog.*) ---- function pickFile(accept) { return new Promise((resolve) => { const inp = document.createElement('input'); inp.type = 'file'; if (accept) inp.accept = accept; inp.style.position = 'fixed'; inp.style.left = '-9999px'; document.body.appendChild(inp); let settled = false; const finish = (file) => { if (settled) return; settled = true; resolve(file); inp.remove(); }; inp.addEventListener('change', () => finish(inp.files[0] || null)); // Cancel has no reliable event; when the window regains focus and no // file was chosen shortly after, treat it as a cancel. window.addEventListener('focus', () => { setTimeout(() => { if (!settled && (!inp.files || !inp.files.length)) finish(null); }, 400); }, { once: true }); inp.click(); }); } const readText = (file) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = no; r.readAsText(file); }); const readBase64 = (file) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = no; r.readAsDataURL(file); }); // ---- CSV -> browser download (a server can't write to the user's disk) ---- function buildCsv(headers, rows) { const cell = (v) => '"' + (v === null || v === undefined ? '' : String(v)).replace(/"/g, '""') + '"'; const line = (cells) => cells.map(cell).join(','); const lines = [line(headers)]; for (const row of rows) lines.push(line(headers.map((h) => row[h]))); return '' + lines.join('\r\n') + '\r\n'; } function downloadCsv(prefix, hint, headers, rows) { const ts = new Date(); const p = (n) => String(n).padStart(2, '0'); const stamp = `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`; const safeHint = String(hint || 'csv').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60); const safePrefix = String(prefix || 'pon-csv').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 40); const filename = `${safePrefix}-${safeHint}-${stamp}.csv`; const blob = new Blob([buildCsv(headers, rows)], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500); return { ok: true, data: { path: filename, filename, rowCount: rows.length } }; } window.api = { login: (o) => postJSON('login', o), logout: () => postJSON('logout', {}), listOnuConfigs: (o) => postJSON('listOnuConfigs', o), listFleet: (o) => postJSON('listFleet', o), fetchStates: (o) => streamJSON('fetchStates', o, 'states'), getOnuConfig: (o) => postJSON('getOnuConfig', o), getOnuCpe: (o) => postJSON('getOnuCpe', o), listOnuFirmware: () => postJSON('listOnuFirmware', {}), uploadFirmware: async () => { const file = await pickFile('.bin'); if (!file) return { ok: false, error: { message: 'cancelled' } }; const base64 = await readBase64(file); return postJSON('uploadFirmware', { filename: file.name, base64 }); }, planUpgrade: (o) => postJSON('planUpgrade', o), executePerOnu: (o) => streamJSON('executePerOnu', o, 'upgrade'), executeBulkTask: (o) => postJSON('executeBulkTask', o), getTaskConfig: (o) => postJSON('getTaskConfig', o), getUpgradeStatus: (o) => postJSON('getUpgradeStatus', o), deleteOnus: (o) => streamJSON('deleteOnus', o, 'delete'), dashboardStats: (o) => postJSON('dashboardStats', o), listOlts: (o) => postJSON('listOlts', o), executeFloodChange: (o) => streamJSON('executeFloodChange', o, 'olt-flood'), fetchFecHealth: (o) => streamJSON('fetchFecHealth', o, 'fec'), fetchOnuSnapshot: (o) => streamJSON('fetchOnuSnapshot', o, 'verify'), // CSV saves become browser downloads (path field kept for UI messages). savePlanCsv: async ({ rows, filenameHint, headers }) => downloadCsv('pon-upgrade', filenameHint, headers, rows), saveCsvFile: async ({ rows, filenameHint, headers, prefix }) => downloadCsv(prefix || 'pon-csv', filenameHint, headers, rows), openCsvFile: async () => { const file = await pickFile('.csv'); if (!file) return { ok: false, error: { message: 'cancelled' } }; return { ok: true, data: { path: file.name, text: await readText(file) } }; }, onFecProgress: sub('fec'), onVerifyProgress: sub('verify'), onUpgradeProgress: sub('upgrade'), onStatesProgress: sub('states'), onDeleteProgress: sub('delete'), onFloodProgress: sub('olt-flood'), }; })();