- Expand From/Step/To scans count-based so descending (negative-step)
scans work, reject zero/unreachable ranges, and cap points at 5000
- Give parse_formula an unbalanced-bracket guard instead of an infinite
loop and use direct recursion
- Return {ok:false,error} from CreateInpFile and validate projectile,
layer composition, density, and thickness instead of the erroneous
ErrMsg/$ErrMsg path
- Freeze the RNG backend after prep_cfg refreshes All and pass it through
writeAsciiFileAsync so scans never mix generators mid-flight
- Wrap startSequence with a run-in-progress guard, disable the Run button,
and snapshot runWorkPath for all output writes
- Unify web/Electron readAsciiFileAsync to throw instead of returning 0
- Make checkDir synchronous (mkdirSync) and honor its return value
- Fix setValues/template substitution parsing and write web input files
byte-identical to Electron
- Track deferred seed-validation and input-cap work in ToDo.txt
252 lines
9.2 KiB
JavaScript
252 lines
9.2 KiB
JavaScript
// 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<lines.length; i++) {
|
|
// Write the content verbatim so web and Electron produce byte-identical
|
|
// input files for the same configuration.
|
|
cgiargs.set("line" + i, lines[i]);
|
|
}
|
|
|
|
let request = "/cgi-bin/singleTrimSP.cgi";
|
|
let xhttp = { status: 0, statusText: "", responseText: "" };
|
|
let response = null;
|
|
try {
|
|
let fetched = await fetch(request, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: cgiargs.toString(),
|
|
cache: "no-store"
|
|
});
|
|
xhttp.status = fetched.status;
|
|
xhttp.statusText = fetched.statusText;
|
|
xhttp.responseText = await fetched.text();
|
|
try {
|
|
response = JSON.parse(xhttp.responseText);
|
|
} catch (error) {
|
|
console.error("Invalid CGI response", error, xhttp.responseText);
|
|
}
|
|
} catch (error) {
|
|
console.error("CGI request failed", error);
|
|
xhttp.statusText = error && error.message ? error.message : "Network error";
|
|
}
|
|
recordWebDiagnostic(filename, xhttp, response);
|
|
|
|
if (xhttp.status < 200 || xhttp.status >= 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);
|
|
}
|