Files
appsandbox/web/app.js
T
James Stringer c986d092bf Add EV + Microsoft-attestation driver signing and release packaging
YubiKey-gated signing/packaging driven from the VS Release|x64 build:
- make-release.ps1 / sign-drivers.ps1: EV-sign all binaries + driver
  .sys/.dll, submit drivers to Microsoft for attestation in parallel,
  deposit the returned MS-signed drivers into bin\Release\drivers, and
  build a flat AppSandbox-<ver>-x64.zip. Token presence is detected via
  WinSCard + a silent NCrypt key-open, so an unplugged or unrelated card
  never triggers an "insert smart card" dialog; no token => test-sign
  only, no packaging.
- AppSandboxPackage.vcxproj runs it as a post-build step.
- Directory.Build.props/targets + build/version.rc: single-source version
  stamped into every binary and into the driver INFs (StampInf).
- VDD INF: pull WUDFRd via Include/Needs + Win11 decoration to pass
  infverif /h for Partner Center submission.
- Default new Windows VMs to Test Mode off (shipped drivers are MS-signed).
- SIGNING.md documents the git-ignored partner-center.local.json that you
  must create with the Entra API secrets for driver attestation.

Partner Center credentials stay in git-ignored tools/sign/*.local.* files.
2026-05-31 22:21:09 -04:00

1433 lines
58 KiB
JavaScript

/* App Sandbox - WebView2 Frontend */
'use strict';
/* ---- State ---- */
let vms = [];
let selectedVm = -1;
let selectedSnap = {}; /* vmIndex -> string value: 'current', 'base', 'base-N', 'S', 'S-N' */
let editModeRow = -1;
let editingCell = null; /* {row, col, element} */
let pendingConfirm = null; /* {resolve} */
let minSizeReported = false;
let lastHostInfo = null;
let rowCache = {}; /* vm.name -> <tr> — persistent rows so the status spinner doesn't reset on every update */
let rowSigCache = {}; /* vm.name -> last render signature; skip rebuild when unchanged */
/* ---- Collapsible sections ---- */
function toggleSection(id) {
var section = document.getElementById(id);
var collapsed = section.classList.toggle('collapsed');
localStorage.setItem('collapse_' + id, collapsed ? '1' : '0');
}
(function restoreCollapse() {
var defaults = { 'log-section': '1' };
Object.keys(defaults).forEach(function(id) {
var val = localStorage.getItem('collapse_' + id);
if (val === null) val = defaults[id];
if (val === '1') document.getElementById(id).classList.add('collapsed');
});
})();
const netNames = ['None', 'NAT', 'External', 'Internal'];
/* ---- Message bridge ----
*
* Two host environments are supported:
* - WebView2 on Windows (window.chrome.webview)
* - WKWebView on macOS (window.webkit.messageHandlers.host)
*
* Native code on both platforms calls window.onHostMessage(obj) with a
* parsed message object; the JS side only sees one uniform surface. On
* Windows we keep using the native chrome.webview event path because it
* is the existing, tested route — onHostMessage is simply wired into the
* same listener.
*/
var hostBridge = (function() {
var isWebView2 = !!(window.chrome && window.chrome.webview);
var isWKWebView = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.host);
function send(action, data) {
var msg = Object.assign({ action: action }, data || {});
if (isWebView2) {
window.chrome.webview.postMessage(msg);
} else if (isWKWebView) {
/* WKWebView only accepts JSON-serializable values; strings round-trip
* most reliably so we hand the native side the raw JSON text. */
window.webkit.messageHandlers.host.postMessage(JSON.stringify(msg));
} else {
console.warn('[hostBridge] no native host available; dropping', msg);
}
}
return { send: send, isWebView2: isWebView2, isWKWebView: isWKWebView, isMac: isWKWebView };
})();
function sendCmd(action, data) { hostBridge.send(action, data); }
/* Hide Windows-only form fields when running on macOS.
* On macOS we also hide both image-source rows (.needs-iso for Windows,
* .needs-linux-version for Linux) — macOS uses .ipsw via its own
* picker, not a path input here. */
if (hostBridge.isMac) {
var hide = document.querySelectorAll('.win-only, .needs-iso, .needs-linux-version');
for (var i = 0; i < hide.length; i++) hide[i].style.display = 'none';
var osSelect = document.getElementById('os-type');
osSelect.value = 'macOS';
osSelect.disabled = true;
}
/* OS Type dropdown: drop guest types that aren't available on this host.
* Windows host: macOS unavailable (Apple Virtualization is Mac-only).
* macOS host: Windows and Linux unavailable (HCS-only path). */
{
var unavailable = hostBridge.isMac ? ['Windows', 'Linux'] : ['macOS'];
unavailable.forEach(function(v) {
var opt = document.querySelector('#os-type option[value="' + v + '"]');
if (opt) opt.remove();
});
}
/* Apply Create-modal visibility rules for the currently selected OS type.
* Windows: .win-only shown, .needs-iso shown, .needs-linux-version hidden
* Linux: .win-only hidden, .needs-iso hidden, .needs-linux-version shown
* macOS: handled by the isMac branch above; this function is a no-op there.
*
* Linux is back to user-picks-an-ISO (Ubuntu Desktop ISO etc.), same as
* Windows. The version-dropdown / cloud-image flow is preserved in
* asb_core.c under #if 0 in case we need to bring it back. */
function applyOsTypeUI() {
if (hostBridge.isMac) return;
var osType = document.getElementById('os-type').value;
var isWindows = osType === 'Windows';
var isLinux = osType === 'Linux';
var winOnly = document.querySelectorAll('.win-only');
var needsIso = document.querySelectorAll('.needs-iso');
var needsLinuxVersion = document.querySelectorAll('.needs-linux-version');
for (var i = 0; i < winOnly.length; i++) winOnly[i].style.display = isWindows ? '' : 'none';
/* ISO picker shows for both Windows and Linux now. */
for (var j = 0; j < needsIso.length; j++) needsIso[j].style.display = (isWindows || isLinux) ? '' : 'none';
/* Linux distribution dropdown is dormant — kept in the DOM but always
hidden so the cloud-image code path can be revived without
re-adding the markup. */
for (var k = 0; k < needsLinuxVersion.length; k++) needsLinuxVersion[k].style.display = 'none';
/* Swap the default VM name between OS conventions, but only when the
field still holds the *other* OS's untouched default — never clobber a
name the user typed. Linux hostnames must be lowercase. */
var nameEl = document.getElementById('vm-name');
if (isLinux && nameEl.value === 'MyAppSandbox') nameEl.value = 'myappsandbox';
else if (!isLinux && nameEl.value === 'myappsandbox') nameEl.value = 'MyAppSandbox';
revalidateVmName();
revalidateUsername();
revalidatePassword();
updateCreateButtons();
}
/* Unified dispatch. Native code on either platform calls
* window.onHostMessage(obj) with an already-parsed object. WebView2 also
* delivers messages through chrome.webview.addEventListener('message'),
* which we route into the same handler so both paths end up in one place. */
window.onHostMessage = function(msg) {
if (!msg || typeof msg !== 'object') return;
switch (msg.type) {
case 'fullState': onFullState(msg); break;
case 'vmListChanged': vms = msg.vms; renderVmTable(); updateHostInfo(msg.hostInfo); revalidateVmName(); break;
case 'vmStateChanged': onVmStateChanged(msg); break;
case 'snapListChanged': break; /* snapshots now inline in vmListChanged */
case 'log': appendLog(msg.message); break;
case 'hostInfo': updateHostInfo(msg); break;
case 'browseResult': onBrowseResult(msg.path); break;
case 'confirmResult': if (pendingConfirm) pendingConfirm.resolve(msg.confirmed); break;
case 'adapters': populateAdapters(msg.adapters, msg.defaultIndex); break;
case 'templates': populateTemplates(msg.templates); break;
case 'alert': showModal('Error', msg.message, 'OK'); break;
case 'prereqRequired': onPrereqRequired(); break;
case 'prereqReboot': onPrereqReboot(); break;
case 'prereqProgress': onPrereqProgress(msg); break;
case 'prereqResult': onPrereqResult(msg); break;
}
};
/* WebView2 delivers events as DOM CustomEvents; forward them into
* window.onHostMessage so both transports converge on the same handler. */
if (hostBridge.isWebView2) {
window.chrome.webview.addEventListener('message', function(event) {
window.onHostMessage(event.data);
});
}
/* ---- Initial state ---- */
function onFullState(msg) {
vms = msg.vms || [];
renderVmTable();
revalidateVmName();
if (msg.hostInfo) updateHostInfo(msg.hostInfo);
if (msg.adapters) populateAdapters(msg.adapters, msg.defaultAdapter);
if (msg.templates) populateTemplates(msg.templates);
if (!minSizeReported) {
minSizeReported = true;
setTimeout(reportMinSize, 50);
}
}
function applySmartDefaults(info) {
var ram = Math.min(Math.floor(info.hostRamMb / 2), 16384);
var cores = Math.min(Math.floor(info.hostCores / 2), 8);
if (ram < 512) ram = 512;
if (cores < 1) cores = 1;
document.getElementById('ram-size').value = ram;
document.getElementById('cpu-cores').value = cores;
}
function onVmStateChanged(msg) {
if (msg.vmIndex >= 0 && msg.vmIndex < vms.length) {
Object.assign(vms[msg.vmIndex], msg);
}
renderVmTable();
if (msg.hostInfo) updateHostInfo(msg.hostInfo);
}
/* ---- Host info ---- */
function updateHostInfo(info) {
if (!info) return;
lastHostInfo = info;
var el;
el = document.getElementById('host-cpu');
if (el) el.textContent = 'Host: ' + info.hostCores + ' cores | VMs using: ' + info.vmCores;
el = document.getElementById('host-ram');
if (el) el.textContent = 'Host: ' + info.hostRamMb + ' MB | VMs using: ' + info.vmRamMb + ' MB';
el = document.getElementById('host-hdd');
if (el) el.textContent = 'Free: ' + info.freeGb + ' GB | VMs allocated: ' + info.vmHddGb + ' GB';
}
/* ---- Adapters ---- */
var currentAdapters = [];
var currentDefaultAdapter = '';
function populateAdapters(adapters, defaultIdx) {
var sel = document.getElementById('net-adapter');
sel.innerHTML = '<option value="">(Auto)</option>';
currentAdapters = adapters || [];
if (adapters) {
adapters.forEach(function(a) {
var opt = document.createElement('option');
opt.value = a;
opt.textContent = a;
sel.appendChild(opt);
});
}
if (typeof defaultIdx === 'number' && defaultIdx >= 0 && defaultIdx < sel.options.length) {
sel.selectedIndex = defaultIdx;
currentDefaultAdapter = sel.value;
} else if (adapters && adapters.length > 0) {
currentDefaultAdapter = adapters[0];
}
}
/* ---- Templates ---- */
var currentTemplates = [];
function templateDefaultLabel() {
var n = currentTemplates.length;
if (n === 0) return '(None)';
return '(' + n + ' template' + (n === 1 ? '' : 's') + ' available)';
}
function populateTemplates(templates) {
currentTemplates = templates || [];
var list = document.getElementById('template-dropdown-list');
var hidden = document.getElementById('template-select');
list.innerHTML = '';
/* Default (None) item — always shows "None" inside the list */
var noneItem = document.createElement('div');
noneItem.className = 'template-dropdown-item';
noneItem.innerHTML = '<span class="tpl-name">(None)</span>';
noneItem.addEventListener('click', function() { selectTemplate('', templateDefaultLabel()); });
list.appendChild(noneItem);
currentTemplates.forEach(function(t) {
var item = document.createElement('div');
item.className = 'template-dropdown-item';
var nameSpan = document.createElement('span');
nameSpan.className = 'tpl-name';
nameSpan.textContent = t.name + ' [' + t.osType + ']';
item.appendChild(nameSpan);
var delBtn = document.createElement('span');
delBtn.className = 'tpl-delete';
delBtn.textContent = '\uD83D\uDDD1\uFE0F';
delBtn.title = 'Delete template';
delBtn.addEventListener('click', function(e) {
e.stopPropagation();
closeTemplateDropdown();
onDeleteTemplate(t.name);
});
item.appendChild(delBtn);
item.addEventListener('click', function() {
selectTemplate(t.name, t.name + ' [' + t.osType + ']');
});
list.appendChild(item);
});
/* If the currently selected template was deleted, reset */
if (hidden.value !== '') {
var found = currentTemplates.some(function(t) { return t.name === hidden.value; });
if (!found) selectTemplate('', templateDefaultLabel());
} else {
/* No template selected — update default label in case count changed */
document.getElementById('template-dropdown-selected').textContent = templateDefaultLabel();
}
}
function selectTemplate(value, label) {
document.getElementById('template-select').value = value;
document.getElementById('template-dropdown-selected').textContent = label;
closeTemplateDropdown();
if (value !== '') {
document.getElementById('image-path').value = '';
}
updateCreateButtons();
}
function closeTemplateDropdown() {
document.getElementById('template-dropdown').classList.remove('open');
}
document.getElementById('template-dropdown-selected').addEventListener('click', function() {
document.getElementById('template-dropdown').classList.toggle('open');
});
/* Close dropdown when clicking outside */
document.addEventListener('click', function(e) {
if (!e.target.closest('#template-dropdown')) {
closeTemplateDropdown();
}
});
function onDeleteTemplate(name) {
showModal(
'Confirm Delete',
'Are you sure you want to delete template "' + name + '"?\n\nThis will permanently delete the template disk image.',
'Delete'
).then(function(confirmed) {
if (confirmed) {
sendCmd('deleteTemplate', { name: name });
}
});
}
/* ---- Browse result ---- */
function onBrowseResult(path) {
if (path) {
document.getElementById('image-path').value = path;
selectTemplate('', templateDefaultLabel());
updateCreateButtons();
}
}
/* ---- Create buttons state ---- */
function updateCreateButtons() {
var osType = document.getElementById('os-type').value;
/* Same ISO-required check for Windows and Linux now. */
var hasImage = (document.getElementById('image-path').value.trim() !== '');
var hasTpl = document.getElementById('template-select').value !== '';
document.getElementById('btn-create').disabled = hostBridge.isMac ? false : !(hasImage || hasTpl);
/* Templates are Windows-only; disabling create-as-template for Linux
(and macOS) is fine since hasImage is the only signal we check. */
document.getElementById('btn-create-template').disabled = (osType !== 'Windows') || !hasImage;
}
/* Wire up change events */
document.getElementById('image-path').addEventListener('input', function() {
if (this.value.trim() !== '') {
selectTemplate('', templateDefaultLabel());
}
updateCreateButtons();
});
function revalidateVmName() {
var name = document.getElementById('vm-name').value.trim();
document.getElementById('vm-name-warn').textContent = validateVmName(name) || '';
}
document.getElementById('vm-name').addEventListener('input', revalidateVmName);
function revalidateUsername() {
var u = document.getElementById('admin-user').value.trim();
document.getElementById('admin-user-warn').textContent = validateUsername(u) || '';
}
function revalidatePassword() {
var p = document.getElementById('admin-pass').value;
document.getElementById('admin-pass-warn').textContent = validatePassword(p) || '';
}
document.getElementById('admin-user').addEventListener('input', revalidateUsername);
function checkPasswordMatch() {
var pass = document.getElementById('admin-pass').value;
var confirm = document.getElementById('admin-confirm');
if (confirm.value === '' && pass === '') {
confirm.classList.remove('pass-mismatch', 'pass-match');
return;
}
if (confirm.value === pass) {
confirm.classList.remove('pass-mismatch');
confirm.classList.add('pass-match');
} else {
confirm.classList.remove('pass-match');
confirm.classList.add('pass-mismatch');
}
}
document.getElementById('admin-pass').addEventListener('input', function() {
checkPasswordMatch();
revalidatePassword();
});
document.getElementById('admin-confirm').addEventListener('input', checkPasswordMatch);
checkPasswordMatch();
function showPassword() {
document.getElementById('admin-pass').type = 'text';
document.getElementById('admin-confirm').type = 'text';
}
function hidePassword() {
document.getElementById('admin-pass').type = 'password';
document.getElementById('admin-confirm').type = 'password';
}
function onNetModeChange() {
/* Adapter dropdown only relevant for External */
var mode = parseInt(document.getElementById('net-mode').value);
var show = (mode === 2) ? '' : 'none';
document.getElementById('net-adapter').style.display = show;
document.getElementById('net-adapter-label').style.display = show;
}
onNetModeChange();
/* ---- Create VM ---- */
function gatherConfig() {
var osType = document.getElementById('os-type').value;
/* Same ISO-picker path for Windows and Linux. The cloud-image
Linux-version dropdown is dormant (see applyOsTypeUI). */
var imagePath = document.getElementById('image-path').value.trim();
return {
name: document.getElementById('vm-name').value.trim(),
osType: osType,
imagePath: imagePath,
templateName: document.getElementById('template-select').value,
hddGb: parseInt(document.getElementById('hdd-size').value) || 64,
ramMb: parseInt(document.getElementById('ram-size').value) || 16384,
cpuCores: parseInt(document.getElementById('cpu-cores').value) || 8,
gpuMode: parseInt(document.getElementById('gpu-mode').value),
networkMode: parseInt(document.getElementById('net-mode').value),
netAdapter: document.getElementById('net-adapter').value,
adminUser: document.getElementById('admin-user').value.trim(),
adminPass: document.getElementById('admin-pass').value,
adminConfirm: document.getElementById('admin-confirm').value,
testMode: document.getElementById('test-mode').checked,
sshEnabled: document.getElementById('ssh-enabled').checked
};
}
function clearCreateForm() {
document.getElementById('image-path').value = '';
selectTemplate('', templateDefaultLabel());
updateCreateButtons();
}
/* VM name / hostname validation. Per-guest-OS rules, keyed off the
selected OS Type (on a macOS host the dropdown is locked to 'macOS',
so osType is an accurate guest discriminator on all hosts). */
function validateVmName(name) {
if (!name) return 'VM name is required.';
var osSelect = document.getElementById('os-type');
var osType = osSelect ? osSelect.value : 'Windows';
if (osType === 'macOS') {
if (name.length > 63) return 'VM name cannot exceed 63 characters (macOS LocalHostName limit).';
} else if (osType === 'Linux') {
if (name.length > 63) return 'VM name cannot exceed 63 characters (Linux hostname limit).';
if (/[A-Z]/.test(name)) return 'Linux hostname must be lowercase.';
} else { /* Windows */
if (name.length > 15) return 'VM name cannot exceed 15 characters (NetBIOS limit).';
}
if (/[^a-zA-Z0-9-]/.test(name)) return 'VM name can only contain letters, digits, and hyphens.';
if (/^\d+$/.test(name)) return 'VM name cannot be only digits.';
if (name.startsWith('-') || name.endsWith('-')) return 'VM name cannot start or end with a hyphen.';
var lower = name.toLowerCase();
for (var i = 0; i < vms.length; i++) {
if (vms[i].name.toLowerCase() === lower) return 'A VM with this name already exists.';
}
for (var j = 0; j < currentTemplates.length; j++) {
if (currentTemplates[j].name.toLowerCase() === lower) return 'A template with this name already exists.';
}
return null;
}
/* Username validation. Per-guest-OS rules keyed off osType. Each branch
is explicit so it's clear which OS's account rules apply. */
function validateUsername(name) {
if (!name) return 'Username is required.';
var osSelect = document.getElementById('os-type');
var osType = osSelect ? osSelect.value : 'Windows';
if (osType === 'Linux') {
/* Ubuntu useradd/adduser: lowercase, start with a letter or
underscore, then [a-z0-9_-], max 32 chars. */
if (name.length > 32) return 'Username cannot exceed 32 characters (Linux limit).';
if (!/^[a-z_][a-z0-9_-]*$/.test(name))
return 'Lowercase alphanumeric only.';
return null;
}
/* macOS and Windows: keep the existing Windows-account ruleset.
(macOS-specific shortname rules are not yet verified; treated the
same as Windows for now — see validatePassword note.) */
if (name.length > 20) return 'Username cannot exceed 20 characters.';
if (/["\\/\[\]:;|=,+*?<>]/.test(name)) return 'Username contains invalid characters.';
if (/^[.\s]+$/.test(name)) return 'Username cannot be only dots or spaces.';
if (name.endsWith('.')) return 'Username cannot end with a period.';
var reserved = ['CON','PRN','AUX','NUL',
'COM1','COM2','COM3','COM4','COM5','COM6','COM7','COM8','COM9',
'LPT1','LPT2','LPT3','LPT4','LPT5','LPT6','LPT7','LPT8','LPT9'];
if (reserved.indexOf(name.toUpperCase()) >= 0) return 'Username is a reserved name.';
return null;
}
/* Password validation. Per-guest-OS rules keyed off osType.
- Linux: Ubuntu accepts ALL characters via the host's $6$ hash path
(usermod -p bypasses pwquality), so the only limits are non-empty
and a sane byte ceiling.
- macOS / Windows: no extra content rule enforced here today. */
function validatePassword(pass) {
var osSelect = document.getElementById('os-type');
var osType = osSelect ? osSelect.value : 'Windows';
if (osType === 'Linux') {
if (!pass) return 'Password is required.';
/* UTF-8 byte length (encodeURIComponent escapes multibyte). */
var bytes = unescape(encodeURIComponent(pass)).length;
if (bytes > 255) return 'Password is too long (max 255 bytes).';
return null;
}
/* macOS / Windows: no additional constraints today. */
return null;
}
function onCreateVm() {
var cfg = gatherConfig();
var nameErr = validateVmName(cfg.name);
if (nameErr) { sendCmd('log', { message: nameErr }); return; }
var userErr = validateUsername(cfg.adminUser);
if (userErr) { sendCmd('log', { message: userErr }); return; }
var passErr = validatePassword(cfg.adminPass);
if (passErr) { sendCmd('log', { message: passErr }); return; }
if (cfg.adminPass !== cfg.adminConfirm) {
sendCmd('log', { message: 'Passwords do not match.' });
return;
}
sendCmd('createVm', cfg);
clearCreateForm();
closeCreateModal();
}
function onCreateTemplate() {
var cfg = gatherConfig();
var nameErr = validateVmName(cfg.name);
if (nameErr) { sendCmd('log', { message: nameErr }); return; }
if (cfg.adminPass !== cfg.adminConfirm) {
sendCmd('log', { message: 'Passwords do not match.' });
return;
}
cfg.isTemplate = true;
sendCmd('createVm', cfg);
clearCreateForm();
closeCreateModal();
}
/* ---- Create Sandbox modal ---- */
function openCreateModal() {
/* Reset to defaults every time the modal opens */
document.getElementById('vm-name').value = 'MyAppSandbox';
document.getElementById('image-path').value = '';
selectTemplate('', templateDefaultLabel());
document.getElementById('hdd-size').value = 64;
document.getElementById('gpu-mode').value = '1';
document.getElementById('net-mode').value = '1';
document.getElementById('admin-user').value = 'user';
document.getElementById('admin-pass').value = 'test123';
document.getElementById('admin-confirm').value = 'test123';
document.getElementById('test-mode').checked = false;
document.getElementById('ssh-enabled').checked = false;
/* Reset OS type to Windows on each open (skip on macOS host — it's locked) */
if (!hostBridge.isMac) document.getElementById('os-type').value = 'Windows';
/* Smart defaults (RAM/cores) from latest host info */
if (lastHostInfo) applySmartDefaults(lastHostInfo);
/* Clear validation state */
document.getElementById('vm-name-warn').textContent = '';
document.getElementById('admin-user-warn').textContent = '';
document.getElementById('admin-pass-warn').textContent = '';
checkPasswordMatch();
onNetModeChange();
applyOsTypeUI(); /* fires updateCreateButtons + revalidateVmName */
document.getElementById('create-vm-overlay').classList.add('active');
setTimeout(function() { document.getElementById('vm-name').focus(); }, 0);
}
function closeCreateModal() {
document.getElementById('create-vm-overlay').classList.remove('active');
}
/* Close on backdrop click */
document.getElementById('create-vm-overlay').addEventListener('click', function(e) {
if (e.target === this) closeCreateModal();
});
/* Close on Escape */
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
if (document.getElementById('create-vm-overlay').classList.contains('active')) {
closeCreateModal();
}
});
/* ---- VM Table ---- */
/* Update the status <td> in place. Preserves the spinner element across
updates so its CSS animation doesn't restart on every staging-file tick. */
function updateStatusCell(td, vm) {
var needsSpinner = false;
var label = '';
var className = '';
if (vm.buildingVhdx) {
needsSpinner = true;
label = vm.vhdxStaging ? 'Staging files... ' : 'Building Disk (' + (vm.vhdxProgress || 0) + '%) ';
className = 'status-building';
} else if (vm.running && vm.shuttingDown) {
className = 'status-shutting-down';
label = 'Shutting Down';
} else if (vm.running && vm.isTemplate) {
needsSpinner = true;
label = 'Building Template ';
className = 'status-building';
} else if (vm.running && !vm.installComplete && !vm.isTemplate) {
needsSpinner = true;
var defaultLabel;
if (vm.osType === 'macOS') defaultLabel = 'Installing macOS ';
else if (vm.osType === 'Linux') defaultLabel = 'Installing Linux ';
else defaultLabel = 'Installing Windows ';
label = (vm.installStatus && vm.installStatus.length > 0)
? (vm.installStatus + ' ')
: defaultLabel;
className = 'status-building';
} else if (vm.running) {
className = 'status-running';
label = 'Running';
} else {
className = 'status-stopped';
label = 'Stopped';
}
td.className = className;
var existingSpinner = td.querySelector('.spinner');
if (needsSpinner) {
/* Drop any existing children except the spinner, then insert the new text
before it. The spinner stays in the document the whole time, so its
CSS animation clock isn't reset. */
if (existingSpinner) {
var child = td.firstChild;
while (child) {
var next = child.nextSibling;
if (child !== existingSpinner) td.removeChild(child);
child = next;
}
td.insertBefore(document.createTextNode(label), existingSpinner);
} else {
td.textContent = '';
td.appendChild(document.createTextNode(label));
var spin = document.createElement('span');
spin.className = 'spinner';
td.appendChild(spin);
}
} else {
/* No spinner needed — wipe and set plain text. Any existing spinner is
removed along with the old text. */
td.textContent = label;
}
}
/* Build the list of <td> cells for a row. The status cell is passed in and
updated in place (rather than recreated) so the spinner animation survives. */
function buildRowCells(vm, i, statusTd) {
updateStatusCell(statusTd, vm);
var agentTd = document.createElement('td');
var agentOff = !vm.running || vm.isTemplate;
var dotClass = 'agent-dot' + (vm.agentOnline ? ' online' : '') + (agentOff ? ' disabled' : '');
agentTd.innerHTML = '<span class="' + dotClass + '"></span>';
agentTd.title = vm.isTemplate
? 'Templates do not run the in-VM agent'
: (!vm.running
? 'VM is not running'
: (vm.agentOnline
? 'In-VM agent is connected — host can manage the guest'
: 'In-VM agent is not connected'));
var bld = vm.buildingVhdx;
var snapVal = selectedSnap[i] || 'current';
var sshActive = vm.sshEnabled && vm.sshState === 2 && vm.running && !bld;
var sshCell = makeIconCell('ssh', '>_', sshActive, (function(idx) { return function() { sendCmd('sshConnect', {vmIndex: idx}); }; })(i), !vm.sshEnabled ? 'hidden' : '');
if (vm.sshEnabled) {
var sshBtn = sshCell.querySelector('.icon-btn');
if (vm.sshState === 1) sshBtn.title = 'Installing OpenSSH in the guest...';
else if (vm.sshState === 2) sshBtn.title = 'Open an SSH terminal to the VM (localhost:' + vm.sshPort + ', tunneled over HvSocket)';
else if (vm.sshState === 3) sshBtn.title = 'SSH install failed';
else sshBtn.title = 'SSH: waiting for the in-VM agent to come online';
}
var cells = [
makeCell(vm.name, i, 0),
makeCell(vm.osType, i, 1),
statusTd,
agentTd,
makeCell(vm.cpuCores, i, 4, 'Number of virtual CPU cores assigned to this VM'),
makeCell(vm.ramMb + ' MB', i, 5, 'Memory allocated to this VM, in megabytes'),
makeCell(vm.hddGb + ' GB', i, 6, 'Virtual disk size, in gigabytes'),
makeCell(vm.gpuName || (vm.gpuMode === 2 ? 'Try all' : vm.gpuMode === 1 ? 'Default GPU' : 'None'), i, 7, 'GPU passed through to the VM via GPU-PV, or None'),
makeCell(netNames[vm.networkMode] || 'None', i, 8, 'Networking mode: NAT (shared), External (bridged), Internal (host-only), or None'),
];
if (!hostBridge.isMac) cells.push(makeSnapCell(vm, i));
cells.push(
makeIconCell('start', '\u25B6\uFE0F', !vm.running && !bld, (function(vmIdx, sv, vmObj) { return function() {
var p = parseSnapValue(sv);
if ((p.snapIndex >= 0 || p.snapIndex === -2) && p.branchIndex < 0) {
/* Creating a new branch — prompt for name */
var parentName = p.snapIndex === -2 ? 'Base' : ((vmObj.snapshots && vmObj.snapshots[p.snapIndex]) ? vmObj.snapshots[p.snapIndex].name : 'Snapshot');
var now = new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var defaultName = now.getFullYear() + '-' + pad(now.getMonth()+1) + '-' + pad(now.getDate()) + ' ' + pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds());
showModal('New Branch', 'A new branch will be created from ' + parentName + '. Branches are independent working copies \u2014 changes in one branch don\u2019t affect others or modify the base snapshot.', 'Boot', {
confirmClass: 'primary',
input: { label: 'Branch name:', value: defaultName }
}).then(function(result) {
if (result === false) return;
selectedSnap[vmIdx] = 'current';
sendCmd('startVm', { vmIndex: vmIdx, snapIndex: p.snapIndex, branchIndex: p.branchIndex, branchName: result });
});
} else {
sendCmd('startVm', { vmIndex: vmIdx, snapIndex: p.snapIndex, branchIndex: p.branchIndex });
}
}; })(i, snapVal, vm), '', 'Start the VM (boots from the selected snapshot/branch)'),
makeIconCell('connect-idd', '\uD83D\uDCFA', vm.running && !bld, function() { sendCmd('connectIddVm', {vmIndex: i}); }, '', 'Open the VM display window (IDD virtual monitor)'),
sshCell,
makeIconCell('shutdown', '\u23FB', vm.running && !bld, function() { sendCmd('shutdownVm', {vmIndex: i}); }, '', 'Request a graceful shutdown from the guest OS'),
makeIconCell('stop', '\u2715\uFE0F', vm.running && !bld, function() { onStopVm(i); }, '', 'Force power off the VM immediately (may lose unsaved guest data)'),
makeIconCell('delete', '\uD83D\uDDD1\uFE0F', !bld, function() { onDeleteVm(i); }, vm.running ? 'running' : '', 'Delete this VM and its virtual disks'),
makeIconCell('edit', editModeRow === i ? '\u2714\uFE0F' : '\u270F\uFE0F', !vm.running && !bld, function() { toggleEditMode(i); }, '', 'Edit VM configuration (CPU, RAM, GPU, network) — VM must be stopped'),
);
return cells;
}
function renderVmTable() {
var tbody = document.getElementById('vm-tbody');
if (vms.length === 0) {
rowCache = {};
rowSigCache = {};
tbody.innerHTML = '';
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = hostBridge.isMac ? 16 : 17;
td.className = 'empty-state';
var btn = document.createElement('button');
btn.className = 'primary empty-state-btn';
btn.textContent = '+ Create your first sandbox';
btn.onclick = openCreateModal;
td.appendChild(btn);
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
/* Drop cached rows for VMs that no longer exist. */
var seen = {};
vms.forEach(function(vm) { seen[vm.name] = true; });
Object.keys(rowCache).forEach(function(name) {
if (!seen[name]) {
var stale = rowCache[name];
if (stale.parentNode) stale.parentNode.removeChild(stale);
delete rowCache[name];
delete rowSigCache[name];
}
});
/* Remove any non-cached tbody children (e.g. leftover empty-state row). */
var kids = Array.prototype.slice.call(tbody.children);
kids.forEach(function(c) {
var cached = false;
for (var n in rowCache) { if (rowCache[n] === c) { cached = true; break; } }
if (!cached) tbody.removeChild(c);
});
/* Skip the cell rebuild when button-relevant fields are unchanged; the
* install progress tick would otherwise destroy the button DOM mid-click. */
vms.forEach(function(vm, i) {
var tr = rowCache[vm.name];
var firstBuild = !tr;
if (!tr) {
tr = document.createElement('tr');
rowCache[vm.name] = tr;
}
var statusTd = tr.children[2] || document.createElement('td');
updateStatusCell(statusTd, vm);
var sig = [
i === selectedVm, editModeRow === i,
vm.running, vm.buildingVhdx, vm.shuttingDown, vm.agentOnline,
vm.installComplete, vm.isTemplate,
vm.sshEnabled, vm.sshState, vm.sshPort,
vm.osType, vm.ramMb, vm.hddGb, vm.cpuCores,
vm.gpuMode, vm.gpuName, vm.networkMode,
selectedSnap[i] || 'current'
].join('|');
if (!firstBuild && rowSigCache[vm.name] === sig) {
if (tbody.children[i] !== tr) {
tbody.insertBefore(tr, tbody.children[i] || null);
}
return;
}
rowSigCache[vm.name] = sig;
tr.className = (i === selectedVm ? 'selected ' : '') +
(vm.running ? 'running' : 'stopped');
tr.onclick = function(e) {
if (e.target.closest('.icon-btn')) return;
if (e.target.closest('.editing')) return;
if (e.target.closest('.snap-cell')) return;
selectVm(i);
};
var cells = buildRowCells(vm, i, statusTd);
for (var c = 0; c < cells.length; c++) {
var newCell = cells[c];
var oldCell = tr.children[c];
if (oldCell === newCell) continue;
if (oldCell) tr.replaceChild(newCell, oldCell);
else tr.appendChild(newCell);
}
while (tr.children.length > cells.length) tr.removeChild(tr.lastChild);
if (tbody.children[i] !== tr) {
tbody.insertBefore(tr, tbody.children[i] || null);
}
});
}
function makeCell(text, row, col, title) {
var td = document.createElement('td');
td.textContent = text;
if (title) td.title = title;
/* Editable columns: 4=CPU, 5=RAM, 7=GPU, 8=Network */
if (editModeRow === row && (col === 4 || col === 5 || col === 7 || col === 8)) {
td.style.cursor = 'pointer';
td.title = 'Click to edit';
td.onclick = function(e) {
e.stopPropagation();
startInlineEdit(row, col, td);
};
}
return td;
}
function makeIconCell(cls, icon, active, handler, extraClass, title) {
var td = document.createElement('td');
td.className = 'icon-col';
var btn = document.createElement('button');
btn.className = 'icon-btn ' + cls + (active ? '' : ' inactive') + (extraClass ? ' ' + extraClass : '');
btn.textContent = icon;
if (title) btn.title = title;
if (active) btn.onclick = handler;
else btn.disabled = true;
td.appendChild(btn);
return td;
}
/* ---- VM Selection ---- */
function selectVm(idx) {
if (editingCell) commitInlineEdit();
if (editModeRow >= 0 && editModeRow !== idx) editModeRow = -1;
selectedVm = idx;
renderVmTable();
sendCmd('selectVm', { vmIndex: idx });
}
/* ---- Inline Editing ---- */
function toggleEditMode(row) {
if (editingCell) commitInlineEdit();
if (vms[row] && vms[row].running) return;
editModeRow = (editModeRow === row) ? -1 : row;
renderVmTable();
}
function startInlineEdit(row, col, td) {
if (editingCell) commitInlineEdit();
var vm = vms[row];
if (!vm || vm.running) return;
var oldValue;
/* Lock cell width before swapping content to prevent column resize */
var cellWidth = td.getBoundingClientRect().width;
td.style.width = cellWidth + 'px';
td.style.maxWidth = cellWidth + 'px';
td.classList.add('editing');
if (col === 7) {
/* GPU combo */
var sel = document.createElement('select');
sel.innerHTML = '<option value="0">None</option><option value="1">Default GPU</option><option value="2">Try all</option>';
sel.value = String(vm.gpuMode);
sel.onclick = function(e) { e.stopPropagation(); };
sel.onchange = function() { commitInlineEdit(); };
sel.onblur = function() { setTimeout(commitInlineEdit, 100); };
td.textContent = '';
td.appendChild(sel);
editingCell = { row: row, col: col, element: sel };
sel.focus();
setTimeout(function() { try { sel.showPicker(); } catch(e) {} }, 0);
} else if (col === 8) {
/* Network combo */
var sel = document.createElement('select');
sel.innerHTML = '<option value="0">None</option><option value="1">NAT</option><option value="2">External</option><option value="3">Internal</option>';
sel.value = String(vm.networkMode);
sel.onclick = function(e) { e.stopPropagation(); };
sel.onchange = function() { commitInlineEdit(); };
sel.onblur = function() { setTimeout(commitInlineEdit, 100); };
td.textContent = '';
td.appendChild(sel);
editingCell = { row: row, col: col, element: sel };
sel.focus();
setTimeout(function() { try { sel.showPicker(); } catch(e) {} }, 0);
} else {
/* Text/number input */
var inp = document.createElement('input');
inp.type = 'number';
inp.value = col === 4 ? String(vm.cpuCores) : String(vm.ramMb);
inp.onkeydown = function(e) {
if (e.key === 'Enter') commitInlineEdit();
else if (e.key === 'Escape') cancelInlineEdit();
};
inp.onblur = function() { commitInlineEdit(); };
td.textContent = '';
td.appendChild(inp);
inp.select();
inp.focus();
editingCell = { row: row, col: col, element: inp };
}
}
function commitInlineEdit() {
if (!editingCell) return;
var el = editingCell.element;
var row = editingCell.row;
var col = editingCell.col;
var value = el.value;
editingCell = null;
var field;
if (col === 4) field = 'cpuCores';
else if (col === 5) field = 'ramMb';
else if (col === 7) field = 'gpuMode';
else if (col === 8) field = 'networkMode';
if (field) {
sendCmd('editVm', { vmIndex: row, field: field, value: value });
if (field === 'networkMode' && value === '2' && currentDefaultAdapter) {
sendCmd('editVm', { vmIndex: row, field: 'netAdapter', value: currentDefaultAdapter });
}
}
}
function cancelInlineEdit() {
editingCell = null;
renderVmTable();
}
/* ---- Force Stop VM ---- */
function onStopVm(idx) {
var vm = vms[idx];
if (!vm) return;
if (vm.isTemplate) {
showModal(
'Cancel Template Build',
'Stopping a template build will delete the incomplete template "' + vm.name + '".\n\nAre you sure?',
'Stop & Delete'
).then(function(confirmed) {
if (confirmed) {
sendCmd('stopVm', { vmIndex: idx });
sendCmd('deleteVm', { vmIndex: idx });
}
});
} else {
if (localStorage.getItem('suppress_force_stop_warn') === '1') {
sendCmd('stopVm', { vmIndex: idx });
} else {
showForceStopModal(idx);
}
}
}
function showForceStopModal(idx) {
document.getElementById('modal-title').textContent = 'Force Stop';
document.getElementById('modal-message').textContent =
'Force Stop will immediately power-off "' + vms[idx].name + '" which may result in corruption of its data.';
document.getElementById('modal-confirm-btn').textContent = 'Force Stop';
var cb = document.getElementById('modal-dont-show');
if (cb) { cb.checked = false; cb.parentElement.style.display = ''; }
document.getElementById('modal-overlay').classList.add('active');
pendingConfirm = { resolve: function(confirmed) {
if (confirmed) {
if (cb && cb.checked) localStorage.setItem('suppress_force_stop_warn', '1');
sendCmd('stopVm', { vmIndex: idx });
}
if (cb) cb.parentElement.style.display = 'none';
}};
}
/* ---- Delete VM ---- */
function onDeleteVm(idx) {
var vm = vms[idx];
if (!vm) return;
showModal(
'Confirm Delete',
'Are you sure you want to delete VM "' + vm.name + '"?\n\nThis will permanently delete all disk data and snapshots.',
'Delete'
).then(function(confirmed) {
if (confirmed) {
sendCmd('deleteVm', { vmIndex: idx });
}
});
}
/* ---- Snapshots ---- */
/* Parse select value string into {snapIndex, branchIndex} */
function parseSnapValue(val) {
if (!val || val === 'current') return {snapIndex: -1, branchIndex: -1};
if (val === 'base') return {snapIndex: -2, branchIndex: -1};
if (val.substring(0, 5) === 'base-') return {snapIndex: -2, branchIndex: parseInt(val.substring(5))};
var parts = val.split('-');
if (parts.length === 1) return {snapIndex: parseInt(parts[0]), branchIndex: -1};
return {snapIndex: parseInt(parts[0]), branchIndex: parseInt(parts[1])};
}
function makeSnapCell(vm, vmIdx) {
var td = document.createElement('td');
td.className = 'snap-cell';
var snaps = vm.snapshots || [];
var baseBranches = vm.baseBranches || [];
var curSnap = vm.snapCurrent; /* -2=base, -1=pre-snapshot, >=0=snapshot index */
var curBranch = vm.snapCurrentBranch; /* branch index or -1 */
var hasSn = vm.hasSnapshots;
var sel = selectedSnap[vmIdx] || 'current';
var snapWrap = document.createElement('span');
snapWrap.className = 'snap-wrap';
var select = document.createElement('select');
select.className = 'snap-select';
select.disabled = vm.running;
function addOpt(value, text, selected) {
var o = document.createElement('option');
o.value = value;
o.textContent = text;
if (selected) o.selected = true;
select.appendChild(o);
}
if (!hasSn) {
addOpt('current', 'No snapshots', true);
} else {
/* Tree with multiple branches per node:
* Current (base, branch 1)
* \u251C Base <- new branch
* \u2502 \u251C branch 1 (date) <- resume
* \u2502 \u2514 branch 2 (date) <- resume
* \u251C Snapshot A (date) <- new branch
* \u2502 \u2514 branch 1 (date) <- resume
* \u2514 Snapshot B (date) <- new branch
*/
/* "Current" — resume whatever is active */
addOpt('current', 'Current', sel === 'current');
/* Base + its branches */
addOpt('base', '\u251C Base [create new child branch]', sel === 'base');
baseBranches.forEach(function(br, b) {
var brChar = (b === baseBranches.length - 1) ? '\u2514' : '\u251C';
var label = '\u2502\u00A0\u00A0' + brChar + ' ' + (br.name || 'branch ' + (b + 1));
if (br.date) label += ' (' + br.date + ')';
if (br.sizeGb) label += ' [' + br.sizeGb + ' GB]';
addOpt('base-' + b, label, sel === 'base-' + b);
});
/* Snapshots + their branches */
snaps.forEach(function(snap, i) {
var isLast = (i === snaps.length - 1);
var treePfx = isLast ? '\u2514 ' : '\u251C ';
var contPfx = isLast ? '\u00A0\u00A0\u00A0' : '\u2502\u00A0\u00A0';
var branches = snap.branches || [];
addOpt(String(i), treePfx + snap.name + ' (' + snap.date + ') [create new child branch]', sel === String(i));
branches.forEach(function(br, b) {
var brChar = (b === branches.length - 1) ? '\u2514' : '\u251C';
var label = contPfx + brChar + ' ' + (br.name || 'branch ' + (b + 1));
if (br.date) label += ' (' + br.date + ')';
if (br.sizeGb) label += ' [' + br.sizeGb + ' GB]';
addOpt(i + '-' + b, label, sel === i + '-' + b);
});
});
}
select.onchange = function(e) {
e.stopPropagation();
selectedSnap[vmIdx] = select.value;
renderVmTable();
};
snapWrap.appendChild(select);
/* Chain overlay — shows selected path when dropdown is closed */
if (hasSn) {
var p = parseSnapValue(sel);
var chainText = '';
if (sel === 'current') {
/* Show the currently active chain */
if (curSnap >= 0 && snaps[curSnap]) {
chainText = 'base \u2192 ' + snaps[curSnap].name;
if (curBranch >= 0 && snaps[curSnap].branches && snaps[curSnap].branches[curBranch])
chainText += ' \u2192 ' + (snaps[curSnap].branches[curBranch].name || 'branch ' + (curBranch + 1));
} else if (curSnap === -2) {
chainText = 'base';
if (curBranch >= 0 && baseBranches[curBranch])
chainText += ' \u2192 ' + (baseBranches[curBranch].name || 'branch ' + (curBranch + 1));
} else {
chainText = 'base';
}
} else if (p.snapIndex === -2) {
chainText = 'base';
if (p.branchIndex >= 0 && baseBranches[p.branchIndex])
chainText += ' \u2192 ' + (baseBranches[p.branchIndex].name || 'branch ' + (p.branchIndex + 1));
else
chainText += ' [create new child branch]';
} else if (p.snapIndex >= 0 && snaps[p.snapIndex]) {
chainText = 'base \u2192 ' + snaps[p.snapIndex].name;
if (p.branchIndex >= 0 && snaps[p.snapIndex].branches && snaps[p.snapIndex].branches[p.branchIndex])
chainText += ' \u2192 ' + (snaps[p.snapIndex].branches[p.branchIndex].name || 'branch ' + (p.branchIndex + 1));
else
chainText += ' [create new child branch]';
}
var overlay = document.createElement('span');
overlay.className = 'snap-overlay';
overlay.textContent = chainText;
snapWrap.appendChild(overlay);
}
td.appendChild(snapWrap);
/* Take snapshot button — only when stopped */
var takeBtn = document.createElement('button');
takeBtn.className = 'snap-btn';
takeBtn.textContent = '+';
takeBtn.title = 'Take snapshot';
takeBtn.disabled = vm.running;
takeBtn.onclick = function(e) {
e.stopPropagation();
var defaultName = 'Snapshot ' + (snaps.length + 1);
showModal('New Snapshot', 'Create a new snapshot of the base disk. Snapshots are frozen points in time that you can create independent branches from.', 'Create', {
confirmClass: 'primary',
input: { label: 'Snapshot name:', value: defaultName }
}).then(function(result) {
if (result === false) return;
sendCmd('snapTake', { vmIndex: vmIdx, name: result });
});
};
td.appendChild(takeBtn);
/* Delete button — context-sensitive */
var parsed = parseSnapValue(sel);
if (!vm.running && parsed.snapIndex >= 0) {
var delBtn = document.createElement('button');
delBtn.className = 'snap-btn danger';
delBtn.textContent = '\u2715';
if (parsed.branchIndex >= 0) {
/* Delete a single branch */
delBtn.title = 'Delete branch';
delBtn.onclick = function(e) {
e.stopPropagation();
showModal('Delete Branch',
'Delete this branch? The snapshot will be kept.',
'Delete'
).then(function(confirmed) {
if (confirmed) {
sendCmd('snapDeleteBranch', { vmIndex: vmIdx, snapIndex: parsed.snapIndex, branchIndex: parsed.branchIndex });
selectedSnap[vmIdx] = 'current';
}
});
};
} else {
/* Delete entire snapshot + all branches */
delBtn.title = 'Delete snapshot';
delBtn.onclick = function(e) {
e.stopPropagation();
var snapName = snaps[parsed.snapIndex] ? snaps[parsed.snapIndex].name : '';
showModal('Delete Snapshot',
'Delete snapshot "' + snapName + '" and all its branches?',
'Delete'
).then(function(confirmed) {
if (confirmed) {
sendCmd('snapDelete', { vmIndex: vmIdx, snapIndex: parsed.snapIndex });
selectedSnap[vmIdx] = 'current';
}
});
};
}
td.appendChild(delBtn);
}
/* Delete button for base branches */
if (!vm.running && parsed.snapIndex === -2 && parsed.branchIndex >= 0) {
var delBrBtn = document.createElement('button');
delBrBtn.className = 'snap-btn danger';
delBrBtn.textContent = '\u2715';
delBrBtn.title = 'Delete base branch';
delBrBtn.onclick = function(e) {
e.stopPropagation();
showModal('Delete Branch',
'Delete this base branch?',
'Delete'
).then(function(confirmed) {
if (confirmed) {
sendCmd('snapDeleteBranch', { vmIndex: vmIdx, snapIndex: -2, branchIndex: parsed.branchIndex });
selectedSnap[vmIdx] = 'current';
}
});
};
td.appendChild(delBrBtn);
}
/* Rename button — when a snapshot or branch is selected */
if (!vm.running && parsed.snapIndex !== -1) {
var currentName = '';
if (parsed.snapIndex === -2 && parsed.branchIndex >= 0 && baseBranches[parsed.branchIndex]) {
currentName = baseBranches[parsed.branchIndex].name || '';
} else if (parsed.snapIndex >= 0 && snaps[parsed.snapIndex]) {
if (parsed.branchIndex >= 0) {
var br = snaps[parsed.snapIndex].branches && snaps[parsed.snapIndex].branches[parsed.branchIndex];
currentName = br ? br.name || '' : '';
} else {
currentName = snaps[parsed.snapIndex].name || '';
}
}
if (currentName || parsed.snapIndex >= 0) {
var renBtn = document.createElement('button');
renBtn.className = 'snap-btn';
renBtn.textContent = '\u270F';
renBtn.title = 'Rename';
renBtn.onclick = function(e) {
e.stopPropagation();
showModal('Rename', 'Enter a new name:', 'Rename', {
confirmClass: 'primary',
input: { label: 'Name:', value: currentName }
}).then(function(result) {
if (result === false || result === currentName) return;
var cmd = { vmIndex: vmIdx, snapIndex: parsed.snapIndex, name: result };
if (parsed.branchIndex >= 0) cmd.branchIndex = parsed.branchIndex;
sendCmd('snapRename', cmd);
});
};
td.appendChild(renBtn);
}
}
return td;
}
/* ---- Log ---- */
function appendLog(msg) {
var panel = document.getElementById('log-panel');
var div = document.createElement('div');
div.className = 'log-line';
div.textContent = msg;
panel.appendChild(div);
panel.scrollTop = panel.scrollHeight;
}
/* ---- Prerequisite check ---- */
function onPrereqRequired() {
document.getElementById('prereq-message').innerHTML =
'App Sandbox requires the <strong>Virtual Machine Platform</strong> Windows feature to create and run VMs. This feature is not currently enabled.';
document.getElementById('prereq-buttons').innerHTML =
'<button onclick="document.getElementById(\'prereq-overlay\').classList.remove(\'active\')">Cancel</button>' +
'<button class="primary" onclick="enableFeature()">Enable</button>';
document.getElementById('prereq-buttons').style.display = '';
document.getElementById('prereq-overlay').classList.add('active');
}
function onPrereqReboot() {
document.getElementById('prereq-message').innerHTML =
'<strong>Virtual Machine Platform</strong> has been enabled but a reboot is required before VMs can be created or started.';
document.getElementById('prereq-buttons').innerHTML =
'<button onclick="document.getElementById(\'prereq-overlay\').classList.remove(\'active\')">Later</button>' +
'<button class="primary" onclick="sendCmd(\'enableFeatureReboot\')">Reboot Now</button>';
document.getElementById('prereq-buttons').style.display = '';
document.getElementById('prereq-overlay').classList.add('active');
}
function enableFeature() {
document.getElementById('prereq-message').innerHTML =
'Enabling <strong>Virtual Machine Platform</strong>. This may take a minute...' +
'<div class="prereq-progress"><div class="prereq-progress-bar" id="prereq-bar"></div></div>' +
'<div class="prereq-pct" id="prereq-pct">0%</div>';
document.getElementById('prereq-buttons').style.display = 'none';
sendCmd('enableFeature');
}
function onPrereqProgress(msg) {
var bar = document.getElementById('prereq-bar');
var pctEl = document.getElementById('prereq-pct');
if (bar) bar.style.width = msg.pct + '%';
if (pctEl) pctEl.textContent = msg.pct + '%';
}
function onPrereqResult(msg) {
if (msg.ok && !msg.reboot) {
document.getElementById('prereq-overlay').classList.remove('active');
} else if (msg.ok && msg.reboot) {
document.getElementById('prereq-message').innerHTML =
'<strong>Virtual Machine Platform</strong> has been enabled. A reboot is required for the change to take effect.';
document.getElementById('prereq-buttons').innerHTML =
'<button onclick="document.getElementById(\'prereq-overlay\').classList.remove(\'active\')">Later</button>' +
'<button class="primary" onclick="sendCmd(\'enableFeatureReboot\')">Reboot Now</button>';
document.getElementById('prereq-buttons').style.display = '';
} else {
document.getElementById('prereq-message').innerHTML =
'Failed to enable <strong>Virtual Machine Platform</strong>.<br><br>' +
'Try enabling it manually:<br>' +
'Settings &gt; System &gt; Optional Features &gt; More Windows Features &gt; Virtual Machine Platform';
document.getElementById('prereq-buttons').innerHTML =
'<button onclick="document.getElementById(\'prereq-overlay\').classList.remove(\'active\')">Close</button>';
document.getElementById('prereq-buttons').style.display = '';
}
}
/* ---- Modal ---- */
function showModal(title, message, confirmText, opts) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-message').textContent = message;
var confirmBtn = document.getElementById('modal-confirm-btn');
confirmBtn.textContent = confirmText || 'Confirm';
confirmBtn.className = (opts && opts.confirmClass) || 'danger';
var cb = document.getElementById('modal-dont-show');
if (cb) cb.parentElement.style.display = 'none';
var inputRow = document.getElementById('modal-input-row');
var inputEl = document.getElementById('modal-input');
if (opts && opts.input) {
inputRow.style.display = 'block';
inputEl.value = opts.input.value || '';
if (opts.input.label) document.getElementById('modal-input-label').textContent = opts.input.label;
inputEl.onkeydown = function(e) { if (e.key === 'Enter') modalResolve(true); };
inputEl.oninput = function() {
/* Strip characters that would break the INI-style .dat file */
var clean = inputEl.value.replace(/[\n\r\t\[\]\\]/g, '');
if (clean !== inputEl.value) inputEl.value = clean;
};
inputEl.maxLength = 127;
setTimeout(function() { inputEl.select(); inputEl.focus(); }, 50);
} else {
inputRow.style.display = 'none';
}
document.getElementById('modal-overlay').classList.add('active');
return new Promise(function(resolve) {
pendingConfirm = { resolve: resolve, hasInput: !!(opts && opts.input) };
});
}
function modalResolve(result) {
document.getElementById('modal-overlay').classList.remove('active');
if (pendingConfirm) {
if (result && pendingConfirm.hasInput) {
pendingConfirm.resolve(document.getElementById('modal-input').value);
} else {
pendingConfirm.resolve(result);
}
pendingConfirm = null;
}
}
/* ---- Minimum size reporting ---- */
function reportMinSize() {
var minW = 0;
/* Measure <table> elements directly — they always report true natural width */
var tables = document.querySelectorAll('table');
tables.forEach(function(t) {
if (t.scrollWidth > minW) minW = t.scrollWidth;
});
/* Add wrapper border (2px) + body padding (24px) */
minW += 28;
/* Height: sum of all sections at minimum height (log just needs ~100px) */
var minH = 0;
var sections = document.querySelectorAll('section');
sections.forEach(function(s, i) {
if (i < sections.length - 1) {
minH += s.scrollHeight + 12;
} else {
minH += 100;
}
});
minH += 16;
sendCmd('setMinSize', { width: minW, height: minH });
}
/* ---- Init ---- */
/* Signal to C that the UI is ready */
sendCmd('uiReady');
/* Report min size once layout is complete (covers case with no VMs) */
setTimeout(function() {
if (!minSizeReported) {
minSizeReported = true;
reportMinSize();
}
}, 300);