// This file contains function that are web app specific let webRunDiagnostics = []; function bytesToBase64URL(bytes) { let binary = ''; const chunkSize = 0x8000; for (let index = 0; index < bytes.length; index += chunkSize) { binary += String.fromCharCode.apply(null, bytes.subarray(index, index + chunkSize)); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function base64URLToBytes(text) { text = String(text || '').replace(/-/g, '+').replace(/_/g, '/'); while (text.length % 4) { text += '='; } let binary = atob(text); let bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } return bytes; } async function transformShareBytes(bytes, stream) { let response = new Response(new Blob([bytes]).stream().pipeThrough(stream)); return new Uint8Array(await response.arrayBuffer()); } async function encodeWebSharedConfiguration(config) { let json = JSON.stringify(config); if (typeof CompressionStream !== 'function') { return { prefix: '#cfg=', payload: encodeURIComponent(json) }; } let bytes = new TextEncoder().encode(json); let zipped = await transformShareBytes(bytes, new CompressionStream('gzip')); return { prefix: '#c=', payload: bytesToBase64URL(zipped) }; } async function decodeWebSharedConfiguration(hash) { hash = String(hash || ''); let compressed = hash.startsWith('#c='); let uncompressed = hash.startsWith('#cfg='); if (!compressed && !uncompressed) { return null; } if (hash.length > 500000) { throw new Error('configuration link is too large'); } let json; if (compressed) { if (typeof DecompressionStream !== 'function') { throw new Error('this browser cannot decompress shared configurations'); } let zipped = base64URLToBytes(hash.substring(3)); if (zipped.length > 100000) { throw new Error('configuration link is too large'); } let bytes = await transformShareBytes(zipped, new DecompressionStream('gzip')); if (bytes.length > 500000) { throw new Error('configuration is too large'); } json = new TextDecoder().decode(bytes); } else { json = decodeURIComponent(hash.substring(5)); } return validateSharedConfiguration(JSON.parse(json)); } function webShareBaseURL() { let location = window.location || {}; let origin = location.origin && location.origin !== 'null' ? location.origin : ''; return origin + (location.pathname || '/'); } async function copyWebConfigurationLink() { let encoded; try { encoded = await encodeWebSharedConfiguration(buildSharedConfiguration()); } catch (error) { showTransientWarning('Could not create configuration link.', document.getElementById('shareConfigButton'), 3500); return false; } let url = webShareBaseURL() + encoded.prefix + encoded.payload; try { if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') { throw new Error('clipboard API unavailable'); } await navigator.clipboard.writeText(url); showTransientWarning('Configuration link copied.', document.getElementById('shareConfigButton'), 3500); return true; } catch (error) { if (typeof window.prompt === 'function') { window.prompt('Copy configuration link:', url); return true; } showTransientWarning('Could not copy configuration link.', document.getElementById('shareConfigButton'), 3500); return false; } } async function initializeWebSharedConfiguration() { let hash = window.location && typeof window.location.hash === 'string' ? window.location.hash : ''; if (!hash.startsWith('#c=') && !hash.startsWith('#cfg=')) { return false; } try { let config = await decodeWebSharedConfiguration(hash); restoreSharedConfiguration(config); window.history.replaceState({}, '', (window.location.pathname || '/') + (window.location.search || '')); showTransientWarning('Shared configuration loaded.', document.getElementById('shareConfigButton'), 3500); return true; } catch (error) { console.error('Could not restore shared configuration:', error); showTransientWarning('Could not load shared configuration.', document.getElementById('shareConfigButton'), 4500); return false; } } window.addEventListener('load', function() { if (typeof amIWeb === 'function' && amIWeb()) { initializeWebSharedConfiguration(); } }); function openWebRunDetails() { let dialog = document.getElementById("runDetailsDialog"); if (dialog && !dialog.open) { dialog.showModal(); } } function recordWebDiagnostic(filename, request, response) { let timestamp = new Date().toLocaleTimeString(); let entry = `[${timestamp}] ${filename}\nHTTP ${request.status} ${request.statusText || ""}`; if (response) { entry += `\nPhase: ${response.phase || "unknown"}`; entry += `\nStatus: ${response.ok ? "successful" : "failed"}`; if (response.exitCode !== null && response.exitCode !== undefined) { entry += `\nBinary exit code: ${response.exitCode}`; } if (response.message) { entry += `\nCGI: ${response.message}`; } if (response.log) { entry += `\n\n${response.log.trim()}`; } } else if (request.responseText) { entry += `\n\n${request.responseText.trim()}`; } webRunDiagnostics.push(entry); let log = document.getElementById("webRunLog"); if (log) { log.textContent = webRunDiagnostics.join("\n\n----------------------------------------\n\n"); log.scrollTop = log.scrollHeight; } } // Async file IO through the CGI. Using fetch instead of a synchronous // XMLHttpRequest keeps the browser responsive while the server runs the // simulation, and lets independent scan points run concurrently. async function writeAsciiFileAsync(filename, content, rngType) { // Write string content into ascii file filename // Go via CGI script // Prepare CGI args use POST for long files let cgiargs = new URLSearchParams(); cgiargs.set("fn", filename); // Forward the RNG backend that was frozen when the run started, so all // points of one scan use the same generator even if the GUI dropdown is // changed while CGI requests are still in flight. if (rngType) { cgiargs.set("rngType", rngType); } let lines = content.split(/\n/); let prefix = filename.split(/\//); for (let i=0; i= 300 || !response || !response.ok) { updateStatusBar({ run: `Web backend failed for ${prefix[prefix.length - 1]}` }); return(0); } if (response.archiveUrl) { let outputLink = document.getElementById("statusOutputLink"); outputLink.href = response.archiveUrl; outputLink.textContent = "Outputs"; outputLink.title = `Download ${prefix[2]}.tgz`; outputLink.hidden = false; } return(1); } async function readAsciiFileAsync(filename) { // Read server-side simulation output through the CGI. Files remain in // filesystem /tmp and do not need to be exposed directly by Apache. if (filename == "" || filename == undefined || filename == null) { // Unify the error contract: like the Electron backend, fail loudly // instead of returning a falsy value that breaks the caller at read time. throw new Error("Invalid empty filename for readAsciiFileAsync"); } let request = "/cgi-bin/singleTrimSP.cgi?action=read&fn=" + encodeURIComponent(filename) + "&_=" + Date.now(); let xhttp = { status: 0, statusText: "", responseText: "" }; try { let fetched = await fetch(request, { cache: "no-store" }); xhttp.status = fetched.status; xhttp.statusText = fetched.statusText; xhttp.responseText = await fetched.text(); } catch (error) { console.error("CGI read failed", error); xhttp.statusText = error && error.message ? error.message : "Network error"; } if (xhttp.status < 200 || xhttp.status >= 300) { recordWebDiagnostic(filename, xhttp, { ok: false, phase: "read", message: `Could not read simulation output (HTTP ${xhttp.status}).`, }); updateStatusBar({ run: `Could not read ${filename.split("/").pop()}` }); throw new Error(`Could not read ${filename}: HTTP ${xhttp.status} ${xhttp.statusText}`); } return(xhttp.responseText); }