524 lines
22 KiB
JavaScript
524 lines
22 KiB
JavaScript
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const TOKEN_KEY = `wolloseum.session.${document.body?.dataset.page || 'app'}`;
|
|
const THEME_KEY = 'wolloseum.theme';
|
|
const encoder = new TextEncoder();
|
|
|
|
function currentTheme() {
|
|
return document.documentElement.dataset.theme
|
|
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
}
|
|
|
|
function setTheme(theme) {
|
|
document.documentElement.dataset.theme = theme;
|
|
localStorage.setItem(THEME_KEY, theme);
|
|
}
|
|
|
|
function bootTheme() {
|
|
const toggle = $('#theme-toggle');
|
|
if (!toggle) return;
|
|
const paint = () => { toggle.textContent = currentTheme() === 'dark' ? '\u2600' : '\u263D'; };
|
|
toggle.addEventListener('click', () => { setTheme(currentTheme() === 'dark' ? 'light' : 'dark'); paint(); });
|
|
paint();
|
|
}
|
|
|
|
function setMessage(element, text, kind = '') {
|
|
if (!element) return;
|
|
element.textContent = text;
|
|
element.className = `message ${kind}`.trim();
|
|
}
|
|
|
|
function fromBase64(value) {
|
|
return Uint8Array.from(atob(value), character => character.charCodeAt(0));
|
|
}
|
|
|
|
function readDerElement(bytes, offset) {
|
|
const start = offset;
|
|
const tag = bytes[offset++];
|
|
let length = bytes[offset++];
|
|
if (tag === undefined || length === undefined) return null;
|
|
if (length & 0x80) {
|
|
const lengthBytes = length & 0x7f;
|
|
if (!lengthBytes || lengthBytes > 4 || offset + lengthBytes > bytes.length) return null;
|
|
length = 0;
|
|
for (let index = 0; index < lengthBytes; index++) length = length * 256 + bytes[offset++];
|
|
}
|
|
const contentStart = offset;
|
|
const end = contentStart + length;
|
|
if (end > bytes.length) return null;
|
|
return { tag, start, contentStart, end };
|
|
}
|
|
|
|
function derLength(length) {
|
|
if (length < 0x80) return [length];
|
|
const bytes = [];
|
|
while (length) {
|
|
bytes.unshift(length & 0xff);
|
|
length >>>= 8;
|
|
}
|
|
return [0x80 | bytes.length, ...bytes];
|
|
}
|
|
|
|
function normalizeEd25519Pkcs8(bytes) {
|
|
const sequence = readDerElement(bytes, 0);
|
|
const version = sequence?.tag === 0x30 && sequence.end === bytes.length
|
|
? readDerElement(bytes, sequence.contentStart)
|
|
: null;
|
|
if (version?.tag !== 0x02 || version.end !== version.contentStart + 1) return bytes;
|
|
if (bytes[version.contentStart] === 0) return bytes;
|
|
if (bytes[version.contentStart] !== 1) return bytes;
|
|
|
|
const algorithm = readDerElement(bytes, version.end);
|
|
const privateKey = algorithm?.tag === 0x30 ? readDerElement(bytes, algorithm.end) : null;
|
|
const publicKey = privateKey?.tag === 0x04 ? readDerElement(bytes, privateKey.end) : null;
|
|
if (algorithm?.tag !== 0x30 || privateKey?.tag !== 0x04 || publicKey?.tag !== 0x81 || publicKey.end !== sequence.end) {
|
|
return bytes;
|
|
}
|
|
|
|
const body = new Uint8Array(3 + algorithm.end - algorithm.start + privateKey.end - privateKey.start);
|
|
body.set([0x02, 0x01, 0x00]);
|
|
body.set(bytes.subarray(algorithm.start, algorithm.end), 3);
|
|
body.set(bytes.subarray(privateKey.start, privateKey.end), 3 + algorithm.end - algorithm.start);
|
|
const encodedLength = derLength(body.length);
|
|
const normalized = new Uint8Array(1 + encodedLength.length + body.length);
|
|
normalized.set([0x30, ...encodedLength]);
|
|
normalized.set(body, 1 + encodedLength.length);
|
|
return normalized;
|
|
}
|
|
|
|
function toBase64(value) {
|
|
return btoa(String.fromCharCode(...new Uint8Array(value)));
|
|
}
|
|
|
|
function toPem(label, value) {
|
|
const base64 = toBase64(value).match(/.{1,64}/g).join('\n');
|
|
return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`;
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const headers = new Headers(options.headers || {});
|
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
|
if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json');
|
|
const response = await fetch(path, { ...options, headers });
|
|
if (response.status === 204) return null;
|
|
const contentType = response.headers.get('content-type') || '';
|
|
const body = contentType.includes('application/json') ? await response.json() : await response.text();
|
|
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
|
|
return body;
|
|
}
|
|
|
|
async function readCredential(file) {
|
|
let bundle;
|
|
try { bundle = JSON.parse(await file.text()); }
|
|
catch (_) { throw new Error('Choose a valid JSON identity bundle.'); }
|
|
if (!bundle.client_id || !bundle.private_key_pem) {
|
|
throw new Error('This JSON file is not an identity bundle.');
|
|
}
|
|
return bundle;
|
|
}
|
|
|
|
async function signIn(file) {
|
|
if (!file) throw new Error('Choose your identity bundle.');
|
|
const credential = await readCredential(file);
|
|
const encodedKey = credential.private_key_pem.match(/-----BEGIN PRIVATE KEY-----([\s\S]*?)-----END PRIVATE KEY-----/);
|
|
if (!encodedKey) throw new Error('Expected an Ed25519 PKCS#8 private key.');
|
|
const pkcs8 = normalizeEd25519Pkcs8(fromBase64(encodedKey[1].replace(/\s/g, '')));
|
|
let privateKey;
|
|
try {
|
|
privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
|
|
} catch (error) {
|
|
if (error.name === 'NotSupportedError') {
|
|
throw new Error('This browser does not support Ed25519 Web Crypto. Update the browser and try again.');
|
|
}
|
|
throw new Error(`Ed25519 key import failed (${error.name}): ${error.message}. Choose data/keys/admin-private.pem or a generated identity JSON bundle.`);
|
|
}
|
|
const issued = await api('/api/auth/challenge', {
|
|
method: 'POST', body: JSON.stringify({ client_id: credential.client_id }),
|
|
});
|
|
const challenge = fromBase64(issued.challenge);
|
|
let signature;
|
|
try {
|
|
signature = await crypto.subtle.sign('Ed25519', privateKey, challenge);
|
|
} catch (error) {
|
|
throw new Error(`Ed25519 challenge signing failed (${error.name}): ${error.message}.`);
|
|
}
|
|
const session = await api('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ client_id: credential.client_id, challenge: issued.challenge, signature: toBase64(signature) }),
|
|
});
|
|
localStorage.setItem(TOKEN_KEY, session.token);
|
|
return api('/api/me');
|
|
}
|
|
|
|
function logout() {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
location.reload();
|
|
}
|
|
|
|
function connectEvents(onEvent, onConnection = () => { }) {
|
|
let reconnectDelay = 1000;
|
|
let socket;
|
|
let stopped = false;
|
|
const connect = () => {
|
|
if (stopped) return;
|
|
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
socket = new WebSocket(`${scheme}//${location.host}/api/events`);
|
|
socket.onopen = () => { reconnectDelay = 1000; onConnection(true); };
|
|
socket.onmessage = message => {
|
|
try { onEvent(JSON.parse(message.data)); } catch (_) { }
|
|
};
|
|
socket.onclose = () => {
|
|
onConnection(false);
|
|
if (!stopped) setTimeout(connect, reconnectDelay);
|
|
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
|
|
};
|
|
socket.onerror = () => socket.close();
|
|
};
|
|
connect();
|
|
return () => { stopped = true; socket?.close(); };
|
|
}
|
|
|
|
function eventRow(event) {
|
|
const row = document.createElement('div');
|
|
row.className = 'event-row';
|
|
const sequence = document.createElement('span');
|
|
sequence.className = 'event-sequence';
|
|
sequence.textContent = `#${event.sequence}`;
|
|
const name = document.createElement('span');
|
|
name.className = 'event-name';
|
|
name.textContent = event.event;
|
|
const data = document.createElement('span');
|
|
data.className = 'event-data';
|
|
data.textContent = JSON.stringify(event.data ?? {});
|
|
row.append(sequence, name, data);
|
|
return row;
|
|
}
|
|
|
|
function bootViewer() {
|
|
const list = $('#event-list');
|
|
const empty = $('#event-empty');
|
|
const status = $('#socket-status');
|
|
const sequence = $('#latest-sequence');
|
|
const total = $('#event-count');
|
|
const search = $('#event-search');
|
|
let count = 0;
|
|
const events = [];
|
|
const redraw = () => {
|
|
list.replaceChildren();
|
|
const query = search.value.trim().toLowerCase();
|
|
const visible = events.filter(event => `${event.event} ${JSON.stringify(event.data)}`.toLowerCase().includes(query));
|
|
empty.classList.toggle('hidden', visible.length > 0);
|
|
visible.forEach(event => list.append(eventRow(event)));
|
|
};
|
|
search.addEventListener('input', redraw);
|
|
connectEvents(event => {
|
|
count += 1;
|
|
events.unshift(event);
|
|
if (events.length > 250) events.pop();
|
|
sequence.textContent = String(event.sequence);
|
|
total.textContent = String(count);
|
|
redraw();
|
|
}, connected => {
|
|
status.classList.toggle('live', connected);
|
|
status.classList.toggle('offline', !connected);
|
|
$('#socket-label').textContent = connected ? 'LIVE' : 'RECONNECTING';
|
|
});
|
|
}
|
|
|
|
function bootLogin() {
|
|
const form = $('#login-form');
|
|
const panel = $('#login-panel');
|
|
const message = $('#login-message');
|
|
const page = document.body.dataset.page;
|
|
const app = page === 'admin' ? $('#admin-app') : $('#user-app');
|
|
const account = $('#account-name');
|
|
const reveal = async identity => {
|
|
const requiredAdmin = page === 'admin';
|
|
if (requiredAdmin && !identity.is_admin && !identity.is_mod) {
|
|
throw new Error('This identity does not have moderator or administrator rights.');
|
|
}
|
|
window.currentIdentity = identity;
|
|
panel.classList.add('hidden');
|
|
app.classList.remove('hidden');
|
|
account.textContent = identity.username;
|
|
if (requiredAdmin) await bootAdmin(identity);
|
|
else await bootUser(identity);
|
|
};
|
|
form.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
$('#identity-bundle').click();
|
|
});
|
|
$('#login-button').addEventListener('click', () => $('#identity-bundle').click());
|
|
$('#identity-bundle').addEventListener('change', async event => {
|
|
const fileInput = event.currentTarget;
|
|
const button = $('#login-button');
|
|
button.disabled = true;
|
|
setMessage(message, 'Verifying challenge...');
|
|
try { await reveal(await signIn(fileInput.files[0])); }
|
|
catch (error) { setMessage(message, error.message, 'error'); }
|
|
finally {
|
|
button.disabled = false;
|
|
fileInput.value = '';
|
|
}
|
|
});
|
|
$('#logout-button, #admin-logout').addEventListener('click', logout);
|
|
if (localStorage.getItem(TOKEN_KEY)) {
|
|
api('/api/me').then(reveal).catch(() => localStorage.removeItem(TOKEN_KEY));
|
|
}
|
|
}
|
|
|
|
async function bootUser(identity) {
|
|
$('#player-id').textContent = identity.client_id;
|
|
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
|
|
$('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
|
|
const status = await api('/api/status');
|
|
paintGameStatus(status.running);
|
|
$('#agent-form').addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const file = $('#agent-file').files[0];
|
|
if (!file) return setMessage($('#agent-message'), 'Choose a compiled WASM module.', 'error');
|
|
const button = $('button[type="submit"]', event.currentTarget);
|
|
button.disabled = true;
|
|
setMessage($('#agent-message'), `Uploading ${file.name}...`);
|
|
try {
|
|
const result = await api('/api/update_agent', { method: 'POST', body: await file.arrayBuffer() });
|
|
$('#queued-agent-digest').textContent = result.sha256;
|
|
setMessage($('#agent-message'), `Agent queued | ${result.bytes.toLocaleString()} bytes`, 'success');
|
|
} catch (error) { setMessage($('#agent-message'), error.message, 'error'); }
|
|
finally { button.disabled = false; }
|
|
});
|
|
connectEvents(event => {
|
|
if (event.event === 'snapshot' || event.event === 'game_state_changed') paintGameStatus(event.data.running);
|
|
if (event.event === 'agents_activated') {
|
|
api('/api/me').then(identity => {
|
|
$('#agent-digest').textContent = identity.agent_sha256 || 'No agent uploaded';
|
|
$('#queued-agent-digest').textContent = identity.queued_agent_sha256 || 'None';
|
|
}).catch(error => setMessage($('#agent-message'), error.message, 'error'));
|
|
}
|
|
});
|
|
}
|
|
|
|
function paintGameStatus(running) {
|
|
const chip = $('#game-status');
|
|
if (!chip) return;
|
|
chip.classList.toggle('running', running);
|
|
chip.classList.toggle('paused', !running);
|
|
$('#game-status-label').textContent = running ? 'RUNNING' : 'PAUSED';
|
|
const copy = $('#game-status-copy');
|
|
if (copy) copy.textContent = running ? 'RUNNING' : 'PAUSED';
|
|
}
|
|
|
|
async function bootAdmin(identity) {
|
|
document.querySelectorAll('.admin-only').forEach(element => {
|
|
element.classList.toggle('hidden', !identity.is_admin);
|
|
});
|
|
if (!identity.is_admin) setMessage($('#admin-message'), 'Moderator access | user management only.');
|
|
const status = await api('/api/status');
|
|
paintGameStatus(status.running);
|
|
if (identity.is_admin) $('#game-control').addEventListener('click', async () => {
|
|
const running = $('#game-status-label').textContent === 'RUNNING';
|
|
const button = $('#game-control');
|
|
button.disabled = true;
|
|
try {
|
|
await api(running ? '/api/stop_game' : '/api/start_game', { method: 'POST', body: '' });
|
|
paintGameStatus(!running);
|
|
button.textContent = running ? 'Resume simulation' : 'Pause simulation';
|
|
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
|
|
finally { button.disabled = false; }
|
|
});
|
|
if ($('#game-control')) $('#game-control').textContent = status.running ? 'Pause simulation' : 'Resume simulation';
|
|
$('#user-form').addEventListener('submit', createUser);
|
|
if (identity.is_admin) {
|
|
$('#core-form').addEventListener('submit', uploadCore);
|
|
$('#simulation-settings-form').addEventListener('submit', saveSimulationSettings);
|
|
$('#reset-world').addEventListener('click', resetWorld);
|
|
$('#export-state').addEventListener('click', exportState);
|
|
$('#import-state').addEventListener('click', () => $('#state-file').click());
|
|
$('#state-file').addEventListener('change', importState);
|
|
await loadSimulationSettings();
|
|
}
|
|
await refreshUsers();
|
|
connectEvents(event => {
|
|
if (event.event === 'snapshot' || event.event === 'game_state_changed') {
|
|
paintGameStatus(event.data.running);
|
|
if ($('#game-control')) $('#game-control').textContent = event.data.running ? 'Pause simulation' : 'Resume simulation';
|
|
}
|
|
if (event.event === 'user_created' || event.event === 'user_deleted' || event.event === 'user_role_changed' || event.event === 'agent_queued' || event.event === 'agents_activated') refreshUsers();
|
|
});
|
|
}
|
|
|
|
async function loadSimulationSettings() {
|
|
try {
|
|
const settings = await api('/api/settings');
|
|
$('#ticks-per-day').value = settings.ticks_per_day;
|
|
$('#days-per-week').value = settings.days_per_week;
|
|
$('#days-per-month').value = settings.days_per_month;
|
|
$('#tick-interval-ms').value = settings.tick_interval_ms;
|
|
$('#agent-activation').value = settings.agent_activation;
|
|
} catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
|
|
}
|
|
|
|
async function saveSimulationSettings(event) {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
const button = $('button[type="submit"]', form);
|
|
button.disabled = true;
|
|
const settings = {
|
|
ticks_per_day: Number($('#ticks-per-day').value),
|
|
days_per_week: Number($('#days-per-week').value),
|
|
days_per_month: Number($('#days-per-month').value),
|
|
tick_interval_ms: Number($('#tick-interval-ms').value),
|
|
agent_activation: $('#agent-activation').value,
|
|
};
|
|
try {
|
|
await api('/api/settings', { method: 'PUT', body: JSON.stringify(settings) });
|
|
setMessage($('#simulation-settings-message'), 'Settings saved.', 'success');
|
|
} catch (error) { setMessage($('#simulation-settings-message'), error.message, 'error'); }
|
|
finally { button.disabled = false; }
|
|
}
|
|
|
|
async function createUser(event) {
|
|
event.preventDefault();
|
|
const username = $('#new-username').value.trim();
|
|
const button = $('button[type="submit"]', event.currentTarget);
|
|
const downloadLink = $('#identity-download');
|
|
if (downloadLink.dataset.objectUrl) URL.revokeObjectURL(downloadLink.dataset.objectUrl);
|
|
delete downloadLink.dataset.objectUrl;
|
|
downloadLink.removeAttribute('href');
|
|
downloadLink.classList.add('hidden');
|
|
button.disabled = true;
|
|
setMessage($('#create-user-message'), 'Generating identity keypair...');
|
|
try {
|
|
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
|
|
const publicKey = await crypto.subtle.exportKey('raw', pair.publicKey);
|
|
const privateKey = await crypto.subtle.exportKey('pkcs8', pair.privateKey);
|
|
const identity = await api('/api/create_user', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ username, public_key: toBase64(publicKey) }),
|
|
});
|
|
const bundle = JSON.stringify({ username, client_id: identity.client_id, private_key_pem: toPem('PRIVATE KEY', privateKey) }, null, 2);
|
|
downloadLink.dataset.objectUrl = URL.createObjectURL(new Blob([bundle], { type: 'application/json' }));
|
|
downloadLink.href = downloadLink.dataset.objectUrl;
|
|
downloadLink.download = `${username}-identity.json`;
|
|
downloadLink.classList.remove('hidden');
|
|
$('#new-username').value = '';
|
|
setMessage($('#create-user-message'), `Created ${username}. Download the private-key bundle before closing this page.`, 'success');
|
|
await refreshUsers();
|
|
} catch (error) { setMessage($('#create-user-message'), error.message, 'error'); }
|
|
finally { button.disabled = false; }
|
|
}
|
|
|
|
async function refreshUsers() {
|
|
try {
|
|
const users = await api('/api/users');
|
|
const list = $('#user-list');
|
|
list.replaceChildren();
|
|
users.forEach(user => {
|
|
const row = document.createElement('div');
|
|
row.className = 'user-row';
|
|
const info = document.createElement('div');
|
|
const name = document.createElement('div');
|
|
name.className = 'user-name';
|
|
name.textContent = `${user.username}${user.is_admin ? ' | admin' : user.is_mod ? ' | mod' : ''}`;
|
|
const id = document.createElement('div');
|
|
id.className = 'user-id';
|
|
id.textContent = user.client_id;
|
|
const meta = document.createElement('div');
|
|
meta.className = 'user-meta';
|
|
const agentStatus = [
|
|
user.has_agent ? `Active | ${user.agent_sha256 || 'uploaded'}` : 'No active agent',
|
|
user.queued_agent_sha256 ? `Queued | ${user.queued_agent_sha256}` : '',
|
|
].filter(Boolean);
|
|
meta.textContent = agentStatus.join(' | ');
|
|
info.append(name, id, meta);
|
|
row.append(info);
|
|
if (window.currentIdentity?.is_admin && user.username !== 'admin') {
|
|
const role = document.createElement('select');
|
|
role.setAttribute('aria-label', `${user.username} role`);
|
|
[['user', 'Player'], ['mod', 'Moderator'], ['admin', 'Administrator']].forEach(([value, label]) => {
|
|
const option = document.createElement('option');
|
|
option.value = value;
|
|
option.textContent = label;
|
|
role.append(option);
|
|
});
|
|
role.value = user.is_admin ? 'admin' : user.is_mod ? 'mod' : 'user';
|
|
role.addEventListener('change', async () => {
|
|
try {
|
|
await api('/api/set_role', { method: 'POST', body: JSON.stringify({ client_id: user.client_id, role: role.value }) });
|
|
await refreshUsers();
|
|
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
|
|
});
|
|
row.append(role);
|
|
}
|
|
const mayRemove = !user.is_admin && (window.currentIdentity?.is_admin || !user.is_mod);
|
|
if (mayRemove) {
|
|
const remove = document.createElement('button');
|
|
remove.className = 'button danger';
|
|
remove.type = 'button';
|
|
remove.textContent = 'Remove';
|
|
remove.addEventListener('click', async () => {
|
|
if (!confirm(`Remove ${user.username} and their uploaded agent?`)) return;
|
|
try {
|
|
await api('/api/delete_user', { method: 'POST', body: JSON.stringify({ client_id: user.client_id }) });
|
|
await refreshUsers();
|
|
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
|
|
});
|
|
row.append(remove);
|
|
}
|
|
list.append(row);
|
|
});
|
|
$('#user-count').textContent = String(users.filter(user => !user.is_admin).length);
|
|
} catch (error) { setMessage($('#admin-message'), error.message, 'error'); }
|
|
}
|
|
|
|
async function resetWorld() {
|
|
if (!confirm('Reset the world? Settings, users, agents, and core will be kept.')) return;
|
|
try {
|
|
await api('/api/reset_world', { method: 'POST', body: '' });
|
|
setMessage($('#state-message'), 'World reset requested.', 'success');
|
|
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
|
|
}
|
|
|
|
async function exportState() {
|
|
try {
|
|
const state = await api('/api/state');
|
|
const url = URL.createObjectURL(new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = 'wolloseum-state.json';
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
setMessage($('#state-message'), 'Simulation state downloaded.', 'success');
|
|
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
|
|
}
|
|
|
|
async function importState(event) {
|
|
const file = event.currentTarget.files[0];
|
|
if (!file) return;
|
|
if (!confirm('Restore this simulation state? Current settings, users, agents, core, and world will be replaced.')) return;
|
|
try {
|
|
await api('/api/state', { method: 'POST', body: await file.text() });
|
|
setMessage($('#state-message'), 'Simulation state restored.', 'success');
|
|
await refreshUsers();
|
|
await loadSimulationSettings();
|
|
} catch (error) { setMessage($('#state-message'), error.message, 'error'); }
|
|
finally { event.currentTarget.value = ''; }
|
|
}
|
|
|
|
async function uploadCore(event) {
|
|
event.preventDefault();
|
|
const file = $('#core-file').files[0];
|
|
if (!file) return setMessage($('#core-message'), 'Choose a WASM core module.', 'error');
|
|
const button = $('button[type="submit"]', event.currentTarget);
|
|
button.disabled = true;
|
|
setMessage($('#core-message'), `Validating ${file.name}...`);
|
|
try {
|
|
const result = await api('/api/upload_core', { method: 'POST', body: await file.arrayBuffer() });
|
|
setMessage($('#core-message'), `Core module accepted | ${result.sha256}`, 'success');
|
|
} catch (error) { setMessage($('#core-message'), error.message, 'error'); }
|
|
finally { button.disabled = false; }
|
|
}
|
|
|
|
bootTheme();
|
|
if (document.body.dataset.page === 'viewer') bootViewer();
|
|
else bootLogin(); |