From c5778f3e9a3bab62ca85a081d1db887ec9e6fcc5 Mon Sep 17 00:00:00 2001 From: Mark Evangelista Date: Wed, 17 Jun 2026 22:37:38 -1000 Subject: [PATCH] Add configurable sandbox storage path (#24) Let users choose where a VM's VHDX is created instead of always using the default location under C:, addressing the common "not enough space on C:" problem (#24, #58). - New optional "Storage Folder" field in the create-sandbox modal, with a Browse button (IFileOpenDialog) wired through the webview2 bridge. - storage_folder added to AsbVmConfig/VmConfig; vhdx_dir construction branches on it when set and falls back to the default otherwise. - Validation of the chosen folder: path syntax, drive existence, length, creatability, and a guard against an existing disk at the target. - HDD size line shows the free space of the selected drive, refreshed as the field changes. --- src/app_win/ui.c | 64 ++++++++++++++++ src/backend_win/asb_core.c | 85 +++++++++++++++++++++ src/backend_win/asb_core.h | 4 + src/backend_win/hcs_vm.h | 1 + web/app.js | 148 +++++++++++++++++++++++++++++++++++-- web/index.html | 7 ++ 6 files changed, 303 insertions(+), 6 deletions(-) diff --git a/src/app_win/ui.c b/src/app_win/ui.c index 6058f3a..3e0711e 100644 --- a/src/app_win/ui.c +++ b/src/app_win/ui.c @@ -892,6 +892,7 @@ static void on_webview2_message(const wchar_t *json) wchar_t name_buf[256] = {0}, os_buf[32] = {0}, img_buf[MAX_PATH] = {0}; wchar_t tpl_buf[256] = {0}, user_buf[128] = {0}, pass_buf[128] = {0}; wchar_t adapter_buf[256] = {0}; + wchar_t storage_buf[MAX_PATH] = {0}; int val; BOOL is_tpl = FALSE; @@ -902,6 +903,7 @@ static void on_webview2_message(const wchar_t *json) json_get_string(json, L"adminUser", user_buf, 128); json_get_string(json, L"adminPass", pass_buf, 128); json_get_string(json, L"netAdapter", adapter_buf, 256); + json_get_string(json, L"storageFolder", storage_buf, MAX_PATH); json_get_bool(json, L"isTemplate", &is_tpl); ZeroMemory(&cfg, sizeof(cfg)); @@ -912,6 +914,7 @@ static void on_webview2_message(const wchar_t *json) cfg.username = user_buf; cfg.password = pass_buf; cfg.net_adapter = adapter_buf; + cfg.storage_folder = storage_buf; cfg.is_template = is_tpl; if (json_get_int(json, L"hddGb", &val)) cfg.hdd_gb = (DWORD)val; @@ -1074,6 +1077,67 @@ static void on_webview2_message(const wchar_t *json) jb_object_end(&jb); webview2_post(json_buf); } + } else if (wcscmp(action, L"browseStorageFolder") == 0) { + IFileOpenDialog *dlg = NULL; + if (SUCCEEDED(CoCreateInstance(&CLSID_FileOpenDialog, NULL, CLSCTX_ALL, + &IID_IFileOpenDialog, (void **)&dlg))) { + DWORD opts = 0; + dlg->lpVtbl->GetOptions(dlg, &opts); + dlg->lpVtbl->SetOptions(dlg, opts | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM); + dlg->lpVtbl->SetTitle(dlg, L"Choose storage folder"); + if (SUCCEEDED(dlg->lpVtbl->Show(dlg, g_hwnd_main))) { + IShellItem *item = NULL; + if (SUCCEEDED(dlg->lpVtbl->GetResult(dlg, &item))) { + PWSTR path = NULL; + if (SUCCEEDED(item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, &path))) { + wchar_t json_buf[MAX_PATH + 128]; + JsonBuilder jb; + jb_init(&jb, json_buf, _countof(json_buf)); + jb_object_begin(&jb); + jb_string(&jb, L"type", L"storageFolderPicked"); + jb_string(&jb, L"path", path); + jb_object_end(&jb); + webview2_post(json_buf); + CoTaskMemFree(path); + } + item->lpVtbl->Release(item); + } + } + dlg->lpVtbl->Release(dlg); + } + } else if (wcscmp(action, L"getDriveFreeSpace") == 0) { + wchar_t path_buf[MAX_PATH] = {0}; + ULARGE_INTEGER free_bytes = { 0 }; + wchar_t root[4] = {0}; + wchar_t json_buf[MAX_PATH + 128]; + JsonBuilder jb; + + json_get_string(json, L"path", path_buf, MAX_PATH); + if (path_buf[0] == 0) return; + + /* Derive drive root: e.g. "R:\sandboxes" -> "R:\" */ + if (wcslen(path_buf) >= 2 && path_buf[1] == L':') { + root[0] = path_buf[0]; root[1] = L':'; root[2] = L'\\'; root[3] = 0; + } else { + return; /* not an absolute path; JS will catch this */ + } + + if (!GetDiskFreeSpaceExW(root, &free_bytes, NULL, NULL)) return; + + jb_init(&jb, json_buf, _countof(json_buf)); + jb_object_begin(&jb); + jb_string(&jb, L"type", L"driveFreeSpace"); + jb_string(&jb, L"path", path_buf); + /* JsonBuilder has only jb_int (32-bit). Serialize uint64 as a decimal + string so JS can Number() it without precision loss (Number handles + integers up to 2^53 safely). */ + { + wchar_t num[32]; + _snwprintf_s(num, _countof(num), _TRUNCATE, L"%llu", free_bytes.QuadPart); + jb_string(&jb, L"freeBytes", num); + } + jb_object_end(&jb); + webview2_post(json_buf); } else if (wcscmp(action, L"snapTake") == 0) { int vi; wchar_t sname[128] = {0}; diff --git a/src/backend_win/asb_core.c b/src/backend_win/asb_core.c index dc7e0f5..b37842c 100644 --- a/src/backend_win/asb_core.c +++ b/src/backend_win/asb_core.c @@ -2599,6 +2599,79 @@ ASB_API void asb_detach(void) g_initialized = FALSE; } +/* Validate a user-supplied storage folder, and (as a side effect) create + the target sandbox folder if it doesn't exist. Returns S_OK if OK or + empty; E_INVALIDARG with an asb_log explanation on failure. Empty folder + is OK (caller falls through to the default ProgramData path). Creates + parent directories recursively via SHCreateDirectoryExW so multi-level + paths like R:\new\nested\sandboxes work in one call. The later + single-level CreateDirectoryW in asb_vm_create still runs but is then + a harmless redundant call (returns ERROR_ALREADY_EXISTS, ignored). */ +static HRESULT validate_storage_folder(const wchar_t *folder, const wchar_t *vm_name) +{ + wchar_t root[4]; + wchar_t target_disk[MAX_PATH]; + UINT drive_type; + DWORD attrs; + int sh_rc; + + if (!folder || !folder[0]) return S_OK; /* empty = use default; nothing to validate */ + + /* 1. Absolute Windows path: :\... */ + if (wcslen(folder) < 3 || + !((folder[0] >= L'A' && folder[0] <= L'Z') || (folder[0] >= L'a' && folder[0] <= L'z')) || + folder[1] != L':' || folder[2] != L'\\') { + asb_log(L"Error: Storage folder must be an absolute path like R:\\sandboxes (got: %s)", folder); + return E_INVALIDARG; + } + + /* 2. Drive is a local fixed volume. */ + root[0] = folder[0]; root[1] = L':'; root[2] = L'\\'; root[3] = L'\0'; + drive_type = GetDriveTypeW(root); + if (drive_type != DRIVE_FIXED) { + asb_log(L"Error: Storage folder must be on a local fixed drive, not network/removable (%s, type=%u)", + root, drive_type); + return E_INVALIDARG; + } + + /* 3. Combined path fits in MAX_PATH. The downstream construction is + \\disk.vhdx — 11 fixed chars (separator + "\\disk.vhdx"). + Reject early so swprintf_s never silently empties the buffer. */ + if (wcslen(folder) + wcslen(vm_name) + 11 >= MAX_PATH) { + asb_log(L"Error: Storage folder path is too long; folder + sandbox name + \\disk.vhdx must fit in %d characters.", + MAX_PATH); + return E_INVALIDARG; + } + + /* 4. The target sandbox folder is creatable (recursively). Use + SHCreateDirectoryExW so that something like R:\new\nested\path\ + gets all intermediate levels created in one call. Treat + ERROR_FILE_EXISTS / ERROR_ALREADY_EXISTS as success. */ + { + wchar_t target_dir[MAX_PATH]; + swprintf_s(target_dir, MAX_PATH, L"%s\\%s", folder, vm_name); + sh_rc = SHCreateDirectoryExW(NULL, target_dir, NULL); + if (sh_rc != ERROR_SUCCESS && + sh_rc != ERROR_FILE_EXISTS && + sh_rc != ERROR_ALREADY_EXISTS) { + asb_log(L"Error: Can't create storage folder %s (Win32 error %d)", + target_dir, sh_rc); + return E_INVALIDARG; + } + } + + /* 5. No existing disk.vhdx at the target sandbox folder. */ + swprintf_s(target_disk, MAX_PATH, L"%s\\%s\\disk.vhdx", folder, vm_name); + attrs = GetFileAttributesW(target_disk); + if (attrs != INVALID_FILE_ATTRIBUTES) { + asb_log(L"Error: A sandbox already exists at %s. Pick a different folder or sandbox name.", + target_disk); + return E_INVALIDARG; + } + + return S_OK; +} + /* ---- VM Create ---- */ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) @@ -2627,6 +2700,7 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) if (config->image_path) wcscpy_s(cfg.image_path, MAX_PATH, config->image_path); if (config->username) wcscpy_s(cfg.admin_user, 128, config->username); if (config->password) wcscpy_s(cfg.admin_pass, 128, config->password); + if (config->storage_folder) wcscpy_s(cfg.storage_folder, MAX_PATH, config->storage_folder); cfg.ram_mb = config->ram_mb; cfg.hdd_gb = config->hdd_gb; cfg.cpu_cores = config->cpu_cores; @@ -2644,6 +2718,12 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) is_template_create = config->is_template; cfg.is_template = is_template_create; + /* Validate user-supplied storage folder (if any). Empty folder is OK. */ + if (!is_template_create) { + HRESULT vhr = validate_storage_folder(cfg.storage_folder, cfg.name); + if (FAILED(vhr)) return vhr; + } + /* Defaults */ if (cfg.hdd_gb == 0) cfg.hdd_gb = 64; if (cfg.ram_mb == 0) cfg.ram_mb = 4096; @@ -2750,10 +2830,15 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) CreateDirectoryW(vhdx_dir, NULL); if (is_template_create) { + /* Templates always live host-wide in ProgramData. */ swprintf_s(vhdx_dir, MAX_PATH, L"%s\\AppSandbox\\templates", base_dir); CreateDirectoryW(vhdx_dir, NULL); swprintf_s(vhdx_dir, MAX_PATH, L"%s\\AppSandbox\\templates\\%s", base_dir, cfg.name); + } else if (cfg.storage_folder[0]) { + /* User-chosen folder: \. */ + swprintf_s(vhdx_dir, MAX_PATH, L"%s\\%s", cfg.storage_folder, cfg.name); } else { + /* Default: %ProgramData%\AppSandbox\. */ swprintf_s(vhdx_dir, MAX_PATH, L"%s\\AppSandbox\\%s", base_dir, cfg.name); } } diff --git a/src/backend_win/asb_core.h b/src/backend_win/asb_core.h index d89d0cb..046c72a 100644 --- a/src/backend_win/asb_core.h +++ b/src/backend_win/asb_core.h @@ -62,6 +62,10 @@ typedef struct { BOOL ssh_enabled; /* TRUE = install OpenSSH Server in guest */ BOOL ssh_deploy_key; /* TRUE = deploy the AppSandbox public key (needs ssh_enabled) */ BOOL is_template; /* TRUE = create as template VM */ + /* Optional: folder under which `\\disk.vhdx` will + live. NULL or empty = use default `%ProgramData%\AppSandbox\\`. + Ignored for templates (which always live in ProgramData). Windows only. */ + const wchar_t *storage_folder; } AsbVmConfig; /* ---- Snapshot/branch info (returned by query functions) ---- */ diff --git a/src/backend_win/hcs_vm.h b/src/backend_win/hcs_vm.h index d79753b..5a4deda 100644 --- a/src/backend_win/hcs_vm.h +++ b/src/backend_win/hcs_vm.h @@ -34,6 +34,7 @@ typedef struct { wchar_t os_type[32]; /* L"Windows" or L"Linux" */ wchar_t image_path[MAX_PATH]; /* ISO path */ wchar_t vhdx_path[MAX_PATH]; /* will be created if doesn't exist */ + wchar_t storage_folder[MAX_PATH]; /* empty = default; transient, not persisted */ DWORD ram_mb; DWORD hdd_gb; DWORD cpu_cores; diff --git a/web/app.js b/web/app.js index f13ea7f..520fda5 100644 --- a/web/app.js +++ b/web/app.js @@ -8,6 +8,8 @@ let selectedSnap = {}; /* vmIndex -> string value: 'current', 'base', 'base-N', let editModeRow = -1; let editingCell = null; /* {row, col, element} */ let pendingConfirm = null; /* {resolve} */ +let pendingFreeSpace = null; /* {resolve} for in-flight getDriveFreeSpace */ +let storageFolderDriveQuery = null; /* {drive} of the latest display-refresh query */ let minSizeReported = false; let lastHostInfo = null; let rowCache = {}; /* vm.name -> — persistent rows so the status spinner doesn't reset on every update */ @@ -137,6 +139,22 @@ window.onHostMessage = function(msg) { case 'log': appendLog(msg.message); break; case 'hostInfo': updateHostInfo(msg); break; case 'browseResult': onBrowseResult(msg.path); break; + case 'storageFolderPicked': onStorageFolderPicked(msg.path); break; + case 'driveFreeSpace': + if (pendingFreeSpace) { + pendingFreeSpace.resolve({ path: msg.path, freeBytes: Number(msg.freeBytes) }); + pendingFreeSpace = null; + } + /* Display refresh: only apply if this response matches the + drive we last queried for the host-hdd line. */ + if (storageFolderDriveQuery && msg.path && + msg.path.charAt(0).toUpperCase() === storageFolderDriveQuery.drive) { + var bytes = Number(msg.freeBytes); + var freeGb = Math.floor(bytes / (1024 * 1024 * 1024)); + var label = storageFolderDriveQuery.drive + ':\\'; + setHostHddText(freeGb, lastHostInfo ? lastHostInfo.vmHddGb : 0, label); + } + break; case 'confirmResult': if (pendingConfirm) pendingConfirm.resolve(msg.confirmed); break; case 'adapters': populateAdapters(msg.adapters, msg.defaultIndex); break; case 'templates': populateTemplates(msg.templates); break; @@ -202,8 +220,33 @@ function updateHostInfo(info) { 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'; + /* If the storage-folder field is empty, show default-drive free space. + Otherwise leave the host-hdd display alone — refreshStorageDriveFree + owns it while a custom folder is set. */ + var sf = document.getElementById('storage-folder'); + if (!sf || !sf.value.trim()) setHostHddText(info.freeGb, info.vmHddGb, null); +} + +function setHostHddText(freeGb, vmHddGb, driveLabel) { + var el = document.getElementById('host-hdd'); + if (!el) return; + var suffix = driveLabel ? (' on ' + driveLabel) : ''; + el.textContent = 'Free: ' + freeGb + ' GB' + suffix + ' | VMs allocated: ' + vmHddGb + ' GB'; +} + +/* When the storage-folder field changes (typed or picker), update the + "Free: X GB" display to reflect the chosen drive. Empty / invalid path + reverts to the cached default-drive figure from hostInfo. */ +function refreshStorageDriveFree() { + var sf = document.getElementById('storage-folder'); + var path = sf ? sf.value.trim() : ''; + if (!path || !/^[A-Za-z]:\\/.test(path)) { + storageFolderDriveQuery = null; + if (lastHostInfo) setHostHddText(lastHostInfo.freeGb, lastHostInfo.vmHddGb, null); + return; + } + storageFolderDriveQuery = { drive: path.charAt(0).toUpperCase() }; + sendCmd('getDriveFreeSpace', { path: path }); } /* ---- Adapters ---- */ @@ -337,6 +380,53 @@ function onBrowseResult(path) { } } +function onStorageFolderPicked(path) { + if (path) { + document.getElementById('storage-folder').value = path; + validateStorageFolder(); + updateCreateButtons(); + refreshStorageDriveFree(); + } +} + +/* Synchronous cheap validation of the storage-folder field. Returns null + on OK (including empty = use default), or an error string. Mirrors the + native check in validate_storage_folder so UX rejects bad input early. */ +function validateStorageFolder() { + var input = document.getElementById('storage-folder'); + var warn = document.getElementById('storage-folder-warn'); + if (!input) return null; /* macOS — field is hidden / absent */ + var value = input.value.trim(); + var err = null; + if (value) { + if (value.indexOf('\\\\') === 0) { + err = 'Network/UNC paths are not supported.'; + } else if (!/^[A-Za-z]:\\/.test(value)) { + err = 'Must be an absolute path like R:\\sandboxes.'; + } else if (/[<>|?*"]/.test(value)) { + err = 'Path contains invalid characters.'; + } + } + if (warn) warn.textContent = err || ''; + return err; +} + +/* Ask the host for free space on the drive containing `path`. Returns a + Promise<{path, freeBytes}>. Resolves with freeBytes=0 after 2s if the + host never replies — that way submit is never indefinitely blocked. */ +function getDriveFreeSpace(path) { + return new Promise(function(resolve) { + pendingFreeSpace = { resolve: resolve }; + setTimeout(function() { + if (pendingFreeSpace) { + pendingFreeSpace.resolve({ path: path, freeBytes: 0 }); + pendingFreeSpace = null; + } + }, 2000); + sendCmd('getDriveFreeSpace', { path: path }); + }); +} + /* ---- Create buttons state ---- */ function updateCreateButtons() { @@ -364,6 +454,18 @@ document.getElementById('ram-size').addEventListener('change', function() { if (!isNaN(mb)) this.value = alignRamMb(mb); }); +(function wireStorageFolderInput() { + var el = document.getElementById('storage-folder'); + if (!el) return; + var debounce = null; + el.addEventListener('input', function() { + validateStorageFolder(); + updateCreateButtons(); + if (debounce) clearTimeout(debounce); + debounce = setTimeout(refreshStorageDriveFree, 250); + }); +})(); + function revalidateVmName() { var name = document.getElementById('vm-name').value.trim(); document.getElementById('vm-name-warn').textContent = validateVmName(name) || ''; @@ -443,7 +545,8 @@ function gatherConfig() { adminConfirm: document.getElementById('admin-confirm').value, testMode: document.getElementById('test-mode').checked, sshEnabled: document.getElementById('ssh-enabled').checked, - sshDeployKey: document.getElementById('ssh-deploy-key').checked + sshDeployKey: document.getElementById('ssh-deploy-key').checked, + storageFolder: (document.getElementById('storage-folder') ? document.getElementById('storage-folder').value.trim() : '') }; } @@ -549,9 +652,37 @@ function onCreateVm() { sendCmd('log', { message: 'Passwords do not match.' }); return; } - sendCmd('createVm', cfg); - clearCreateForm(); - closeCreateModal(); + var sfErr = validateStorageFolder(); + if (sfErr) { sendCmd('log', { message: 'Storage folder: ' + sfErr }); return; } + + function submit() { + sendCmd('createVm', cfg); + clearCreateForm(); + closeCreateModal(); + } + + /* Free-space pre-flight only when a custom folder is set. Default + drive (ProgramData / C:) is not gated here. */ + if (cfg.storageFolder) { + getDriveFreeSpace(cfg.storageFolder).then(function(r) { + var requested = cfg.hddGb; + var freeGb = Math.floor(r.freeBytes / (1024 * 1024 * 1024)); + if (r.freeBytes > 0 && freeGb < requested) { + showModal( + 'Low disk space', + 'Only ' + freeGb + ' GB free on ' + r.path[0] + ':\\; sandbox is configured for ' + requested + ' GB.\n\nContinue anyway? VHDX files are dynamic — they grow over time, so a tight fit is not necessarily wrong.', + 'Continue', + { confirmClass: 'primary' } + ).then(function(ok) { + if (ok) submit(); + }); + } else { + submit(); + } + }); + } else { + submit(); + } } function onCreateTemplate() { @@ -574,6 +705,11 @@ function openCreateModal() { /* Reset to defaults every time the modal opens */ document.getElementById('vm-name').value = 'MyAppSandbox'; document.getElementById('image-path').value = ''; + var sf = document.getElementById('storage-folder'); + if (sf) sf.value = ''; + var sfw = document.getElementById('storage-folder-warn'); + if (sfw) sfw.textContent = ''; + refreshStorageDriveFree(); /* revert host-hdd line to default-drive view */ selectTemplate('', templateDefaultLabel()); document.getElementById('hdd-size').value = 64; document.getElementById('gpu-mode').value = '1'; diff --git a/web/index.html b/web/index.html index 27b85a4..14745b0 100644 --- a/web/index.html +++ b/web/index.html @@ -94,6 +94,13 @@

New Sandbox

+ +
+ + + +
+