From 978f52b8f58cbedc95e4610f982e50ecf8e8524e Mon Sep 17 00:00:00 2001 From: James Stringer <38541878+jamesstringer90@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:02:28 -0400 Subject: [PATCH 01/10] Fix VM setup, storage, snapshots, and viewer behavior --- src/app_mac/headless.m | 81 ++-- src/app_mac/ui.m | 61 ++- src/app_win/headless.c | 152 ++++--- src/app_win/ui.c | 223 ++++++++- src/app_win/webview2_bridge.c | 75 ++-- src/app_win/webview2_bridge.h | 2 + src/backend_mac/asb_core_mac.h | 8 +- src/backend_mac/asb_core_mac.m | 183 +++++++- src/backend_mac/host_info.h | 3 + src/backend_mac/host_info.m | 26 +- src/backend_mac/idd_display.m | 329 ++++++++++++-- src/backend_mac/iso_patch_mac.m | 1 + src/backend_mac/qemu_vm.m | 6 +- src/backend_mac/vm_dir.h | 5 + src/backend_mac/vm_dir.m | 75 +++- src/backend_win/asb_core.c | 672 +++++++++++++++++++++++----- src/backend_win/asb_core.h | 16 + src/backend_win/disk_util.c | 159 ++++++- src/backend_win/disk_util.h | 5 + src/backend_win/gpu_enum.c | 56 +++ src/backend_win/gpu_enum.h | 3 + src/backend_win/hcs_vm.h | 2 +- src/backend_win/snapshot.c | 28 ++ src/backend_win/snapshot.h | 3 + src/backend_win/vm_display_idd.c | 31 +- tools/agent/agent.c | 122 ++++- tools/agent/agent.vcxproj | 8 +- tools/agent/appsandbox-input.c | 16 + tools/agent/p9copy.c | 97 +++- tools/agent/p9copy.h | 11 + tools/iso-patch-mac/iso-patch-mac.m | 58 ++- tools/iso-patch/iso-patch.c | 2 +- tools/iso-patch/ubuntu_vhdx.c | 2 + tools/provision/win_provision.c | 27 +- web/app.js | 285 ++++++++++-- web/index.html | 32 +- web/style.css | 35 ++ 37 files changed, 2458 insertions(+), 442 deletions(-) diff --git a/src/app_mac/headless.m b/src/app_mac/headless.m index a77d007..e59bbcc 100644 --- a/src/app_mac/headless.m +++ b/src/app_mac/headless.m @@ -127,6 +127,7 @@ Otherwise an orphaned child (e.g. a QEMU VM left running after the app quits) ke static gid_t g_user_gid = 0; static dispatch_queue_t g_chown_q; static int g_chown_pending = 0; /* touched only on g_chown_q */ +static NSArray *g_chown_disk_paths; /* snapshot, owned by g_chown_q */ /* Resolve the invoking user's uid/gid from the SAME source vm_dir.m uses to * resolve the tree location -- getpwnam(SUDO_USER) -- so the chown target and @@ -148,7 +149,20 @@ static void chown_to_user(NSString *path) { chown(path.fileSystemRepresentation, g_user_uid, g_user_gid); } -static void fix_tree_ownership_now(void) { +/* Read the registry on the main queue; only VM disk paths may change owner. */ +static NSArray *vm_disk_ownership_paths(void) { + NSMutableArray *paths = [NSMutableArray array]; + for (int i = 0; i < asb_mac_vm_count(); i++) { + AsbVmMac *vm = asb_mac_vm_get(i); + if (!vm || !vm->disk_directory[0]) continue; + NSString *name = @(vm->name); + [paths addObject:[VmDir diskDirectoryForVm:name].path]; + [paths addObject:[VmDir diskImageURLFor:name].path]; + } + return paths; +} + +static void fix_tree_ownership_now(NSArray *disk_paths) { if (!g_user_uid) return; NSTask *t = [[NSTask alloc] init]; t.launchPath = @"/usr/sbin/chown"; @@ -157,6 +171,9 @@ static void fix_tree_ownership_now(void) { (unsigned)g_user_uid, (unsigned)g_user_gid], support_dir()]; if ([t launchAndReturnError:nil]) [t waitUntilExit]; + for (NSString *path in disk_paths) { + lchown(path.fileSystemRepresentation, g_user_uid, g_user_gid); + } } /* Debounced (5s) so chatty events coalesce; chown -R is metadata-only and the @@ -164,13 +181,15 @@ static void fix_tree_ownership_now(void) { * and written only on g_chown_q (serial), so no cross-thread race. */ static void fix_tree_ownership_soon(void) { if (!g_user_uid) return; + NSArray *diskPaths = vm_disk_ownership_paths(); dispatch_async(g_chown_q, ^{ + g_chown_disk_paths = diskPaths; if (g_chown_pending) return; /* a pass is already scheduled */ g_chown_pending = 1; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC), g_chown_q, ^{ g_chown_pending = 0; - fix_tree_ownership_now(); + fix_tree_ownership_now(g_chown_disk_paths); }); }); } @@ -300,6 +319,7 @@ static BOOL vm_installing(const AsbVmMac *v) { return @{ @"name": @(v->name), @"osType": @(v->os_type), + @"diskDirectory": [VmDir diskDirectoryForVm:@(v->name)].URLByDeletingLastPathComponent.path, @"state": @(derive_state(v)), @"running": v->running ? @YES : @NO, @"agentOnline": v->agent_online ? @YES : @NO, @@ -327,9 +347,10 @@ static BOOL vm_installing(const AsbVmMac *v) { * templates), plus the numeric rules the Windows daemon enforces, so the API * rejects exactly what the UI would. Returns an English error (nil = valid). */ static NSString *validate_create_mac(NSString *name, NSString *os, - NSString *user, BOOL is_template, + id user, id password, BOOL is_template, int ram_mb, int hdd_gb, int cpu_cores, - int gpu_mode, int net_mode) { + int gpu_mode, int net_mode, + NSString *disk_directory) { BOOL is_mac = [os.lowercaseString isEqualToString:@"macos"]; BOOL is_win = [os.lowercaseString isEqualToString:@"windows"]; if (!is_mac && !is_win) @@ -337,9 +358,12 @@ static BOOL vm_installing(const AsbVmMac *v) { if (is_template) return @"Templates are not yet supported for Windows-on-Mac (use a direct ISO install)."; - /* name / hostname (macOS LocalHostName rules, app.js validateVmName) */ if (!name.length) return @"VM name is required."; - if (name.length > 63) return @"VM name cannot exceed 63 characters (macOS LocalHostName limit)."; + if (is_win) { + if (name.length > 15) return @"VM name cannot exceed 15 characters (NetBIOS limit)."; + } else if (name.length > 63) { + return @"VM name cannot exceed 63 characters (macOS LocalHostName limit)."; + } BOOL all_digits = YES; for (NSUInteger i = 0; i < name.length; i++) { unichar ch = [name characterAtIndex:i]; @@ -354,24 +378,11 @@ static BOOL vm_installing(const AsbVmMac *v) { if (asb_mac_vm_find(name.UTF8String)) return @"A VM with this name already exists."; - /* username -- app.js applies the Windows-account ruleset on macOS */ - if (!user.length) return @"Username is required."; - if (user.length > 20) return @"Username cannot exceed 20 characters."; - BOOL only_dots_ws = YES; - for (NSUInteger i = 0; i < user.length; i++) { - unichar ch = [user characterAtIndex:i]; - if ([@"\"\\/[]:;|=,+*?<>" rangeOfString: - [NSString stringWithCharacters:&ch length:1]].location != NSNotFound) - return @"Username contains invalid characters."; - if (!(ch == '.' || ch == ' ' || ch == '\t')) only_dots_ws = NO; - } - if (only_dots_ws) return @"Username cannot be only dots or spaces."; - if ([user hasSuffix:@"."]) return @"Username cannot end with a period."; - NSArray *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 containsObject:user.uppercaseString]) - return @"Username is a reserved name."; + NSString *usernameError = asb_mac_validate_username(os, user, name); + if (usernameError) return usernameError; + + NSString *passwordError = asb_mac_validate_password(os, password); + if (passwordError) return passwordError; /* numeric ranges (0 = unset -> the core fills a default). The even-RAM rule is kept for interface parity with the GUI/Windows daemon. */ @@ -384,7 +395,7 @@ static BOOL vm_installing(const AsbVmMac *v) { if (gpu_mode < 0 || gpu_mode > 2) return @"gpuMode must be 0 (None), 1 (Default), or 2 (Try all)."; if (net_mode < 0 || net_mode > 3) return @"networkMode must be 0 (None), 1 (NAT), 2 (External), or 3 (Internal)."; - return nil; + return [VmDir validationErrorForDiskDirectory:disk_directory vmName:name]; } /* ---- Minimal HTTP plumbing (loopback only, Connection: close) ---- */ @@ -608,10 +619,11 @@ static void cleanup_and_exit(int code) { if (g_listen_fd >= 0) close(g_listen_fd); /* Orderly teardown of agent/proxy/clipboard threads; the VZ VMs themselves are in-process and terminate with us -- the daemon owns its VMs. */ + NSArray *diskPaths = vm_disk_ownership_paths(); asb_mac_cleanup(); /* Final synchronous hand-back: everything root touched in the user's AppSandbox tree is the user's again before we exit. */ - fix_tree_ownership_now(); + fix_tree_ownership_now(diskPaths); hlog(@"cleaned up. exit %d.", code); if (g_log) { fclose(g_log); g_log = NULL; } if (g_lock_fd >= 0) close(g_lock_fd); @@ -665,6 +677,7 @@ static int handle_request(int fd, HttpReq *r) { info = @{ @"hostCores": @([HostInfo hostCores]), @"hostRamMb": @([HostInfo hostRamMb]), @"freeGb": @([HostInfo freeGb]), + @"defaultDiskDirectory": [VmDir vmsRootDirectory].path, @"vmCores": @(vmCores), @"vmRamMb": @(vmRamMb), @"vmHddGb": @(vmHddGb) }; }); @@ -699,10 +712,13 @@ static int handle_request(int fd, HttpReq *r) { if (!b[@"name"]) name = @""; NSString *os = b[@"osType"] ? [b[@"osType"] description] : @"macOS"; NSString *img = b[@"imagePath"] ? [b[@"imagePath"] description] : @""; - NSString *user = b[@"adminUser"] ? [[b[@"adminUser"] description] - stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceAndNewlineCharacterSet]] : @""; - NSString *pass = b[@"adminPass"] ? [b[@"adminPass"] description] : @""; + if (b[@"diskDirectory"] && ![b[@"diskDirectory"] isKindOfClass:[NSString class]]) { + send_err(fd, 400, "Bad Request", @"invalid_arg", @"diskDirectory must be a string."); + return 0; + } + NSString *diskDirectory = b[@"diskDirectory"] ?: @""; + NSString *user = b[@"adminUser"]; + NSString *pass = b[@"adminPass"]; int ram = [b[@"ramMb"] intValue], hdd = [b[@"hddGb"] intValue]; int cpu = [b[@"cpuCores"] intValue], gpu = [b[@"gpuMode"] intValue]; int net = [b[@"networkMode"] intValue]; @@ -718,8 +734,8 @@ static int handle_request(int fd, HttpReq *r) { } __block NSString *verr; on_main(^{ - verr = validate_create_mac(name, os, user, isTemplate, - ram, hdd, cpu, gpu, net); + verr = validate_create_mac(name, os, user, pass, isTemplate, + ram, hdd, cpu, gpu, net, diskDirectory); }); if (verr) { send_err(fd, 400, "Bad Request", @"invalid_arg", verr); @@ -742,6 +758,7 @@ static int handle_request(int fd, HttpReq *r) { int rc = asb_mac_vm_create(name.UTF8String, os.UTF8String, ram, hdd, cpu, gpu, net, img.length ? img.UTF8String : NULL, + diskDirectory.UTF8String, user.UTF8String, pass.UTF8String, sshEnabled, sshDeploy, testMode); if (rc != BACKEND_OK) diff --git a/src/app_mac/ui.m b/src/app_mac/ui.m index 6aaf64f..af80a7e 100644 --- a/src/app_mac/ui.m +++ b/src/app_mac/ui.m @@ -12,6 +12,7 @@ #import "asb_core_mac.h" #import "vz_display.h" #import "host_info.h" +#import "vm_dir.h" #import "EventLogWindow.h" #include "asb_types.h" @@ -57,6 +58,7 @@ static void postToJs(NSDictionary *message) { return @{ @"name": [NSString stringWithUTF8String:vm->name], @"osType": [NSString stringWithUTF8String:vm->os_type], + @"diskDirectory": [VmDir diskDirectoryForVm:@(vm->name)].URLByDeletingLastPathComponent.path, @"running": @(vm->running || (!vm->disk_built && vm->install_progress >= 0)), @"shuttingDown": @(vm->shutting_down ? YES : NO), @"agentOnline": @(vm->agent_online ? YES : NO), @@ -111,6 +113,7 @@ static void postToJs(NSDictionary *message) { @"vmCores": @(vmCores), @"vmRamMb": @(vmRamMb), @"freeGb": @([HostInfo freeGb]), + @"defaultDiskDirectory": [VmDir vmsRootDirectory].path, @"vmHddGb": @(vmHddGb), }; } @@ -268,8 +271,9 @@ static void handleCreateVm(NSDictionary *msg) { NSString *name = msg[@"name"]; NSString *osType = msg[@"osType"] ?: @"macOS"; NSString *image = msg[@"imagePath"]; - NSString *adminUser = msg[@"adminUser"] ?: @"user"; - NSString *adminPass = msg[@"adminPass"] ?: @"test123"; + NSString *diskDirectory = msg[@"diskDirectory"] ?: @""; + NSString *adminUser = msg[@"adminUser"]; + NSString *adminPass = msg[@"adminPass"]; BOOL sshEnabled = [msg[@"sshEnabled"] boolValue]; BOOL sshDeployKey = [msg[@"sshDeployKey"] boolValue]; BOOL testMode = [msg[@"testMode"] boolValue]; @@ -279,10 +283,22 @@ static void handleCreateVm(NSDictionary *msg) { int gpuMode = [msg[@"gpuMode"] intValue]; int networkMode = [msg[@"networkMode"] intValue]; + NSString *usernameError = asb_mac_validate_username(osType, adminUser, name); + if (usernameError) { + sendAlert(usernameError); + return; + } + NSString *passwordError = asb_mac_validate_password(osType, adminPass); + if (passwordError) { + sendAlert(passwordError); + return; + } + const char *imagePath = (image.length > 0) ? [image UTF8String] : NULL; int rc = asb_mac_vm_create([name UTF8String], [osType UTF8String], ramMb, hddGb, cpuCores, gpuMode, networkMode, imagePath, + diskDirectory.UTF8String, [adminUser UTF8String], [adminPass UTF8String], sshEnabled, sshDeployKey, testMode); if (rc != 0) { @@ -358,6 +374,37 @@ static void handleBrowseImage(NSDictionary *msg) { }); } +static void handleBrowseDiskDirectory(NSDictionary *msg) { + NSString *initial = [msg[@"path"] isKindOfClass:[NSString class]] ? msg[@"path"] : @""; + dispatch_async(dispatch_get_main_queue(), ^{ + NSOpenPanel *panel = [NSOpenPanel openPanel]; + panel.canChooseFiles = NO; + panel.canChooseDirectories = YES; + panel.canCreateDirectories = YES; + panel.allowsMultipleSelection = NO; + panel.message = @"Choose a folder for this VM's disks"; + panel.directoryURL = initial.length ? [NSURL fileURLWithPath:initial isDirectory:YES] + : [VmDir vmsRootDirectory]; + [panel beginWithCompletionHandler:^(NSModalResponse result) { + NSString *path = result == NSModalResponseOK && panel.URL ? panel.URL.path : @""; + postToJs(@{ @"type": @"diskDirectoryBrowseResult", @"path": path }); + }]; + }); +} + +static void handleGetDiskSpace(NSDictionary *msg) { + BOOL validPath = !msg[@"path"] || [msg[@"path"] isKindOfClass:[NSString class]]; + NSString *path = validPath ? (msg[@"path"] ?: @"") : @""; + NSNumber *requestId = [msg[@"requestId"] isKindOfClass:[NSNumber class]] ? msg[@"requestId"] : @0; + /* Filesystem queries can block on external/network volumes. Echo the + * request so the UI can discard a reply after the user chooses another path. */ + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + int freeGb = validPath ? [HostInfo freeGbForDirectory:path] : -1; + postToJs(@{ @"type": @"diskSpace", @"path": path, + @"requestId": requestId, @"freeGb": @(freeGb) }); + }); +} + void ui_handle_message(NSString *json) { if (json.length == 0) return; NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; @@ -391,6 +438,10 @@ void ui_handle_message(NSString *json) { handleEditVm(msg); } else if ([action isEqualToString:@"browseImage"]) { handleBrowseImage(msg); + } else if ([action isEqualToString:@"browseDiskDirectory"]) { + handleBrowseDiskDirectory(msg); + } else if ([action isEqualToString:@"getDiskSpace"]) { + handleGetDiskSpace(msg); } else if ([action isEqualToString:@"log"]) { NSString *message = msg[@"message"]; if (message) sendLog(message); @@ -412,6 +463,8 @@ void ui_handle_message(NSString *json) { * script. No AppleScript, no Apple-Events TCC prompt. */ NSString *user = [NSString stringWithUTF8String: vm->admin_user[0] ? vm->admin_user : "user"]; + NSString *quotedUser = [NSString stringWithFormat:@"'%@'", + [user stringByReplacingOccurrencesOfString:@"'" withString:@"'\\''"]]; /* If this VM had the AppSandbox key deployed, use it (-i) so the * terminal logs in with key auth instead of a password prompt. * IdentitiesOnly avoids offering the user's other keys, and -- since @@ -432,8 +485,8 @@ void ui_handle_message(NSString *json) { NSString *body = [NSString stringWithFormat: @"#!/bin/sh\n" @"printf '\\033c'\n" - @"exec ssh %@%@@127.0.0.1 -p %d\n", - keyopt, user, vm->ssh_port]; + @"exec ssh %@-l %@ -p %d 127.0.0.1\n", + keyopt, quotedUser, vm->ssh_port]; NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: [NSString stringWithFormat:@"appsandbox-ssh-%@-%u.command", n, arc4random()]]; diff --git a/src/app_win/headless.c b/src/app_win/headless.c index f1f2a5b..8b3ae37 100644 --- a/src/app_win/headless.c +++ b/src/app_win/headless.c @@ -242,10 +242,14 @@ static const char *derive_state(VmInstance *v) /* Cheap per-VM status object (no disk I/O -- snapshot tree is a separate route). */ static int append_vm_json(char *out, int cap, int pos, VmInstance *v) { + wchar_t disk_directory[MAX_PATH]; pos += sprintf_s(out + pos, cap - pos, "{\"name\":"); pos = append_wstr(out, cap, pos, v->name); pos += sprintf_s(out + pos, cap - pos, ",\"osType\":"); pos = append_wstr(out, cap, pos, v->os_type); + asb_vm_disk_directory((AsbVm)v, disk_directory, MAX_PATH); + pos += sprintf_s(out + pos, cap - pos, ",\"diskDirectory\":"); + pos = append_wstr(out, cap, pos, disk_directory); pos += sprintf_s(out + pos, cap - pos, ",\"state\":\"%s\",\"running\":%s,\"agentOnline\":%s,\"installComplete\":%s," "\"building\":%s,\"progress\":%d,\"sshState\":%d,\"sshPort\":%lu," @@ -267,7 +271,7 @@ static int build_host_info(char *buf, int cap) { SYSTEM_INFO si; MEMORYSTATUSEX ms; ULARGE_INTEGER freeB; wchar_t pd[MAX_PATH]; - int i, count, vmCores = 0, vmRamMb = 0, vmHddGb = 0; + int i, count, pos, vmCores = 0, vmRamMb = 0, vmHddGb = 0; GetSystemInfo(&si); ms.dwLength = sizeof(ms); GlobalMemoryStatusEx(&ms); if (!GetEnvironmentVariableW(L"ProgramData", pd, MAX_PATH)) wcscpy_s(pd, MAX_PATH, L"C:\\"); @@ -279,13 +283,17 @@ static int build_host_info(char *buf, int cap) if (v->running) { vmCores += (int)v->cpu_cores; vmRamMb += (int)v->ram_mb; } vmHddGb += (int)v->hdd_gb; } - return sprintf_s(buf, cap, + pos = sprintf_s(buf, cap, "{\"hostCores\":%lu,\"hostRamMb\":%llu,\"freeGb\":%llu," - "\"vmCores\":%d,\"vmRamMb\":%d,\"vmHddGb\":%d}", + "\"vmCores\":%d,\"vmRamMb\":%d,\"vmHddGb\":%d,\"defaultDiskDirectory\":", (unsigned long)si.dwNumberOfProcessors, (unsigned long long)(ms.ullTotalPhys / (1024ULL * 1024)), (unsigned long long)(freeB.QuadPart / (1024ULL * 1024 * 1024)), vmCores, vmRamMb, vmHddGb); + asb_default_disk_directory(pd, MAX_PATH); + pos = append_wstr(buf, cap, pos, pd); + pos += sprintf_s(buf + pos, cap - pos, "}"); + return pos; } /* ---- HTTP helpers ---- */ @@ -340,7 +348,7 @@ static void send_hr(HTTP_REQUEST_ID id, const char *action, const char *nu, HRES } /* Read the request entity body explicitly (reliable) -> wide string for json_get_*. */ -static void body_to_wide(PHTTP_REQUEST req, wchar_t *wout, int wcap) +static BOOL body_to_wide(PHTTP_REQUEST req, wchar_t *wout, int wcap) { char body[8192]; int pos = 0; wout[0] = 0; @@ -353,11 +361,15 @@ static void body_to_wide(PHTTP_REQUEST req, wchar_t *wout, int wcap) if (pos >= (int)sizeof(body) - 1) break; } body[pos] = 0; + if (memchr(body, 0, pos)) return FALSE; /* On failure (e.g. the converted body would exceed wcap) MultiByteToWideChar leaves the buffer partially written WITHOUT a NUL -- treat that as an empty body rather than parse garbage. */ - if (!MultiByteToWideChar(CP_UTF8, 0, body, -1, wout, wcap)) + if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, body, -1, wout, wcap)) { wout[0] = 0; + return FALSE; + } + return TRUE; } static int auth_ok(PHTTP_REQUEST req) @@ -460,12 +472,7 @@ static void trim_ws(wchar_t *s) while (n > 0 && (s[n-1]==L' '||s[n-1]==L'\t'||s[n-1]==L'\r'||s[n-1]==L'\n')) s[--n] = 0; } -/* Mirror the GUI's JS input guards (web/app.js validateVmName / validateUsername / - validatePassword + the RAM/range rules) so the API rejects exactly what the UI - would, rather than forwarding unvalidated input to asb_vm_create (the core trusts - a pre-validated front end). Returns an English error (NULL = valid). */ static const char *validate_create(const wchar_t *name, const wchar_t *os, - const wchar_t *user, const wchar_t *pass, const wchar_t *tpl, const wchar_t *img, BOOL is_template, int ram_mb, int hdd_gb, int cpu_cores, int gpu_mode, int net_mode) @@ -505,44 +512,6 @@ static const char *validate_create(const wchar_t *name, const wchar_t *os, if (is_template && from_template) return "Cannot create a template from another template."; if (is_template && !is_win) return "Templates are only supported for Windows."; - /* username / password -- GUI validates on a normal create (onCreateVm) but - not on a template build (onCreateTemplate); match that. */ - if (!is_template) { - if (!user || !user[0]) return "Username is required."; - len = (int)wcslen(user); - if (is_linux) { - if (len > 32) return "Username cannot exceed 32 characters (Linux limit)."; - if (!((user[0]>=L'a'&&user[0]<=L'z')||user[0]==L'_')) - return "Linux username must start with a lowercase letter or underscore."; - for (i = 0; i < len; i++) { - wchar_t ch = user[i]; - if (!((ch>=L'a'&&ch<=L'z')||(ch>=L'0'&&ch<=L'9')||ch==L'_'||ch==L'-')) - return "Linux username: lowercase letters, digits, '_' and '-' only."; - } - } else { - BOOL only_dots_ws = TRUE; - if (len > 20) return "Username cannot exceed 20 characters."; - for (i = 0; i < len; i++) /* mirror app.js /^[.\s]+$/ */ - if (!(user[i]==L'.' || user[i]==L' ' || user[i]==L'\t')) { only_dots_ws = FALSE; break; } - if (only_dots_ws) return "Username cannot be only dots or spaces."; - for (i = 0; i < len; i++) - if (wcschr(L"\"\\/[]:;|=,+*?<>", user[i])) return "Username contains invalid characters."; - if (user[len-1] == L'.') return "Username cannot end with a period."; - { - static const wchar_t *res[] = {L"CON",L"PRN",L"AUX",L"NUL", - L"COM1",L"COM2",L"COM3",L"COM4",L"COM5",L"COM6",L"COM7",L"COM8",L"COM9", - L"LPT1",L"LPT2",L"LPT3",L"LPT4",L"LPT5",L"LPT6",L"LPT7",L"LPT8",L"LPT9"}; - int r; for (r = 0; r < (int)(sizeof(res)/sizeof(res[0])); r++) - if (_wcsicmp(user, res[r]) == 0) return "Username is a reserved name."; - } - } - if (is_linux) { - if (!pass || !pass[0]) return "Password is required."; - if (WideCharToMultiByte(CP_UTF8,0,pass,-1,NULL,0,NULL,NULL) - 1 > 255) - return "Password is too long (max 255 bytes)."; - } - } - /* numeric ranges (HTML: ram>=512 step2, hdd>=1, cpu>=1; enums). 0 = unset -> the core fills a default, so only check explicitly-provided values. */ if (ram_mb != 0) { @@ -642,21 +611,55 @@ static int handle_request(PHTTP_REQUEST req) wchar_t body[8192]; AsbVmConfig cfg; int iv; BOOL bv; wchar_t name[256]={0}, os[32]={0}, img[MAX_PATH]={0}, tpl[256]={0}; - wchar_t user[128]={0}, pass[128]={0}, adapter[256]={0}; + wchar_t user[128]={0}, pass[256]={0}, adapter[256]={0}; + wchar_t disk_directory[MAX_PATH + 1]={0}; char nu[256]={0}; - body_to_wide(req, body, 8192); + if (!body_to_wide(req, body, 8192)) { + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", + "Request body must contain valid UTF-8 JSON without NUL bytes."); + return 0; + } ZeroMemory(&cfg, sizeof(cfg)); json_get_string(body, L"name", name, 256); json_get_string(body, L"osType", os, 32); json_get_string(body, L"imagePath", img, MAX_PATH); json_get_string(body, L"templateName", tpl, 256); - json_get_string(body, L"adminUser", user, 128); - json_get_string(body, L"adminPass", pass, 128); + if (!json_get_string(body, L"adminUser", user, ARRAYSIZE(user))) { + const char *error = !json_has_key(body, L"adminUser") + ? "Username is required." + : user[0] + ? (_wcsicmp(os, L"Linux") == 0 && !tpl[0] + ? "Username cannot exceed 32 characters (Linux limit)." + : "Username cannot exceed 20 characters.") + : "adminUser must be a valid JSON string without NUL characters."; + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", error); + return 0; + } + if (!json_get_string(body, L"adminPass", pass, ARRAYSIZE(pass))) { + const char *error = !json_has_key(body, L"adminPass") + ? "Password is required." + : pass[0] + ? (_wcsicmp(os, L"Linux") == 0 && !tpl[0] + ? "Password is too long (max 255 bytes)." + : "Password is too long (max 127 characters for Windows).") + : "adminPass must be a valid JSON string without NUL characters."; + SecureZeroMemory(pass, sizeof(pass)); + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", error); + return 0; + } json_get_string(body, L"netAdapter", adapter, 256); + if (!json_get_string(body, L"diskDirectory", disk_directory, MAX_PATH + 1) && + json_has_key(body, L"diskDirectory")) { + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", + disk_directory[0] ? "Disk storage path is too long." : "diskDirectory must be a string without NUL characters."); + return 0; + } + trim_ws(disk_directory); trim_ws(name); trim_ws(user); /* match the GUI's .value.trim() */ cfg.name = name; cfg.os_type = os; cfg.image_path = img; cfg.template_name = tpl; cfg.username = user; cfg.password = pass; cfg.net_adapter = adapter; + cfg.disk_directory = disk_directory; if (json_get_int(body, L"ramMb", &iv)) cfg.ram_mb = (DWORD)iv; if (json_get_int(body, L"hddGb", &iv)) cfg.hdd_gb = (DWORD)iv; if (json_get_int(body, L"cpuCores", &iv)) cfg.cpu_cores = (DWORD)iv; @@ -672,10 +675,51 @@ static int handle_request(PHTTP_REQUEST req) return 0; } { - const char *verr = validate_create(name, os, user, pass, tpl, img, + const wchar_t *password_os = os; + const wchar_t *verr; + for (i = 0; tpl[0] && i < asb_template_count(); i++) { + if (_wcsicmp(tpl, asb_template_name(i)) == 0) { + password_os = asb_template_os_type(i); + break; + } + } + verr = asb_validate_username(password_os, user, name, cfg.is_template); + if (!verr) verr = asb_validate_password(password_os, pass); + if (verr) { + char message[512]; + WideCharToMultiByte(CP_UTF8, 0, verr, -1, message, sizeof(message), NULL, NULL); + SecureZeroMemory(pass, sizeof(pass)); + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", message); + return 0; + } + } + { + const char *verr = validate_create(name, os, tpl, img, cfg.is_template, (int)cfg.ram_mb, (int)cfg.hdd_gb, (int)cfg.cpu_cores, cfg.gpu_mode, cfg.network_mode); - if (verr) { send_err(req->RequestId, 400, "Bad Request", "invalid_arg", verr); return 0; } + if (verr) { + SecureZeroMemory(pass, sizeof(pass)); + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", verr); + return 0; + } + } + { + const wchar_t *verr = asb_validate_disk_directory(name, disk_directory, cfg.is_template); + if (verr) { + char message[512]; + WideCharToMultiByte(CP_UTF8, 0, verr, -1, message, sizeof(message), NULL, NULL); + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", message); + return 0; + } + } + { + const wchar_t *verr = asb_validate_template_disk_size(tpl, cfg.hdd_gb); + if (verr) { + char message[512]; + WideCharToMultiByte(CP_UTF8, 0, verr, -1, message, sizeof(message), NULL, NULL); + send_err(req->RequestId, 400, "Bad Request", "invalid_arg", message); + return 0; + } } WideCharToMultiByte(CP_UTF8,0,name,-1,nu,sizeof(nu),NULL,NULL); { diff --git a/src/app_win/ui.c b/src/app_win/ui.c index 6058f3a..cd88f32 100644 --- a/src/app_win/ui.c +++ b/src/app_win/ui.c @@ -21,6 +21,7 @@ #include #include #include +#include #include #pragma comment(lib, "dwmapi.lib") @@ -61,6 +62,7 @@ static VmDisplayIdd *g_idd_displays[ASB_MAX_VMS]; #define WM_SHOW_ALERT (WM_APP + 15) #define WM_VM_SHUTDOWN_TIMEOUT (WM_APP + 9) #define WM_PREREQ_DONE (WM_APP + 17) +#define WM_DISK_SPACE (WM_APP + 19) /* Tray */ #define TRAY_CMD_SHOW 1 @@ -125,6 +127,90 @@ static void safe_destroy_idd(int idx) /* ---- JSON state builders ---- */ +static int query_disk_free_gb(const wchar_t *selected) +{ + wchar_t supplied[MAX_PATH], path[MAX_PATH + 1]; + ULARGE_INTEGER available; + ULONGLONG gb; + DWORD length, attrs; + size_t i, len; + + if (!selected || !selected[0]) { + asb_default_disk_directory(supplied, MAX_PATH); + } else { + if (wcslen(selected) >= MAX_PATH) return -1; + wcscpy_s(supplied, MAX_PATH, selected); + } + len = wcslen(supplied); + for (i = 0; i < len; i++) { + if (supplied[i] == L'/') supplied[i] = L'\\'; + if (supplied[i] < 32 || wcschr(L"*?\"<>|", supplied[i]) || + (supplied[i] == L':' && i != 1)) + return -1; + } + if (!((len >= 3 && ((supplied[0] >= L'A' && supplied[0] <= L'Z') || + (supplied[0] >= L'a' && supplied[0] <= L'z')) && + supplied[1] == L':' && supplied[2] == L'\\') || + (len >= 5 && supplied[0] == L'\\' && supplied[1] == L'\\'))) + return -1; + length = GetFullPathNameW(supplied, MAX_PATH, path, NULL); + if (!length || length >= MAX_PATH) return -1; + attrs = GetFileAttributesW(path); + if (attrs == INVALID_FILE_ATTRIBUTES || !(attrs & FILE_ATTRIBUTE_DIRECTORY)) + return -1; + /* UNC volume queries require a trailing separator. */ + if (path[length - 1] != L'\\') { + path[length++] = L'\\'; + path[length] = 0; + } + if (!GetDiskFreeSpaceExW(path, &available, NULL, NULL)) return -1; + gb = available.QuadPart / (1024ULL * 1024 * 1024); + return gb > INT_MAX ? INT_MAX : (int)gb; +} + +static void send_disk_space(const wchar_t *json, BOOL query) +{ + size_t input_len = wcslen(json); + wchar_t *path, *response; + JsonBuilder jb; + int request_id = 0, free_gb = -1; + + /* Preserve the requested path in the reply even when it exceeds MAX_PATH + and is therefore rejected by the filesystem query. */ + if (input_len > (((size_t)-1) / sizeof(wchar_t) - 128) / 2) return; + path = (wchar_t *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, + (input_len + 1) * sizeof(wchar_t)); + response = (wchar_t *)HeapAlloc(GetProcessHeap(), 0, + (input_len * 2 + 128) * sizeof(wchar_t)); + if (!path || !response) { + if (path) HeapFree(GetProcessHeap(), 0, path); + if (response) HeapFree(GetProcessHeap(), 0, response); + return; + } + if (json_get_string(json, L"path", path, input_len + 1) && query) + free_gb = query_disk_free_gb(path); + json_get_int(json, L"requestId", &request_id); + jb_init(&jb, response, input_len * 2 + 128); + jb_object_begin(&jb); + jb_string(&jb, L"type", L"diskSpace"); + jb_string(&jb, L"path", path); + jb_int(&jb, L"requestId", request_id); + jb_int(&jb, L"freeGb", free_gb); + jb_object_end(&jb); + /* WebView2 is apartment-bound; deliver the response on the UI thread. */ + if (!PostMessageW(g_hwnd_main, WM_DISK_SPACE, 0, (LPARAM)response)) + HeapFree(GetProcessHeap(), 0, response); + HeapFree(GetProcessHeap(), 0, path); +} + +static DWORD WINAPI disk_space_thread(LPVOID param) +{ + wchar_t *json = (wchar_t *)param; + send_disk_space(json, TRUE); + free(json); + return 0; +} + static void build_host_info_json(JsonBuilder *jb) { SYSTEM_INFO si; @@ -132,8 +218,7 @@ static void build_host_info_json(JsonBuilder *jb) DWORD host_cores, host_ram_mb; DWORD vm_cores = 0, vm_ram_mb = 0, vm_hdd_gb = 0; wchar_t base_dir[MAX_PATH]; - ULARGE_INTEGER free_bytes; - DWORD free_gb = 0; + int free_gb; int i, count = asb_vm_count(); GetSystemInfo(&si); @@ -148,17 +233,16 @@ static void build_host_info_json(JsonBuilder *jb) if (v) vm_hdd_gb += v->hdd_gb; } - if (!GetEnvironmentVariableW(L"ProgramData", base_dir, MAX_PATH)) - wcscpy_s(base_dir, MAX_PATH, L"C:\\ProgramData"); - if (GetDiskFreeSpaceExW(base_dir, &free_bytes, NULL, NULL)) - free_gb = (DWORD)(free_bytes.QuadPart / (1024ULL * 1024 * 1024)); + asb_default_disk_directory(base_dir, MAX_PATH); + free_gb = query_disk_free_gb(base_dir); jb_int(jb, L"hostCores", (int)host_cores); jb_int(jb, L"hostRamMb", (int)host_ram_mb); jb_int(jb, L"vmCores", (int)vm_cores); jb_int(jb, L"vmRamMb", (int)vm_ram_mb); - jb_int(jb, L"freeGb", (int)free_gb); + jb_int(jb, L"freeGb", free_gb); jb_int(jb, L"vmHddGb", (int)vm_hdd_gb); + jb_string(jb, L"defaultDiskDirectory", base_dir); } static ULONGLONG get_file_size_bytes(const wchar_t *path) @@ -185,6 +269,7 @@ static void jb_size_gb(JsonBuilder *jb, const wchar_t *key, ULONGLONG bytes) static void build_vm_json(JsonBuilder *jb, int i) { + wchar_t disk_directory[MAX_PATH]; VmInstance *v = asb_vm_instance(asb_vm_get(i)); SnapshotTree *st_ = asb_vm_snap_tree(asb_vm_get(i)); if (!v || !st_) return; @@ -192,6 +277,8 @@ static void build_vm_json(JsonBuilder *jb, int i) jb_object_begin(jb); jb_string(jb, L"name", v->name); jb_string(jb, L"osType", v->os_type); + asb_vm_disk_directory(asb_vm_get(i), disk_directory, MAX_PATH); + jb_string(jb, L"diskDirectory", disk_directory); jb_bool(jb, L"running", v->running); jb_bool(jb, L"shuttingDown", v->shutdown_requested); jb_bool(jb, L"agentOnline", v->agent_online); @@ -300,11 +387,11 @@ static void build_vm_json(JsonBuilder *jb, int i) static void send_vm_list(void) { - wchar_t buf[16384]; + wchar_t buf[32768]; JsonBuilder jb; int i, count = asb_vm_count(); - jb_init(&jb, buf, 16384); + jb_init(&jb, buf, 32768); jb_object_begin(&jb); jb_string(&jb, L"type", L"vmListChanged"); @@ -316,9 +403,9 @@ static void send_vm_list(void) jb_array_end(&jb); { - wchar_t hi_buf[512]; + wchar_t hi_buf[2048]; JsonBuilder hi; - jb_init(&hi, hi_buf, 512); + jb_init(&hi, hi_buf, 2048); jb_object_begin(&hi); build_host_info_json(&hi); jb_object_end(&hi); @@ -334,9 +421,9 @@ static void send_vm_list(void) static void send_host_info(void) { - wchar_t buf[512]; + wchar_t buf[2048]; JsonBuilder jb; - jb_init(&jb, buf, 512); + jb_init(&jb, buf, 2048); jb_object_begin(&jb); jb_string(&jb, L"type", L"hostInfo"); build_host_info_json(&jb); @@ -420,6 +507,14 @@ static void send_templates(void) webview2_post(buf); } +static int CALLBACK disk_folder_browse_callback(HWND hwnd, UINT message, LPARAM lp, LPARAM data) +{ + (void)lp; + if (message == BFFM_INITIALIZED && data) + SendMessageW(hwnd, BFFM_SETSELECTIONW, TRUE, data); + return 0; +} + static void send_full_state(void) { wchar_t buf[32768]; @@ -440,9 +535,9 @@ static void send_full_state(void) /* Host info */ { - wchar_t hi[512]; + wchar_t hi[2048]; JsonBuilder hj; - jb_init(&hj, hi, 512); + jb_init(&hj, hi, 2048); jb_object_begin(&hj); build_host_info_json(&hj); jb_object_end(&hj); @@ -890,8 +985,8 @@ static void on_webview2_message(const wchar_t *json) } AsbVmConfig cfg; 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 tpl_buf[256] = {0}, user_buf[128] = {0}, pass_buf[256] = {0}; + wchar_t adapter_buf[256] = {0}, disk_buf[MAX_PATH + 1] = {0}; int val; BOOL is_tpl = FALSE; @@ -899,9 +994,35 @@ static void on_webview2_message(const wchar_t *json) json_get_string(json, L"osType", os_buf, 32); json_get_string(json, L"imagePath", img_buf, MAX_PATH); json_get_string(json, L"templateName", tpl_buf, 256); - json_get_string(json, L"adminUser", user_buf, 128); - json_get_string(json, L"adminPass", pass_buf, 128); + if (!json_get_string(json, L"adminUser", user_buf, ARRAYSIZE(user_buf))) { + const wchar_t *error = !json_has_key(json, L"adminUser") + ? L"Username is required." + : user_buf[0] + ? (_wcsicmp(os_buf, L"Linux") == 0 && !tpl_buf[0] + ? L"Username cannot exceed 32 characters (Linux limit)." + : L"Username cannot exceed 20 characters.") + : L"adminUser must be a valid JSON string without NUL characters."; + ui_show_alert(error); + return; + } + if (!json_get_string(json, L"adminPass", pass_buf, ARRAYSIZE(pass_buf))) { + const wchar_t *error = !json_has_key(json, L"adminPass") + ? L"Password is required." + : pass_buf[0] + ? (_wcsicmp(os_buf, L"Linux") == 0 && !tpl_buf[0] + ? L"Password is too long (max 255 bytes)." + : L"Password is too long (max 127 characters for Windows).") + : L"adminPass must be a valid JSON string without NUL characters."; + SecureZeroMemory(pass_buf, sizeof(pass_buf)); + ui_show_alert(error); + return; + } json_get_string(json, L"netAdapter", adapter_buf, 256); + if (!json_get_string(json, L"diskDirectory", disk_buf, MAX_PATH + 1) && + json_has_key(json, L"diskDirectory")) { + ui_show_alert(L"Disk folder is invalid or too long."); + return; + } json_get_bool(json, L"isTemplate", &is_tpl); ZeroMemory(&cfg, sizeof(cfg)); @@ -913,6 +1034,7 @@ static void on_webview2_message(const wchar_t *json) cfg.password = pass_buf; cfg.net_adapter = adapter_buf; cfg.is_template = is_tpl; + cfg.disk_directory = disk_buf; if (json_get_int(json, L"hddGb", &val)) cfg.hdd_gb = (DWORD)val; if (json_get_int(json, L"ramMb", &val)) cfg.ram_mb = (DWORD)val; @@ -995,7 +1117,7 @@ static void on_webview2_message(const wchar_t *json) } if (inst->admin_user[0]) _snwprintf_s(cmd, 1024, _TRUNCATE, - L"cmd.exe /k ssh %s-p %lu %s@localhost", + L"cmd.exe /v:off /k ssh %s-p %lu -l \"%s\" localhost", keyopt, inst->ssh_port, inst->admin_user); else _snwprintf_s(cmd, 1024, _TRUNCATE, @@ -1013,10 +1135,17 @@ static void on_webview2_message(const wchar_t *json) int idx; if (json_get_int(json, L"vmIndex", &idx) && idx >= 0 && idx < asb_vm_count()) { int j; + HRESULT hr; ui_log(L"Deleting VM \"%s\"...", asb_vm_name(asb_vm_get(idx))); safe_destroy_rdp(idx); safe_destroy_idd(idx); - asb_vm_delete(asb_vm_get(idx)); + hr = asb_vm_delete(asb_vm_get(idx)); + if (FAILED(hr)) { + ui_log(L"VM deletion failed (0x%08X). Check that its disk storage is available and writable.", hr); + ui_show_alert(L"VM deletion failed. Check that its disk storage is available and writable."); + send_vm_list(); + return; + } /* Compact display arrays */ for (j = idx; j < asb_vm_count(); j++) { g_displays[j] = g_displays[j + 1]; @@ -1032,7 +1161,11 @@ static void on_webview2_message(const wchar_t *json) wchar_t tpl_name[256] = { 0 }; json_get_string(json, L"name", tpl_name, 256); if (tpl_name[0] != L'\0') { - asb_template_delete(tpl_name); + HRESULT hr = asb_template_delete(tpl_name); + if (FAILED(hr)) { + ui_log(L"Template deletion failed (0x%08X). Check that its disk storage is available and writable.", hr); + ui_show_alert(L"Template deletion failed. Check that its disk storage is available and writable."); + } send_templates(); } } else if (wcscmp(action, L"editVm") == 0) { @@ -1053,13 +1186,47 @@ static void on_webview2_message(const wchar_t *json) } else if (wcscmp(action, L"selectVm") == 0) { int idx; if (json_get_int(json, L"vmIndex", &idx)) g_selected_vm = idx; + } else if (wcscmp(action, L"getDiskSpace") == 0) { + wchar_t *copy = _wcsdup(json); + HANDLE thread = copy ? CreateThread(NULL, 0, disk_space_thread, copy, 0, NULL) : NULL; + if (thread) CloseHandle(thread); + else { + free(copy); + send_disk_space(json, FALSE); + } + } else if (wcscmp(action, L"browseDiskDirectory") == 0) { + BROWSEINFOW browse; + PIDLIST_ABSOLUTE selection; + wchar_t initial[MAX_PATH] = {0}, path[MAX_PATH] = {0}; + json_get_string(json, L"path", initial, MAX_PATH); + if (!initial[0]) asb_default_disk_directory(initial, MAX_PATH); + ZeroMemory(&browse, sizeof(browse)); + browse.hwndOwner = g_hwnd_main; + browse.lpszTitle = L"Choose a folder for VM disks"; + browse.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE | BIF_EDITBOX; + browse.lpfn = disk_folder_browse_callback; + browse.lParam = (LPARAM)initial; + selection = SHBrowseForFolderW(&browse); + if (selection) { + if (SHGetPathFromIDListW(selection, path)) { + wchar_t json_buf[2048]; + JsonBuilder jb; + jb_init(&jb, json_buf, 2048); + jb_object_begin(&jb); + jb_string(&jb, L"type", L"diskDirectoryBrowseResult"); + jb_string(&jb, L"path", path); + jb_object_end(&jb); + webview2_post(json_buf); + } + CoTaskMemFree(selection); + } } else if (wcscmp(action, L"browseImage") == 0) { OPENFILENAMEW ofn; wchar_t file[MAX_PATH] = { 0 }; ZeroMemory(&ofn, sizeof(ofn)); ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = g_hwnd_main; - ofn.lpstrFilter = L"ISO Files (*.iso)\0*.iso\0VHDX Files (*.vhdx)\0*.vhdx\0All Files\0*.*\0"; + ofn.lpstrFilter = L"ISO Files (*.iso)\0*.iso\0All Files\0*.*\0"; ofn.lpstrFile = file; ofn.nMaxFile = MAX_PATH; ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; @@ -1460,6 +1627,16 @@ static LRESULT CALLBACK main_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) return 0; } + case WM_DISK_SPACE: + { + wchar_t *response = (wchar_t *)lp; + if (response) { + webview2_post(response); + HeapFree(GetProcessHeap(), 0, response); + } + return 0; + } + case WM_PREREQ_PROGRESS: { wchar_t buf[128]; diff --git a/src/app_win/webview2_bridge.c b/src/app_win/webview2_bridge.c index 90b6edc..87e7e00 100644 --- a/src/app_win/webview2_bridge.c +++ b/src/app_win/webview2_bridge.c @@ -533,20 +533,45 @@ void jb_bool(JsonBuilder *jb, const wchar_t *key, BOOL val) /* ---- Simple JSON parser ---- */ +/* Skip quoted values so they cannot be mistaken for property names. */ +static const wchar_t *json_find_value(const wchar_t *json, const wchar_t *key) +{ + const wchar_t *p = json; + size_t key_len; + if (!json || !key) return NULL; + key_len = wcslen(key); + while (*p) { + const wchar_t *start, *end; + if (*p != L'"') { p++; continue; } + start = ++p; + while (*p && *p != L'"') { + if (*p == L'\\' && p[1]) p += 2; + else p++; + } + if (!*p) return NULL; + end = p++; + while (*p == L' ' || *p == L'\t' || *p == L'\n' || *p == L'\r') p++; + if (*p != L':' || (size_t)(end - start) != key_len || + wcsncmp(start, key, key_len) != 0) continue; + p++; + while (*p == L' ' || *p == L'\t' || *p == L'\n' || *p == L'\r') p++; + return p; + } + return NULL; +} + +BOOL json_has_key(const wchar_t *json, const wchar_t *key) +{ + return json_find_value(json, key) != NULL; +} + BOOL json_get_string(const wchar_t *json, const wchar_t *key, wchar_t *out, size_t out_len) { - wchar_t pattern[300]; - const wchar_t *p; + const wchar_t *p = json_find_value(json, key); size_t o = 0; - swprintf_s(pattern, 300, L"\"%s\"", key); - p = wcsstr(json, pattern); - if (!p) return FALSE; - - p += wcslen(pattern); - while (*p == L' ' || *p == L':' || *p == L'\t' || *p == L'\n' || *p == L'\r') p++; - if (*p != L'"') return FALSE; + if (!p || !out || !out_len || *p != L'"') return FALSE; p++; /* Copy the value into `out`, DECODING JSON string escapes. \uXXXX -- what @@ -571,41 +596,38 @@ BOOL json_get_string(const wchar_t *json, const wchar_t *key, case L'u': { unsigned v = 0; int i; p++; - for (i = 0; i < 4 && *p; i++) { + for (i = 0; i < 4; i++) { wchar_t h = *p; if (h >= L'0' && h <= L'9') v = (v << 4) | (unsigned)(h - L'0'); else if (h >= L'a' && h <= L'f') v = (v << 4) | (unsigned)(h - L'a' + 10); else if (h >= L'A' && h <= L'F') v = (v << 4) | (unsigned)(h - L'A' + 10); - else break; + else { out[0] = 0; return FALSE; } p++; } ch = (wchar_t)v; break; } - default: ch = *p; p++; break; /* unknown escape: keep the char literally */ + default: out[0] = 0; return FALSE; } } else { ch = *p; p++; + if (ch < 0x20) { out[0] = 0; return FALSE; } } + /* Embedded NUL would silently turn a decoded path into its prefix. */ + if (ch == 0) { out[0] = 0; return FALSE; } if (o + 1 >= out_len) { out[o] = 0; return FALSE; } out[o++] = ch; } + if (*p != L'"') { out[0] = 0; return FALSE; } out[o] = 0; return TRUE; } BOOL json_get_int(const wchar_t *json, const wchar_t *key, int *out) { - wchar_t pattern[300]; - const wchar_t *p; - - swprintf_s(pattern, 300, L"\"%s\"", key); - p = wcsstr(json, pattern); - if (!p) return FALSE; - - p += wcslen(pattern); - while (*p == L' ' || *p == L':' || *p == L'\t' || *p == L'\n' || *p == L'\r') p++; + const wchar_t *p = json_find_value(json, key); + if (!p || !out) return FALSE; /* Handle quoted numbers from JS */ if (*p == L'"') p++; @@ -615,15 +637,8 @@ BOOL json_get_int(const wchar_t *json, const wchar_t *key, int *out) BOOL json_get_bool(const wchar_t *json, const wchar_t *key, BOOL *out) { - wchar_t pattern[300]; - const wchar_t *p; - - swprintf_s(pattern, 300, L"\"%s\"", key); - p = wcsstr(json, pattern); - if (!p) return FALSE; - - p += wcslen(pattern); - while (*p == L' ' || *p == L':' || *p == L'\t' || *p == L'\n' || *p == L'\r') p++; + const wchar_t *p = json_find_value(json, key); + if (!p || !out) return FALSE; *out = (*p == L't' || *p == L'T') ? TRUE : FALSE; return TRUE; } diff --git a/src/app_win/webview2_bridge.h b/src/app_win/webview2_bridge.h index 45c6dd2..2b3bda4 100644 --- a/src/app_win/webview2_bridge.h +++ b/src/app_win/webview2_bridge.h @@ -51,6 +51,8 @@ void jb_append_escaped(JsonBuilder *jb, const wchar_t *s); /* ---- Simple JSON parser (for messages from JS) ---- */ +BOOL json_has_key(const wchar_t *json, const wchar_t *key); + /* Extract a string value for a given key from a JSON string. Returns TRUE if found. out is null-terminated. */ BOOL json_get_string(const wchar_t *json, const wchar_t *key, diff --git a/src/backend_mac/asb_core_mac.h b/src/backend_mac/asb_core_mac.h index 37fce67..b6cb508 100644 --- a/src/backend_mac/asb_core_mac.h +++ b/src/backend_mac/asb_core_mac.h @@ -18,8 +18,9 @@ typedef struct { char name[256]; char os_type[32]; + char disk_directory[1024]; /* Optional disk parent; metadata stays in Application Support. */ char admin_user[64]; - char admin_pass[128]; + char admin_pass[512]; /* UTF-8 storage for up to 127 Windows UTF-16 units. */ int ram_mb; int hdd_gb; int cpu_cores; @@ -64,10 +65,15 @@ int asb_mac_vm_count(void); AsbVmMac *asb_mac_vm_get(int index); AsbVmMac *asb_mac_vm_find(const char *name); +NSString *asb_mac_validate_username(NSString *os_type, id username, NSString *vm_name); + +NSString *asb_mac_validate_password(NSString *os_type, id password); + int asb_mac_vm_create(const char *name, const char *os_type, int ram_mb, int hdd_gb, int cpu_cores, int gpu_mode, int network_mode, const char *image_path, + const char *disk_directory, const char *admin_user, const char *admin_pass, BOOL ssh_enabled, diff --git a/src/backend_mac/asb_core_mac.m b/src/backend_mac/asb_core_mac.m index eef12a9..c27e221 100644 --- a/src/backend_mac/asb_core_mac.m +++ b/src/backend_mac/asb_core_mac.m @@ -188,6 +188,8 @@ static void save_vm_list(void) { fprintf(f, "[VM]\n"); fprintf(f, "Name=%s\n", g_vms[i].name); fprintf(f, "OsType=%s\n", g_vms[i].os_type); + if (g_vms[i].disk_directory[0]) + fprintf(f, "DiskDirectory=%s\n", g_vms[i].disk_directory); fprintf(f, "RamMB=%d\n", g_vms[i].ram_mb); fprintf(f, "HddGB=%d\n", g_vms[i].hdd_gb); fprintf(f, "CpuCores=%d\n", g_vms[i].cpu_cores); @@ -222,7 +224,7 @@ static void load_vm_list(void) { FILE *f = fopen(url.fileSystemRepresentation, "r"); if (!f) return; - char line[1024]; + char line[2048]; AsbVmMac *vm = NULL; BOOL in_settings = NO; @@ -259,6 +261,8 @@ static void load_vm_list(void) { strlcpy(vm->name, line + 5, sizeof(vm->name)); else if (strncmp(line, "OsType=", 7) == 0) strlcpy(vm->os_type, line + 7, sizeof(vm->os_type)); + else if (strncmp(line, "DiskDirectory=", 14) == 0) + strlcpy(vm->disk_directory, line + 14, sizeof(vm->disk_directory)); else if (strncmp(line, "RamMB=", 6) == 0) vm->ram_mb = atoi(line + 6); else if (strncmp(line, "HddGB=", 6) == 0) @@ -1013,16 +1017,149 @@ static void start_install_flow(int idx, NSURL *restoreURL) { /* ---- Public: lifecycle ---- */ +/* Match ECMAScript String.trim exactly; Foundation's built-in whitespace set + includes NEL and excludes BOM, which would give the API different names. */ +static BOOL username_trim_character(unichar ch) { + return (ch >= 0x0009 && ch <= 0x000D) || ch == 0x0020 || ch == 0x00A0 || + ch == 0x1680 || (ch >= 0x2000 && ch <= 0x200A) || ch == 0x2028 || + ch == 0x2029 || ch == 0x202F || ch == 0x205F || ch == 0x3000 || ch == 0xFEFF; +} + +static NSString *normalized_username(NSString *value) { + NSUInteger start = 0, end = value.length; + while (start < end && username_trim_character([value characterAtIndex:start])) start++; + while (end > start && username_trim_character([value characterAtIndex:end - 1])) end--; + return [value substringWithRange:NSMakeRange(start, end - start)]; +} + +NSString *asb_mac_validate_username(NSString *os_type, id username, NSString *vm_name) { + if (!username) return @"Username is required."; + if (![username isKindOfClass:[NSString class]]) + return @"Username must be a string."; + NSString *value = normalized_username(username); + if (!value.length) return @"Username is required."; + for (NSUInteger i = 0; i < value.length; i++) { + unichar ch = [value characterAtIndex:i]; + if (ch == 0) return @"Username cannot contain NUL characters."; + if (ch >= 0xD800 && ch <= 0xDBFF) { + if (++i >= value.length) + return @"Username contains invalid Unicode."; + ch = [value characterAtIndex:i]; + if (ch < 0xDC00 || ch > 0xDFFF) + return @"Username contains invalid Unicode."; + } else if (ch >= 0xDC00 && ch <= 0xDFFF) { + return @"Username contains invalid Unicode."; + } + } + + BOOL isWindows = os_type && [os_type caseInsensitiveCompare:@"Windows"] == NSOrderedSame; + if (isWindows) { + if (value.length > 20) return @"Username cannot exceed 20 characters."; + BOOL onlyDotsWhitespace = YES; + for (NSUInteger i = 0; i < value.length; i++) { + unichar ch = [value characterAtIndex:i]; + if (ch < 32 || ch == 0xFFFE || ch == 0xFFFF || + [@"\"\\/[]:;|=,+*?<>%@" rangeOfString: + [NSString stringWithCharacters:&ch length:1]].location != NSNotFound) + return @"Username contains invalid characters."; + if (ch != '.' && !username_trim_character(ch)) onlyDotsWhitespace = NO; + } + if (onlyDotsWhitespace) return @"Username cannot be only dots or spaces."; + if ([value hasSuffix:@"."]) return @"Username cannot end with a period."; + NSArray *reserved = @[@"NONE",@"CON",@"PRN",@"AUX",@"NUL", + @"COM1",@"COM2",@"COM3",@"COM4",@"COM5",@"COM6",@"COM7",@"COM8",@"COM9", + @"LPT1",@"LPT2",@"LPT3",@"LPT4",@"LPT5",@"LPT6",@"LPT7",@"LPT8",@"LPT9"]; + if ([reserved containsObject:value.uppercaseString]) + return @"Username is a reserved name."; + if (vm_name && [vm_name caseInsensitiveCompare:value] == NSOrderedSame) + return @"Username cannot match the VM name (Windows computer name)."; + } else { + if ([value lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 63) + return @"Username is too long (max 63 UTF-8 bytes in AppSandbox)."; + for (NSUInteger i = 0; i < value.length; i++) { + unichar ch = [value characterAtIndex:i]; + if (username_trim_character(ch)) + return @"Username cannot contain spaces (macOS short account name)."; + } + for (NSUInteger i = 0; i < value.length; i++) { + unichar ch = [value characterAtIndex:i]; + if (ch < 32 || ch == 127 || ch == 0xFFFE || ch == 0xFFFF || + ch == '/' || ch == '\\' || ch == ':') + return @"Username contains invalid characters."; + } + if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) + return @"Username cannot be . or .. (macOS short account name)."; + if ([@[@"root",@"daemon",@"nobody",@"guest",@"shared"] containsObject:value.lowercaseString]) + return @"Username is a reserved name."; + } + return nil; +} + +NSString *asb_mac_validate_password(NSString *os_type, id password) { + if (!password) return @"Password is required."; + if (![password isKindOfClass:[NSString class]]) + return @"Password must be a string."; + NSString *value = password; + if (!value.length) return @"Password is required."; + + /* Validate before crossing the NUL-terminated UTF-8 API boundary. */ + NSUInteger codePoints = 0; + for (NSUInteger i = 0; i < value.length; i++) { + unichar ch = [value characterAtIndex:i]; + if (ch == 0) return @"Password cannot contain NUL characters."; + if (ch >= 0xD800 && ch <= 0xDBFF) { + if (++i >= value.length) + return @"Password contains invalid Unicode."; + ch = [value characterAtIndex:i]; + if (ch < 0xDC00 || ch > 0xDFFF) + return @"Password contains invalid Unicode."; + } else if (ch >= 0xDC00 && ch <= 0xDFFF) { + return @"Password contains invalid Unicode."; + } + codePoints++; + } + + if (os_type && [os_type caseInsensitiveCompare:@"Windows"] == NSOrderedSame) { + if (value.length > 127) + return @"Password is too long (max 127 characters for Windows)."; + } else { + if (codePoints < 4) + return @"Password must be at least 4 characters (macOS minimum)."; + if ([value lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > 127) + return @"Password is too long (max 127 UTF-8 bytes in AppSandbox)."; + } + return nil; +} + int asb_mac_vm_create(const char *name, const char *os_type, int ram_mb, int hdd_gb, int cpu_cores, int gpu_mode, int network_mode, const char *image_path, + const char *disk_directory, const char *admin_user, const char *admin_pass, BOOL ssh_enabled, BOOL ssh_deploy_key, BOOL test_mode) { if (!name || !os_type) return BACKEND_ERR_INVALID_ARG; + NSString *username = admin_user ? [NSString stringWithUTF8String:admin_user] : nil; + NSString *usernameError = (admin_user && !username) + ? @"Username contains invalid Unicode." + : asb_mac_validate_username([NSString stringWithUTF8String:os_type], username, + [NSString stringWithUTF8String:name]); + if (usernameError) { + post_alert(name, "%s", usernameError.UTF8String); + return BACKEND_ERR_INVALID_ARG; + } + username = normalized_username(username); + NSString *password = admin_pass ? [NSString stringWithUTF8String:admin_pass] : nil; + NSString *passwordError = (admin_pass && !password) + ? @"Password contains invalid Unicode." + : asb_mac_validate_password([NSString stringWithUTF8String:os_type], password); + if (passwordError) { + post_alert(name, "%s", passwordError.UTF8String); + return BACKEND_ERR_INVALID_ARG; + } if (vm_index_of(name) >= 0) { post_alert(name, "A VM named '%s' already exists", name); return BACKEND_ERR_INVALID_ARG; @@ -1032,11 +1169,25 @@ int asb_mac_vm_create(const char *name, const char *os_type, return BACKEND_ERR_FAILED; } + NSString *diskDirectory = disk_directory ? @(disk_directory) : @""; + NSString *diskError = [VmDir validationErrorForDiskDirectory:diskDirectory vmName:@(name)]; + if (diskError) { + post_alert(name, "%s", diskError.UTF8String); + return BACKEND_ERR_INVALID_ARG; + } + diskDirectory = [VmDir normalizedDiskDirectory:diskDirectory]; + NSString *imagePath = (image_path && image_path[0]) + ? [NSString stringWithUTF8String:image_path] : nil; + /* Prompt for admin up front so the user isn't blocked 20 minutes into * the install. Token is cached for the process lifetime; subsequent * VM creations reuse it silently. Windows-on-Mac has no privileged step * (build-windows + QEMU/HVF run unprivileged), so skip the prompt. */ BOOL isWindows = (os_type && strcasecmp(os_type, "Windows") == 0); + if (isWindows && imagePath.length == 0) { + post_alert(name, "A Windows ISO must be selected to create a Windows VM"); + return BACKEND_ERR_INVALID_ARG; + } if (!isWindows) { NSError *authErr = nil; if (![IsoPatchMac preauthorize:&authErr]) { @@ -1051,18 +1202,15 @@ int asb_mac_vm_create(const char *name, const char *os_type, memset(vm, 0, sizeof(*vm)); strlcpy(vm->name, name, sizeof(vm->name)); strlcpy(vm->os_type, os_type, sizeof(vm->os_type)); + strlcpy(vm->disk_directory, diskDirectory.UTF8String, sizeof(vm->disk_directory)); vm->ram_mb = ram_mb > 0 ? ram_mb : 8192; vm->hdd_gb = hdd_gb > 0 ? hdd_gb : 64; vm->cpu_cores = cpu_cores > 0 ? cpu_cores : 4; vm->gpu_mode = gpu_mode; vm->network_mode = network_mode; vm->test_mode = test_mode; /* honored at start (Windows guest); not forced */ - strlcpy(vm->admin_user, - (admin_user && admin_user[0]) ? admin_user : "user", - sizeof(vm->admin_user)); - strlcpy(vm->admin_pass, - (admin_pass && admin_pass[0]) ? admin_pass : "test123", - sizeof(vm->admin_pass)); + strlcpy(vm->admin_user, username.UTF8String, sizeof(vm->admin_user)); + strlcpy(vm->admin_pass, admin_pass, sizeof(vm->admin_pass)); vm->ssh_enabled = ssh_enabled; /* Key deploy needs SSH; prepare the AppSandbox keypair now so the instance carries the public key (the guest agent deploys it at runtime once the @@ -1096,21 +1244,10 @@ carries the public key (the guest agent deploys it at runtime once the return BACKEND_ERR_FAILED; } - NSString *imagePath = (image_path && image_path[0]) - ? [NSString stringWithUTF8String:image_path] : nil; - /* ---- Windows guest: from-scratch create from a Microsoft ISO. The user picks a .iso; we apply install.wim with our own NTFS writer + stage the agent/drivers, then boot via QEMU (always testMode). No IPSW, no DISM. ---- */ if (vm->os_type[0] && strcasecmp(vm->os_type, "Windows") == 0) { - if (imagePath.length == 0) { - post_alert(name, "A Windows ISO must be selected to create a Windows VM"); - g_vm_count--; - memset(&g_vms[idx], 0, sizeof(g_vms[idx])); - save_vm_list(); - post_list_changed(); - return BACKEND_ERR_INVALID_ARG; - } run_on_main(^{ int i = vm_index_of(nsName.UTF8String); if (i >= 0) start_windows_build_flow(i, [NSURL fileURLWithPath:imagePath]); @@ -1556,8 +1693,18 @@ int asb_mac_vm_edit(const char *name, const char *field, const char *value) { NSString *newName = [NSString stringWithUTF8String:value]; NSURL *oldDir = [VmDir directoryForVm:oldName]; NSURL *newDir = [VmDir directoryForVm:newName]; + NSURL *oldDiskDir = [VmDir diskDirectoryForVm:oldName]; + NSURL *newDiskDir = [[oldDiskDir URLByDeletingLastPathComponent] + URLByAppendingPathComponent:newName isDirectory:YES]; + BOOL external = ![oldDiskDir isEqual:oldDir]; NSError *err = nil; + if (external && ![[NSFileManager defaultManager] moveItemAtURL:oldDiskDir toURL:newDiskDir error:&err]) { + post_alert(name, "Rename disk folder failed: %s", err.localizedDescription.UTF8String); + return BACKEND_ERR_FAILED; + } if (![[NSFileManager defaultManager] moveItemAtURL:oldDir toURL:newDir error:&err]) { + if (external) + [[NSFileManager defaultManager] moveItemAtURL:newDiskDir toURL:oldDiskDir error:nil]; post_alert(name, "Rename failed: %s", err.localizedDescription.UTF8String); return BACKEND_ERR_FAILED; } diff --git a/src/backend_mac/host_info.h b/src/backend_mac/host_info.h index c8fbac2..6782354 100644 --- a/src/backend_mac/host_info.h +++ b/src/backend_mac/host_info.h @@ -19,6 +19,9 @@ /* Free bytes on the volume that backs the VMs root directory, as GB. */ + (int)freeGb; +/* Empty selects the default VM root; invalid or unavailable paths return -1. */ ++ (int)freeGbForDirectory:(NSString *)directory; + /* Host GPU name (e.g. "Apple M3 Pro"). VM guests implicitly share this GPU. */ + (NSString *)hostGpuName; diff --git a/src/backend_mac/host_info.m b/src/backend_mac/host_info.m index 9a671d5..6968f1c 100644 --- a/src/backend_mac/host_info.m +++ b/src/backend_mac/host_info.m @@ -3,6 +3,7 @@ #import #include +#include @implementation HostInfo @@ -21,17 +22,28 @@ + (int)hostRamMb { } + (int)freeGb { - NSURL *root = [VmDir vmsRootDirectory]; - if (!root) return 0; + return MAX(0, [self freeGbForDirectory:@""]); +} + ++ (int)freeGbForDirectory:(NSString *)directory { + NSString *path = directory.length ? directory : [VmDir vmsRootDirectory].path; + if (![path hasPrefix:@"/"] || + [path rangeOfCharacterFromSet:[NSCharacterSet controlCharacterSet]].location != NSNotFound) + return -1; + + NSFileManager *fm = [NSFileManager defaultManager]; + BOOL isDirectory = NO; + if (![fm fileExistsAtPath:path isDirectory:&isDirectory] || !isDirectory) + return -1; NSError *err = nil; - NSDictionary *attrs = [[NSFileManager defaultManager] - attributesOfFileSystemForPath:[root path] error:&err]; - if (!attrs) return 0; + NSDictionary *attrs = [fm attributesOfFileSystemForPath:path error:&err]; + if (!attrs || err) return -1; NSNumber *freeBytes = attrs[NSFileSystemFreeSize]; - if (!freeBytes) return 0; - return (int)([freeBytes unsignedLongLongValue] / (1024ULL * 1024ULL * 1024ULL)); + if (!freeBytes) return -1; + unsigned long long freeGb = [freeBytes unsignedLongLongValue] / (1024ULL * 1024ULL * 1024ULL); + return freeGb > INT_MAX ? INT_MAX : (int)freeGb; } + (NSString *)hostGpuName { diff --git a/src/backend_mac/idd_display.m b/src/backend_mac/idd_display.m index 0dc90d9..085d801 100644 --- a/src/backend_mac/idd_display.m +++ b/src/backend_mac/idd_display.m @@ -18,6 +18,7 @@ #import "idd_display.h" #import "asb_ivshmem_transport.h" +#import "vm_dir.h" #import #import #import @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #include "../../tools/transport/asb_transport.h" /* ASB_CH_DISPLAY/INPUT/AUDIO/CLIPBOARD[_READER], AsbCursor, ASB_CURSOR_MAGIC */ @@ -160,6 +162,21 @@ static int is_extended(unsigned short kc) return 0; } +/* Private WindowServer hotkey controls; resolve at runtime so an unavailable API + disables capture without preventing the viewer from opening. */ +static int32_t (*idd_cgs_connection)(void); +static CGError (*idd_cgs_get_hotkeys)(int32_t, int *); +static CGError (*idd_cgs_set_hotkeys)(int32_t, int); +static void idd_load_hotkey_api(void) +{ + static dispatch_once_t once; + dispatch_once(&once, ^{ + idd_cgs_connection = dlsym(RTLD_DEFAULT, "_CGSDefaultConnection"); + idd_cgs_get_hotkeys = dlsym(RTLD_DEFAULT, "CGSGetGlobalHotKeyOperatingMode"); + idd_cgs_set_hotkeys = dlsym(RTLD_DEFAULT, "CGSSetGlobalHotKeyOperatingMode"); + }); +} + /* Resolve the GUI console user (the one who will paste) so root-created clipboard files can be chowned to them. The daemon runs as root (for vmnet), where NSTemporaryDirectory() is root's PRIVATE temp, unreadable by the logged-in user — see -clipRecvFiles. Prefer /dev/console's owner (the logged-in GUI @@ -240,6 +257,7 @@ static BOOL idd_console_user(uid_t *uid, gid_t *gid) * Input (coalesced moves + discrete events) is written to the ch3 fd by the controller. * ================================================================================ */ @class IddDisplayWindow; +static __weak IddDisplayWindow *g_hotkeyOwner; @interface IddDisplayView : NSView @property (nonatomic, weak) IddDisplayWindow *owner; @@ -283,6 +301,10 @@ - (NSCursor *)appliedCursor; - (void)sendInput:(uint32_t)type p1:(uint32_t)p1 p2:(uint32_t)p2 p3:(uint32_t)p3; - (void)recordMoveX:(uint32_t)x y:(uint32_t)y; - (void)flushMove; +- (void)forwardKeyEvent:(NSEvent *)event; +- (void)releaseHeldKeys; +- (void)releaseKeyboardCapture; +- (void)updateKeyboardCapture; @end @implementation IddDisplayWindow { @@ -329,9 +351,16 @@ main thread by the render timer (rebuilt on shape/visibility/scale change). */ int _hasMove; uint32_t _moveX, _moveY; - /* ch3 INPUT fd (the input WORKER thread connects + writes InputPacket). -1 until connected; the - worker reconnects it. The main thread NEVER touches this fd — it only enqueues into _inq. */ - volatile int _inputFd; + id _eventMonitor; + BOOL _transmitHotkeys; + BOOL _hotkeysCaptured; + int32_t _hotkeyConnection; + int _savedHotkeyMode; + NSUInteger _trackingMenus; + BOOL _heldKeys[256]; + uint8_t _heldExtended[256]; + + /* Published reader fds shared with teardown. */ pthread_mutex_t _inputLock; /* Input send queue: the AppKit main thread (-sendInput:) enqueues InputPackets here and returns @@ -357,6 +386,7 @@ main thread by the render timer (rebuilt on shape/visibility/scale change). */ uint8_t *_pcm; /* PCM_RING_SZ jitter buffer */ volatile uint32_t _pcmHead, _pcmTail; pthread_mutex_t _pcmLock; + BOOL _audioMuted; /* ch5/ch6 CLIPBOARD published fds (guarded by _inputLock, the shared fd-publication lock). The NSPasteboard changeCount we set ourselves, so the writer doesn't echo a Windows->Mac paste @@ -396,7 +426,7 @@ - (instancetype)initWithName:(NSString *)name _name = [name copy]; _transport = transport; - _inputFd = -1; + _stop = 1; _displayFd = -1; _audioFd = -1; _clipWriterFd = -1; @@ -415,10 +445,194 @@ - (instancetype)initWithName:(NSString *)name _view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; window.contentView = _view; window.delegate = self; + [self loadDisplaySettings]; [self setupMetal]; return self; } +#pragma mark - Display settings and keyboard capture + +- (NSURL *)displaySettingsURL { + return [[VmDir directoryForVm:self.name] URLByAppendingPathComponent:@"display_settings.json"]; +} + +- (void)saveDisplaySettings { + NSData *data = [NSJSONSerialization dataWithJSONObject:@{ @"transmitKeyboardHotkeys": @(_transmitHotkeys ? 1 : 0) } + options:0 error:nil]; + NSError *error = nil; + if (![data writeToURL:[self displaySettingsURL] options:NSDataWritingAtomic error:&error]) + NSLog(@"IDD [%@]: Could not save display settings: %@", self.name, error); +} + +- (void)loadDisplaySettings { + NSData *data = [NSData dataWithContentsOfURL:[self displaySettingsURL]]; + if (!data) { [self saveDisplaySettings]; return; } + id settings = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + if ([settings isKindOfClass:[NSDictionary class]]) { + id value = settings[@"transmitKeyboardHotkeys"]; + if ([value isKindOfClass:[NSNumber class]]) _transmitHotkeys = [value boolValue]; + } +} + +- (void)toggleAudioMute:(id)sender { + (void)sender; + pthread_mutex_lock(&_pcmLock); + _audioMuted = !_audioMuted; + _pcmHead = _pcmTail = 0; + pthread_mutex_unlock(&_pcmLock); + self.window.title = [NSString stringWithFormat:@"%@%@ — Display", _audioMuted ? @"🔇 " : @"", self.name]; +} + +- (void)toggleTransmitHotkeys:(id)sender { + (void)sender; + _transmitHotkeys = !_transmitHotkeys; + [self updateKeyboardCapture]; + [self saveDisplaySettings]; +} + +- (void)showTitlebarMenuAtPoint:(NSPoint)point { + NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; + NSMenuItem *mute = [menu addItemWithTitle:@"Mute audio" action:@selector(toggleAudioMute:) keyEquivalent:@""]; + mute.target = self; + mute.state = _audioMuted ? NSControlStateValueOn : NSControlStateValueOff; + NSMenuItem *hotkeys = [menu addItemWithTitle:@"Transmit Keyboard Hotkeys" + action:@selector(toggleTransmitHotkeys:) keyEquivalent:@""]; + hotkeys.target = self; + hotkeys.state = _transmitHotkeys ? NSControlStateValueOn : NSControlStateValueOff; + _trackingMenus++; + [self releaseKeyboardCapture]; + [menu popUpMenuPositioningItem:nil atLocation:point inView:nil]; + if (_trackingMenus) _trackingMenus--; + [self updateKeyboardCapture]; +} + +- (NSEvent *)handleViewerEvent:(NSEvent *)event { + if (_stop) return event; + BOOL contextClick = event.type == NSEventTypeRightMouseDown || + (event.type == NSEventTypeLeftMouseDown && (event.modifierFlags & NSEventModifierFlagControl)); + if (contextClick && !_trackingMenus) { + NSPoint screenPoint = event.window ? [event.window convertPointToScreen:event.locationInWindow] : [NSEvent mouseLocation]; + BOOL targetsWindow = event.window == self.window; + if (!event.window) + targetsWindow = [NSWindow windowNumberAtPoint:screenPoint belowWindowWithWindowNumber:0] == self.window.windowNumber; + NSPoint point = [self.window convertPointFromScreen:screenPoint]; + if (targetsWindow && !(self.window.styleMask & NSWindowStyleMaskFullScreen) && + point.x >= 0 && point.x < self.window.frame.size.width && + point.y >= NSMaxY(self.window.contentLayoutRect) && point.y < self.window.frame.size.height) { + [NSApp activateIgnoringOtherApps:YES]; + [self.window makeKeyAndOrderFront:nil]; + [self.window makeFirstResponder:self.view]; + [self showTitlebarMenuAtPoint:screenPoint]; + return nil; + } + } + if (_hotkeysCaptured && !_trackingMenus && NSApp.isActive && self.window.isKeyWindow && + event.window == self.window && self.window.firstResponder == self.view && !self.window.attachedSheet && + (event.type == NSEventTypeKeyDown || event.type == NSEventTypeKeyUp || event.type == NSEventTypeFlagsChanged)) { + [self forwardKeyEvent:event]; + return nil; + } + return event; +} + +- (void)installEventMonitor { + if (_eventMonitor) return; + __weak IddDisplayWindow *weakSelf = self; + _eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask: + NSEventMaskKeyDown | NSEventMaskKeyUp | NSEventMaskFlagsChanged | NSEventMaskRightMouseDown | NSEventMaskLeftMouseDown + handler:^NSEvent *(NSEvent *event) { + IddDisplayWindow *owner = weakSelf; + return owner ? [owner handleViewerEvent:event] : event; + }]; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + [nc addObserver:self selector:@selector(applicationDidBecomeActive:) name:NSApplicationDidBecomeActiveNotification object:NSApp]; + [nc addObserver:self selector:@selector(applicationDidResignActive:) name:NSApplicationDidResignActiveNotification object:NSApp]; + [nc addObserver:self selector:@selector(menuDidBeginTracking:) name:NSMenuDidBeginTrackingNotification object:nil]; + [nc addObserver:self selector:@selector(menuDidEndTracking:) name:NSMenuDidEndTrackingNotification object:nil]; +} + +- (void)releaseKeyboardCapture { + [self releaseHeldKeys]; + if (!_hotkeysCaptured) return; + if (g_hotkeyOwner == self) { + CGError error = idd_cgs_set_hotkeys(_hotkeyConnection, _savedHotkeyMode); + if (error != kCGErrorSuccess) + NSLog(@"IDD [%@]: Could not restore host keyboard shortcuts (%d).", self.name, error); + g_hotkeyOwner = nil; + } + _hotkeysCaptured = NO; +} + +- (void)updateKeyboardCapture { + BOOL capture = _transmitHotkeys && !_stop && !_trackingMenus && _eventMonitor && NSApp.isActive && + self.window.isKeyWindow && self.window.firstResponder == self.view && !self.window.attachedSheet; + if (!capture) { [self releaseKeyboardCapture]; return; } + if (_hotkeysCaptured) return; + [g_hotkeyOwner releaseKeyboardCapture]; + idd_load_hotkey_api(); + CGError error = kCGErrorFailure; + if (idd_cgs_connection && idd_cgs_get_hotkeys && idd_cgs_set_hotkeys) { + _hotkeyConnection = idd_cgs_connection(); + error = idd_cgs_get_hotkeys(_hotkeyConnection, &_savedHotkeyMode); + if (error == kCGErrorSuccess) { + const int disabled = 1; + error = idd_cgs_set_hotkeys(_hotkeyConnection, disabled); + int mode = -1; + if (error == kCGErrorSuccess) error = idd_cgs_get_hotkeys(_hotkeyConnection, &mode); + if (error == kCGErrorSuccess && mode != disabled) error = kCGErrorFailure; + if (error != kCGErrorSuccess) idd_cgs_set_hotkeys(_hotkeyConnection, _savedHotkeyMode); + } + } + if (error == kCGErrorSuccess) { + g_hotkeyOwner = self; + _hotkeysCaptured = YES; + } else { + _transmitHotkeys = NO; + [self saveDisplaySettings]; + NSLog(@"IDD [%@]: Keyboard capture failed (%d).", self.name, error); + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = @"Keyboard capture could not be enabled"; + alert.informativeText = @"macOS did not allow the viewer to capture system keyboard shortcuts. Transmit Keyboard Hotkeys has been turned off."; + [alert beginSheetModalForWindow:self.window completionHandler:nil]; + } +} + +- (void)forwardKeyEvent:(NSEvent *)event { + [self flushMove]; + unsigned short kc = event.keyCode; + uint32_t vk = 0; + BOOL up = event.type == NSEventTypeKeyUp; + BOOL extended = is_extended(kc); + if (event.type == NSEventTypeFlagsChanged) { + NSEventModifierFlags mask = 0; + extended = NO; + switch (kc) { + case 0x38: case 0x3C: vk = 0x10; mask = NSEventModifierFlagShift; break; + case 0x3B: case 0x3E: vk = 0x11; mask = NSEventModifierFlagControl; break; + case 0x3A: case 0x3D: vk = 0x12; mask = NSEventModifierFlagOption; break; + case 0x37: vk = 0x5B; mask = NSEventModifierFlagCommand; break; + case 0x36: vk = 0x5C; mask = NSEventModifierFlagCommand; break; + case 0x39: vk = 0x14; mask = NSEventModifierFlagCapsLock; break; + default: return; + } + up = (event.modifierFlags & mask) == 0; + } else { + vk = kc < 128 ? g_vk[kc] : 0; + } + if (!vk) return; + [self sendInput:INPUT_KEY p1:vk p2:0 p3:((extended ? 1 : 0) | (up ? 2 : 0))]; + _heldKeys[vk] = !up; + _heldExtended[vk] = extended; +} + +- (void)releaseHeldKeys { + for (uint32_t vk = 0; vk < 256; vk++) { + if (!_heldKeys[vk]) continue; + [self sendInput:INPUT_KEY p1:vk p2:0 p3:(2 | _heldExtended[vk])]; + _heldKeys[vk] = NO; + } +} + /* Build the Metal device/pipeline/sampler and point the view's CAMetalLayer at them. The shaders are the direct MSL port of g_vs_hlsl/g_ps_hlsl in vm_display_idd.c: a fullscreen triangle (no vertex buffer) whose UVs sample the frame texture; the letterbox is done by the viewport+scissor at draw time, the @@ -505,6 +719,7 @@ Harmless in the GUI (already the active app). Mirrors VzDisplayWindow.showDispla with dead display+input. Reconnecting re-arms ch2/ch3 and the guest re-accepts. */ _stop = 0; self.userClosed = NO; + [self installEventMonitor]; if (!_threadsStarted) { _threadsStarted = YES; self.timer = [NSTimer timerWithTimeInterval:(1.0 / 60.0) repeats:YES block:^(NSTimer *t) { @@ -523,6 +738,7 @@ Harmless in the GUI (already the active app). Mirrors VzDisplayWindow.showDispla pthread_create(&_clipWriterThread, NULL, idd_clip_writer_thread, (__bridge void *)self); pthread_create(&_clipReaderThread, NULL, idd_clip_reader_thread, (__bridge void *)self); } + [self updateKeyboardCapture]; } /* Render-timer body (main thread): flush a coalesced move, mirror the guest HW cursor onto the @@ -800,12 +1016,8 @@ _fbLock is taken only for the memcpy into _fb (as in the dirty-rect path below). free(scratch); } -/* Own the ch3 INPUT connection end-to-end on this worker thread: connect, publish the fd (so teardown - can shutdown() it), then loop draining the main thread's _inq and BLOCKING-sending each InputPacket, - plus detecting guest-side teardown. Blocking I/O is safe HERE (off the UI thread): ch3 backpressure - or a dead slot stalls only this thread, and we recover by reconnecting. The main thread only ever - touches _inq, so it can never wedge. Mirrors the proven Windows model (recv thread owns the input - socket + its reconnect) — just with the send moved off the UI thread via the queue. */ +/* Own ch3 on the worker: drain the main thread's input queue, reconnect on failure, + and flush queued key releases before closing. Socket writes are nonblocking. */ - (void)inputLoop { while (!_stop) { int fd = [_transport connectChannel:ASB_CH_INPUT timeoutMs:2000]; @@ -818,10 +1030,6 @@ - (void)inputLoop { without O_NONBLOCK the worker can still briefly block in __sendto on a full ring. With it, send returns EAGAIN instantly (-> drop) and only a real error/EOF triggers reconnect. */ fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); - pthread_mutex_lock(&_inputLock); - _inputFd = fd; - pthread_mutex_unlock(&_inputLock); - BOOL dead = NO; while (!_stop && !dead) { /* 1) Wait briefly for queued input, then drain it into a local batch. The cond wait wakes @@ -852,7 +1060,7 @@ fd for EOF (a guest input-helper respawn while input is idle). */ acceptor stops draining (force-killed helper) — the ivshmem analog of an hvsocket RST — so this send then fails (EPIPE) exactly like Windows. A guest that is merely slow keeps draining, so EAGAIN-drop just sheds a few stale input events without dropping the link. */ - for (int i = 0; i < nbatch && !_stop; i++) { + for (int i = 0; i < nbatch; i++) { ssize_t wn = send(fd, &batch[i], sizeof(InputPacket), MSG_DONTWAIT); if (wn == (ssize_t)sizeof(InputPacket)) continue; /* delivered */ if (wn < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) continue; /* full -> drop */ @@ -876,9 +1084,16 @@ this send then fails (EPIPE) exactly like Windows. A guest that is merely slow k } } - pthread_mutex_lock(&_inputLock); - if (_inputFd == fd) _inputFd = -1; - pthread_mutex_unlock(&_inputLock); + /* Teardown leaves ch3 open until the worker has sent the queued key releases. */ + if (_stop && !dead) { + pthread_mutex_lock(&_inqLock); + while (_inqHead != _inqTail) { + InputPacket pkt = _inq[_inqHead]; + _inqHead = (_inqHead + 1) % IDD_INQ_CAP; + if (send(fd, &pkt, sizeof(pkt), MSG_DONTWAIT) != (ssize_t)sizeof(pkt)) break; + } + pthread_mutex_unlock(&_inqLock); + } close(fd); if (!_stop) usleep(100000); } @@ -890,6 +1105,7 @@ this send then fails (EPIPE) exactly like Windows. A guest that is merely slow k Full => drop oldest so latency stays bounded. */ - (void)pcmPush:(const uint8_t *)p len:(uint32_t)n { pthread_mutex_lock(&_pcmLock); + if (_audioMuted) { pthread_mutex_unlock(&_pcmLock); return; } for (uint32_t i = 0; i < n; i++) { uint32_t next = (_pcmTail + 1) % PCM_RING_SZ; if (next == _pcmHead) _pcmHead = (_pcmHead + 1) % PCM_RING_SZ; /* full -> drop oldest */ @@ -900,6 +1116,7 @@ - (void)pcmPush:(const uint8_t *)p len:(uint32_t)n { } - (uint32_t)pcmPull:(uint8_t *)out len:(uint32_t)n { pthread_mutex_lock(&_pcmLock); + if (_audioMuted) { pthread_mutex_unlock(&_pcmLock); return 0; } uint32_t got = 0; while (got < n && _pcmHead != _pcmTail) { out[got++] = _pcm[_pcmHead]; _pcmHead = (_pcmHead + 1) % PCM_RING_SZ; } pthread_mutex_unlock(&_pcmLock); @@ -1275,6 +1492,38 @@ lock it to that user (0700) so other local users can't read clipboard contents f #pragma mark - NSWindowDelegate +- (void)windowDidBecomeKey:(NSNotification *)notification { + (void)notification; + [self updateKeyboardCapture]; +} + +- (void)windowDidResignKey:(NSNotification *)notification { + (void)notification; + [self releaseKeyboardCapture]; +} + +- (void)applicationDidBecomeActive:(NSNotification *)notification { + (void)notification; + [self updateKeyboardCapture]; +} + +- (void)applicationDidResignActive:(NSNotification *)notification { + (void)notification; + [self releaseKeyboardCapture]; +} + +- (void)menuDidBeginTracking:(NSNotification *)notification { + (void)notification; + _trackingMenus++; + [self releaseKeyboardCapture]; +} + +- (void)menuDidEndTracking:(NSNotification *)notification { + (void)notification; + if (_trackingMenus) _trackingMenus--; + [self updateKeyboardCapture]; +} + - (void)windowWillClose:(NSNotification *)notification { self.userClosed = YES; /* mark closed (X button or programmatic) before teardown */ [self teardown]; @@ -1284,25 +1533,27 @@ - (void)windowWillClose:(NSNotification *)notification { - (void)teardown { if (_stop) return; + [self releaseKeyboardCapture]; + if (_eventMonitor) { + [NSEvent removeMonitor:_eventMonitor]; + _eventMonitor = nil; + } + [[NSNotificationCenter defaultCenter] removeObserver:self]; + _trackingMenus = 0; _stop = 1; [self.timer invalidate]; self.timer = nil; - /* Wake the input worker if it's parked in pthread_cond_timedwait so it sees _stop at once - (the shutdown() below also unblocks it if it's mid-send). */ + /* Wake the input worker so it can flush pending key releases before closing ch3. */ pthread_mutex_lock(&_inqLock); pthread_cond_broadcast(&_inqCond); pthread_mutex_unlock(&_inqLock); - /* Drop both fds so the threads' blocking calls return: the input thread's published fd, and the - display thread's published ch2 fd (it's blocked in idd_rd_full -> recv; shutdown() forces EOF). - Without the display shutdown the join below hangs until the VDD next sends a frame. */ + /* Unblock the readers. The nonblocking input worker drains key releases and closes ch3 itself. */ pthread_mutex_lock(&_inputLock); - int ifd = _inputFd; _inputFd = -1; int dfd = _displayFd; _displayFd = -1; int afd = _audioFd; _audioFd = -1; int cwfd = _clipWriterFd; _clipWriterFd = -1; int crfd = _clipReaderFd; _clipReaderFd = -1; pthread_mutex_unlock(&_inputLock); - if (ifd >= 0) shutdown(ifd, SHUT_RDWR); if (dfd >= 0) shutdown(dfd, SHUT_RDWR); /* Audio + clipboard threads block in recv on their published fd; shutdown() forces EOF so the join below can't hang (same close-hang fix as display). */ @@ -1317,6 +1568,9 @@ - (void)teardown { pthread_join(_clipReaderThread, NULL); _threadsStarted = NO; } + pthread_mutex_lock(&_inqLock); + _inqHead = _inqTail = 0; + pthread_mutex_unlock(&_inqLock); /* The audio thread stops its own AudioQueue on exit, but make sure nothing lingers. */ if (_aq) { AudioQueueStop(_aq, true); AudioQueueDispose(_aq, true); _aq = NULL; } } @@ -1598,34 +1852,15 @@ - (void)scrollWheel:(NSEvent *)e /* ---- keyboard ---- */ - (void)keyDown:(NSEvent *)e { - [self.owner flushMove]; - unsigned short kc = e.keyCode; - uint8_t vk = (kc < 128) ? g_vk[kc] : 0; - if (vk) [self.owner sendInput:INPUT_KEY p1:vk p2:0 p3:(is_extended(kc) ? 1 : 0)]; + [self.owner forwardKeyEvent:e]; /* swallow (no super) to avoid the system beep */ } - (void)keyUp:(NSEvent *)e { - [self.owner flushMove]; - unsigned short kc = e.keyCode; - uint8_t vk = (kc < 128) ? g_vk[kc] : 0; - if (vk) [self.owner sendInput:INPUT_KEY p1:vk p2:0 p3:((is_extended(kc) ? 1 : 0) | 2)]; + [self.owner forwardKeyEvent:e]; } - (void)flagsChanged:(NSEvent *)e { - [self.owner flushMove]; - unsigned short kc = e.keyCode; - uint32_t vk = 0, mask = 0; - switch (kc) { - case 0x38: case 0x3C: vk = 0x10; mask = NSEventModifierFlagShift; break; /* Shift */ - case 0x3B: case 0x3E: vk = 0x11; mask = NSEventModifierFlagControl; break; /* Control */ - case 0x3A: case 0x3D: vk = 0x12; mask = NSEventModifierFlagOption; break; /* Option->Alt */ - case 0x37: vk = 0x5B; mask = NSEventModifierFlagCommand; break; /* LCmd->LWin */ - case 0x36: vk = 0x5C; mask = NSEventModifierFlagCommand; break; /* RCmd->RWin */ - case 0x39: vk = 0x14; mask = NSEventModifierFlagCapsLock; break; /* CapsLock */ - default: return; - } - BOOL down = (e.modifierFlags & mask) != 0; - [self.owner sendInput:INPUT_KEY p1:vk p2:0 p3:(down ? 0 : 2)]; + [self.owner forwardKeyEvent:e]; } @end diff --git a/src/backend_mac/iso_patch_mac.m b/src/backend_mac/iso_patch_mac.m index 22a225e..ab5ab86 100644 --- a/src/backend_mac/iso_patch_mac.m +++ b/src/backend_mac/iso_patch_mac.m @@ -392,6 +392,7 @@ + (void)installMacOSWithName:(NSString *)name @"install", @"--name", name, @"--vm-dir", vmDir.path, + @"--disk-path", [VmDir diskImageURLFor:name].path, @"--ipsw", ipswURL.path, @"--ram-mb", [NSString stringWithFormat:@"%d", ramMb], @"--cpus", [NSString stringWithFormat:@"%d", cpus], diff --git a/src/backend_mac/qemu_vm.m b/src/backend_mac/qemu_vm.m index c011950..2ad484b 100644 --- a/src/backend_mac/qemu_vm.m +++ b/src/backend_mac/qemu_vm.m @@ -1,4 +1,5 @@ #import "qemu_vm.h" +#import "vm_dir.h" #import "asb_ivshmem_transport.h" #import @@ -176,7 +177,7 @@ - (void)startWithCompletion:(void (^)(NSError *_Nullable))completion { } } - NSString *diskPath = [self.vmDir URLByAppendingPathComponent:@"disk.img"].path; + NSString *diskPath = [VmDir diskImageURLFor:self.name].path; NSString *ivsPath = [self.vmDir URLByAppendingPathComponent:@"ivshmem.bin"].path; NSString *varsPath = [self.vmDir URLByAppendingPathComponent:@"vars.fd"].path; @@ -234,7 +235,8 @@ - (void)startWithCompletion:(void (^)(NSError *_Nullable))completion { * the Hyper-V case where the desktop lands on the VDD with no topology setup. */ @"-device", @"qemu-xhci,id=usb", @"-device", @"usb-kbd", @"-device", @"usb-tablet", @"-device", @"nvme,drive=hdd,serial=asb-nvme,bootindex=0", - @"-drive", [NSString stringWithFormat:@"if=none,id=hdd,format=raw,file=%@", diskPath], + @"-drive", [NSString stringWithFormat:@"if=none,id=hdd,format=raw,file=%@", + [diskPath stringByReplacingOccurrencesOfString:@"," withString:@",,"]], @"-netdev", @"vmnet-shared,id=net0", /* NAT networking: vmnet-shared NATs the guest to the internet AND puts the host on the same * private subnet (bridge100 is the gateway), so host<->guest is reachable over IP. The NIC is diff --git a/src/backend_mac/vm_dir.h b/src/backend_mac/vm_dir.h index 30d6759..d79f705 100644 --- a/src/backend_mac/vm_dir.h +++ b/src/backend_mac/vm_dir.h @@ -17,6 +17,11 @@ NS_ASSUME_NONNULL_BEGIN + (NSURL *)vmsRootDirectory; + (NSURL *)directoryForVm:(NSString *)name; ++ (NSURL *)diskDirectoryForVm:(NSString *)name; +/* Empty selects the default VM directory. Custom parents must already exist. */ ++ (NSString *)normalizedDiskDirectory:(NSString *)directory; ++ (nullable NSString *)validationErrorForDiskDirectory:(NSString *)directory + vmName:(NSString *)name; + (NSURL *)diskImageURLFor:(NSString *)name; + (NSURL *)auxiliaryStorageURLFor:(NSString *)name; diff --git a/src/backend_mac/vm_dir.m b/src/backend_mac/vm_dir.m index c9ca814..242feb0 100644 --- a/src/backend_mac/vm_dir.m +++ b/src/backend_mac/vm_dir.m @@ -1,7 +1,10 @@ #import "vm_dir.h" +#import "asb_core_mac.h" #include #include +#include +#include @implementation VmDir @@ -43,7 +46,52 @@ + (NSURL *)directoryForVm:(NSString *)name { } + (NSURL *)diskImageURLFor:(NSString *)name { - return [[self directoryForVm:name] URLByAppendingPathComponent:@"disk.img"]; + return [[self diskDirectoryForVm:name] URLByAppendingPathComponent:@"disk.img"]; +} + ++ (NSString *)normalizedDiskDirectory:(NSString *)directory { + if (!directory.length) return @""; + NSString *path = directory.stringByStandardizingPath.stringByResolvingSymlinksInPath; + NSString *root = [self vmsRootDirectory].path; + struct stat selected, original; + if ([path isEqualToString:root] || + (stat(path.fileSystemRepresentation, &selected) == 0 && + stat(root.fileSystemRepresentation, &original) == 0 && + selected.st_dev == original.st_dev && selected.st_ino == original.st_ino)) + return @""; + return path; +} + ++ (NSString *)validationErrorForDiskDirectory:(NSString *)directory vmName:(NSString *)name { + if (!directory.length) return nil; + if ([directory rangeOfCharacterFromSet:[NSCharacterSet controlCharacterSet]].location != NSNotFound) + return @"Disk folder cannot contain control characters."; + if (![directory hasPrefix:@"/"]) + return @"Disk folder must be an absolute path."; + NSString *parent = [self normalizedDiskDirectory:directory]; + if (!parent.length) return nil; + NSString *child = [parent stringByAppendingPathComponent:name]; + if ([directory lengthOfBytesUsingEncoding:NSUTF8StringEncoding] >= 1024 || + [[child stringByAppendingPathComponent:@"disk.img"] lengthOfBytesUsingEncoding:NSUTF8StringEncoding] >= 1024) + return @"Disk folder path is too long."; + NSFileManager *fm = [NSFileManager defaultManager]; + BOOL isDirectory = NO; + if (![fm fileExistsAtPath:parent isDirectory:&isDirectory] || !isDirectory) + return @"Disk folder must be an existing folder."; + if (![fm isWritableFileAtPath:parent]) + return @"Disk folder is not writable."; + struct stat existing; + if (lstat(child.fileSystemRepresentation, &existing) == 0) + return @"A folder or file for this VM already exists in the disk storage location."; + return nil; +} + ++ (NSURL *)diskDirectoryForVm:(NSString *)name { + AsbVmMac *vm = asb_mac_vm_find(name.UTF8String); + if (vm && vm->disk_directory[0]) + return [[NSURL fileURLWithPath:@(vm->disk_directory) isDirectory:YES] + URLByAppendingPathComponent:name isDirectory:YES]; + return [self directoryForVm:name]; } + (NSURL *)auxiliaryStorageURLFor:(NSString *)name { @@ -60,10 +108,19 @@ + (NSURL *)machineIdentifierURLFor:(NSString *)name { + (BOOL)ensureDirectoryFor:(NSString *)name error:(NSError **)error { NSURL *dir = [self directoryForVm:name]; - return [[NSFileManager defaultManager] createDirectoryAtURL:dir + NSURL *diskDir = [self diskDirectoryForVm:name]; + BOOL external = ![diskDir isEqual:dir]; + /* A missing external parent must not be recreated as local storage. */ + if (external && mkdir(diskDir.fileSystemRepresentation, 0755) != 0) { + if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]; + return NO; + } + BOOL ok = [[NSFileManager defaultManager] createDirectoryAtURL:dir withIntermediateDirectories:YES attributes:nil error:error]; + if (!ok && external) rmdir(diskDir.fileSystemRepresentation); + return ok; } + (BOOL)vmExists:(NSString *)name { @@ -74,6 +131,20 @@ + (BOOL)vmExists:(NSString *)name { + (BOOL)deleteVm:(NSString *)name error:(NSError **)error { NSURL *dir = [self directoryForVm:name]; + NSURL *diskDir = [self diskDirectoryForVm:name]; + if (![diskDir isEqual:dir]) { + NSFileManager *fm = [NSFileManager defaultManager]; + /* An offline volume must not silently orphan its VM disk. */ + BOOL isDirectory = NO; + if (![fm fileExistsAtPath:diskDir.URLByDeletingLastPathComponent.path isDirectory:&isDirectory] || !isDirectory) { + if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:ENOENT userInfo:nil]; + return NO; + } + NSURL *disk = [self diskImageURLFor:name]; + if ([fm fileExistsAtPath:disk.path] && ![fm removeItemAtURL:disk error:error]) return NO; + /* Leave unrelated files in the VM's disk directory. */ + rmdir(diskDir.fileSystemRepresentation); + } return [[NSFileManager defaultManager] removeItemAtURL:dir error:error]; } diff --git a/src/backend_win/asb_core.c b/src/backend_win/asb_core.c index dc7e0f5..d3ade17 100644 --- a/src/backend_win/asb_core.c +++ b/src/backend_win/asb_core.c @@ -465,7 +465,7 @@ static void save_vm_list(void) get_config_path(path, MAX_PATH); - if (_wfopen_s(&f, path, L"w") != 0 || !f) return; + if (_wfopen_s(&f, path, L"w,ccs=UTF-8") != 0 || !f) return; if (g_last_iso_path[0] != L'\0' || g_suppress_tray_warn) { fwprintf(f, L"[Settings]\n"); @@ -476,6 +476,14 @@ static void save_vm_list(void) fwprintf(f, L"\n"); } + for (i = 0; i < g_template_count; i++) { + fwprintf(f, L"[Template]\n"); + fwprintf(f, L"Name=%s\n", g_templates[i].name); + fwprintf(f, L"OsType=%s\n", g_templates[i].os_type); + fwprintf(f, L"ImagePath=%s\n", g_templates[i].image_path); + fwprintf(f, L"VhdxPath=%s\n\n", g_templates[i].vhdx_path); + } + for (i = 0; i < g_vm_count; i++) { if (g_vms[i].building_vhdx) continue; fwprintf(f, L"[VM]\n"); @@ -519,16 +527,46 @@ static void save_vm_list(void) /* ---- Persistence: load VM list ---- */ +/* A VM named "snapshots" is a normal VM folder. Only generated branch/frozen + filenames identify a disk in the snapshots subfolder. */ +static BOOL get_vm_disk_root(const wchar_t *disk_path, wchar_t *out) +{ + wchar_t *slash; + BOOL branch; + size_t len; + if (!disk_path || wcslen(disk_path) >= MAX_PATH) return FALSE; + wcscpy_s(out, MAX_PATH, disk_path); + slash = wcsrchr(out, L'\\'); + if (!slash) return FALSE; + branch = (_wcsnicmp(slash + 1, L"branch_", 7) == 0 || + _wcsnicmp(slash + 1, L"snapshot_", 9) == 0); + *slash = 0; + len = wcslen(out); + if (branch && len >= 10 && _wcsicmp(out + len - 10, L"\\snapshots") == 0) + out[len - 10] = 0; + /* Never treat a drive root as an owned VM directory. */ + return wcslen(out) > 3; +} + static void load_vm_list(void) { wchar_t path[MAX_PATH]; wchar_t line[1024]; FILE *f; VmInstance *vm = NULL; + TemplateInfo *tpl = NULL; BOOL in_settings = FALSE; + unsigned char bom[3] = { 0 }; + BOOL unicode_config; get_config_path(path, MAX_PATH); - if (_wfopen_s(&f, path, L"r") != 0 || !f) return; + /* A Unicode BOM selects Unicode decoding; files without a BOM use ANSI. */ + if (_wfopen_s(&f, path, L"rb") != 0 || !f) return; + (void)fread(bom, 1, sizeof(bom), f); + fclose(f); + unicode_config = (bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF) || + (bom[0] == 0xFF && bom[1] == 0xFE); + if (_wfopen_s(&f, path, unicode_config ? L"r,ccs=UTF-8" : L"r") != 0 || !f) return; while (fgetws(line, 1024, f)) { size_t len = wcslen(line); @@ -538,11 +576,13 @@ static void load_vm_list(void) if (wcscmp(line, L"[Settings]") == 0) { in_settings = TRUE; vm = NULL; + tpl = NULL; continue; } if (wcscmp(line, L"[VM]") == 0) { in_settings = FALSE; + tpl = NULL; if (g_vm_count >= ASB_MAX_VMS) break; vm = &g_vms[g_vm_count]; ZeroMemory(vm, sizeof(VmInstance)); @@ -551,6 +591,29 @@ static void load_vm_list(void) continue; } + if (wcscmp(line, L"[Template]") == 0) { + in_settings = FALSE; + vm = NULL; + tpl = NULL; + if (g_template_count < ASB_MAX_TEMPLATES) { + tpl = &g_templates[g_template_count++]; + ZeroMemory(tpl, sizeof(*tpl)); + } + continue; + } + + if (tpl) { + if (wcsncmp(line, L"Name=", 5) == 0) + wcsncpy_s(tpl->name, 256, line + 5, _TRUNCATE); + else if (wcsncmp(line, L"OsType=", 7) == 0) + wcsncpy_s(tpl->os_type, 32, line + 7, _TRUNCATE); + else if (wcsncmp(line, L"ImagePath=", 10) == 0) + wcsncpy_s(tpl->image_path, MAX_PATH, line + 10, _TRUNCATE); + else if (wcsncmp(line, L"VhdxPath=", 9) == 0) + wcsncpy_s(tpl->vhdx_path, MAX_PATH, line + 9, _TRUNCATE); + continue; + } + if (in_settings) { if (wcsncmp(line, L"LastIsoPath=", 12) == 0) wcscpy_s(g_last_iso_path, MAX_PATH, line + 12); @@ -617,23 +680,15 @@ static void load_vm_list(void) int i; for (i = 0; i < g_vm_count; i++) { wchar_t snap_dir[MAX_PATH]; - wchar_t *last_slash; g_vms[i].handle = NULL; g_vms[i].running = FALSE; if (vm_load_state_json(g_vms[i].vhdx_path)) g_vms[i].install_complete = TRUE; - wcscpy_s(snap_dir, MAX_PATH, g_vms[i].vhdx_path); - last_slash = wcsrchr(snap_dir, L'\\'); - if (last_slash) *last_slash = L'\0'; - { - size_t dlen = wcslen(snap_dir); - if (dlen >= 10 && _wcsicmp(snap_dir + dlen - 10, L"\\snapshots") == 0) { - /* Already points to snapshots dir */ - } else { - wcscat_s(snap_dir, MAX_PATH, L"\\snapshots"); - } + if (get_vm_disk_root(g_vms[i].vhdx_path, snap_dir) && + wcslen(snap_dir) + 10 < MAX_PATH) { + wcscat_s(snap_dir, MAX_PATH, L"\\snapshots"); + snapshot_init(&g_snap_trees[i], snap_dir); } - snapshot_init(&g_snap_trees[i], snap_dir); } } } @@ -647,7 +702,8 @@ static void scan_templates(void) WIN32_FIND_DATAW fd; HANDLE hFind; - g_template_count = 0; + /* Retain registered templates while external storage is unavailable, and + discover unregistered folders in the template library. */ { wchar_t base_dir[MAX_PATH]; @@ -665,9 +721,13 @@ static void scan_templates(void) wchar_t vhdx_path[MAX_PATH]; FILE *jf; wchar_t line[1024]; + int i; if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; if (fd.cFileName[0] == L'.') continue; + for (i = 0; i < g_template_count; i++) + if (_wcsicmp(g_templates[i].name, fd.cFileName) == 0) break; + if (i < g_template_count) continue; if (g_template_count >= ASB_MAX_TEMPLATES) break; swprintf_s(json_path, MAX_PATH, L"%s\\%s\\%s.json", tpl_base, fd.cFileName, fd.cFileName); @@ -709,12 +769,20 @@ static void scan_templates(void) /* ---- Utility: recursive directory delete ---- */ -static void remove_dir_recursive(const wchar_t *dir) +static BOOL remove_dir_recursive(const wchar_t *dir) { wchar_t pattern[MAX_PATH], full[MAX_PATH]; WIN32_FIND_DATAW fd; HANDLE h; + /* A VM folder may be on user-selected storage. Never follow junctions or + directory symlinks while removing its files. */ + DWORD attrs = GetFileAttributesW(dir); + if (attrs == INVALID_FILE_ATTRIBUTES) return FALSE; + if (attrs & FILE_ATTRIBUTE_REPARSE_POINT) { + return RemoveDirectoryW(dir); + } + swprintf_s(pattern, MAX_PATH, L"%s\\*", dir); h = FindFirstFileW(pattern, &fd); if (h != INVALID_HANDLE_VALUE) { @@ -730,7 +798,7 @@ static void remove_dir_recursive(const wchar_t *dir) } while (FindNextFileW(h, &fd)); FindClose(h); } - RemoveDirectoryW(dir); + return RemoveDirectoryW(dir); } /* ---- HCS state callback (called from HCS worker thread) ---- */ @@ -783,6 +851,14 @@ static void asb_hcs_state_changed(VmInstance *instance, DWORD event) asb_log(L"Template \"%s\" created successfully.", instance->name); EnterCriticalSection(&g_cs); + if (g_template_count < ASB_MAX_TEMPLATES) { + TemplateInfo *ti = &g_templates[g_template_count++]; + ZeroMemory(ti, sizeof(*ti)); + wcscpy_s(ti->name, 256, instance->name); + wcscpy_s(ti->os_type, 32, instance->os_type); + wcscpy_s(ti->image_path, MAX_PATH, instance->image_path); + wcscpy_s(ti->vhdx_path, MAX_PATH, instance->vhdx_path); + } for (j = i; j < g_vm_count - 1; j++) { g_vms[j] = g_vms[j+1]; g_snap_trees[j] = g_snap_trees[j+1]; @@ -974,8 +1050,10 @@ static DWORD WINAPI start_vm_thread(LPVOID param) * per-GPU driver shares. Windows guests have no use for it. */ if (_wcsicmp(args->config.os_type, L"Linux") == 0) gpu_append_lxsslib_share(&args->config.gpu_shares); - else if (_wcsicmp(args->config.os_type, L"Windows") == 0) + else if (_wcsicmp(args->config.os_type, L"Windows") == 0) { + gpu_append_nvidia_drs_share(&g_gpu_list, &args->config.gpu_shares); prepare_gl_layers_share(&args->config.gpu_shares); + } } asb_log(L"Re-creating HCS compute system for \"%s\"...", vm->name); @@ -1486,11 +1564,14 @@ static int stage_marker_file(FILE *manifest_f, const wchar_t *staging, } if (content_len > 0) { DWORD wr = 0; - WriteFile(h, content, (DWORD)content_len, &wr, NULL); + if (!WriteFile(h, content, (DWORD)content_len, &wr, NULL) || wr != content_len) { + CloseHandle(h); + asb_log(L"Error: could not write marker on host: %s", basename); + return 0; + } } CloseHandle(h); - fwprintf(manifest_f, L"%s\t%s\n", host, rootfs_path); - return 1; + return fwprintf(manifest_f, L"%s\t%s\n", host, rootfs_path) >= 0; } /* ==================================================================== @@ -1725,6 +1806,10 @@ static int generate_vhdx_manifest_ubuntu(const wchar_t *manifest_path, const wchar_t *vm_name) { wchar_t extras[MAX_PATH]; + if (!admin_user || !admin_user[0] || !admin_pw_hash || !admin_pw_hash[0]) { + asb_log(L"Error: Linux username and password hash are required."); + return -1; + } CreateDirectoryW(staging, NULL); /* Populate staging\extras\ with the full agent + module + units tree. */ stage_linux_agent_and_extras(staging, res_dir, ssh_enabled); @@ -1743,22 +1828,24 @@ static int generate_vhdx_manifest_ubuntu(const wchar_t *manifest_path, /* Walk extras\ -> /opt/appsandbox/ */ int n = write_manifest_walk_dir(f, extras, "/opt/appsandbox"); - /* Admin user + password hash. firstboot STEP 5 reads these to create - * the login account. Without them, the firstboot falls back to a - * static test/test123 (which is a security smell anyway). */ - if (admin_user && admin_user[0] && admin_pw_hash && admin_pw_hash[0]) { + /* Both account markers are required; do not build a guest that would use + firstboot's placeholder credentials because either marker is missing. */ + { char user_utf8[128]; - WideCharToMultiByte(CP_UTF8, 0, admin_user, -1, - user_utf8, sizeof(user_utf8), NULL, NULL); - n += stage_marker_file(f, staging, L"admin-user.marker", - user_utf8, strlen(user_utf8), - L"/etc/appsandbox-admin-user"); - n += stage_marker_file(f, staging, L"admin-hash.marker", - admin_pw_hash, strlen(admin_pw_hash), - L"/etc/appsandbox-admin-hash"); + if (!WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, admin_user, -1, + user_utf8, sizeof(user_utf8), NULL, NULL) || + !stage_marker_file(f, staging, L"admin-user.marker", + user_utf8, strlen(user_utf8), + L"/etc/appsandbox-admin-user") || + !stage_marker_file(f, staging, L"admin-hash.marker", + admin_pw_hash, strlen(admin_pw_hash), + L"/etc/appsandbox-admin-hash")) { + asb_log(L"Error: failed to stage Linux account credentials."); + fclose(f); + return -1; + } + n += 2; asb_log(L"Linux admin: staged user=%s + $6$ hash marker", admin_user); - } else { - asb_log(L"Linux admin: no admin_user/admin_pass — firstboot will use test/test123"); } if (ssh_enabled) { @@ -1802,7 +1889,11 @@ static int generate_vhdx_manifest_ubuntu(const wchar_t *manifest_path, asb_log(L"Linux hostname: staged /etc/appsandbox-hostname = %s", vm_name); } - fclose(f); + { + BOOL written = !ferror(f); + if (fclose(f) != 0) written = FALSE; + if (!written) return -1; + } asb_log(L"Linux staging manifest written: %s (%d file(s))", manifest_path, n); return n; } @@ -2263,16 +2354,16 @@ static DWORD WINAPI linux_create_thread(LPVOID param) /* Hash the modal-supplied admin password into glibc $6$ format so the firstboot can drop it straight into /etc/shadow via `usermod -p`. Plaintext is wiped immediately after — same pattern as the Windows - autounattend path. If the user left password blank, fall through - with empty strings; generate_vhdx_manifest_ubuntu logs a warning - and the guest falls back to the test/test123 placeholder. */ + autounattend path. */ char admin_pw_hash[256] = {0}; - if (args->config.admin_pass[0] != L'\0') { - if (!unix_password_hash(args->config.admin_pass, - admin_pw_hash, sizeof(admin_pw_hash))) { - asb_log(L"WARN: failed to hash admin_pass — falling back to test/test123"); - admin_pw_hash[0] = '\0'; - } + if (!args->config.admin_pass[0] || + !unix_password_hash(args->config.admin_pass, admin_pw_hash, sizeof(admin_pw_hash))) { + SecureZeroMemory(args->config.admin_pass, sizeof(args->config.admin_pass)); + SecureZeroMemory(admin_pw_hash, sizeof(admin_pw_hash)); + args->result = E_FAIL; + wcscpy_s(args->error_msg, ARRAYSIZE(args->error_msg), + L"Failed to hash the Linux password."); + goto done; } SecureZeroMemory(args->config.admin_pass, sizeof(args->config.admin_pass)); @@ -2601,6 +2692,246 @@ ASB_API void asb_detach(void) /* ---- VM Create ---- */ +ASB_API void asb_default_disk_directory(wchar_t *out, size_t out_len) +{ + wchar_t base[MAX_PATH]; + DWORD n; + if (!out || !out_len) return; + n = GetEnvironmentVariableW(L"ProgramData", base, MAX_PATH); + if (!n || n >= MAX_PATH) + wcscpy_s(base, MAX_PATH, L"C:\\ProgramData"); + _snwprintf_s(out, out_len, _TRUNCATE, L"%s\\AppSandbox", base); +} + +/* Each selected location receives an exclusive VM-named child, so cleanup + never owns the selected parent. */ +static const wchar_t *resolve_disk_directory(const wchar_t *name, + const wchar_t *selected, BOOL is_template, wchar_t *parent, wchar_t *vm_dir) +{ + wchar_t supplied[MAX_PATH], default_dir[MAX_PATH], default_full[MAX_PATH]; + size_t i, len; + DWORD n, attrs, err; + BOOL use_default; + + if (!name || !name[0] || wcscmp(name, L".") == 0 || wcscmp(name, L"..") == 0) + return L"VM name is required."; + len = wcslen(name); + if (len >= 256 || name[len - 1] == L'.' || name[len - 1] == L' ') + return L"VM name cannot be used as a disk folder name."; + for (i = 0; i < len; i++) + if (name[i] < 32 || wcschr(L"\\/:*?\"<>|", name[i])) + return L"VM name cannot be used as a disk folder name."; + + asb_default_disk_directory(default_dir, MAX_PATH); + if (!selected || !selected[0]) selected = default_dir; + len = wcslen(selected); + if (len >= MAX_PATH) + return L"Disk storage path is too long."; + wcscpy_s(supplied, MAX_PATH, selected); + for (i = 0; i < len; i++) { + if (supplied[i] == L'/') supplied[i] = L'\\'; + if (supplied[i] < 32 || wcschr(L"*?\"<>|", supplied[i]) || + (supplied[i] == L':' && i != 1)) + return L"Disk storage location contains invalid path characters."; + } + if (!((len >= 3 && ((supplied[0] >= L'A' && supplied[0] <= L'Z') || + (supplied[0] >= L'a' && supplied[0] <= L'z')) && + supplied[1] == L':' && supplied[2] == L'\\') || + (len >= 5 && supplied[0] == L'\\' && supplied[1] == L'\\'))) + return L"Disk storage location must be an absolute path."; + + n = GetFullPathNameW(supplied, MAX_PATH, parent, NULL); + if (!n || n >= MAX_PATH) + return L"Disk storage path is too long or invalid."; + while (n > 3 && parent[n - 1] == L'\\') parent[--n] = 0; + n = GetFullPathNameW(default_dir, MAX_PATH, default_full, NULL); + if (!n || n >= MAX_PATH) return L"Default disk storage location is invalid."; + while (n > 3 && default_full[n - 1] == L'\\') default_full[--n] = 0; + use_default = (_wcsicmp(parent, default_full) == 0); + if (use_default && is_template) { + if (wcslen(parent) + 10 >= MAX_PATH) return L"Disk storage path is too long."; + wcscat_s(parent, MAX_PATH, L"\\templates"); + } + + /* Leave room for both GUID snapshot names and the installer staging tree. */ + if (wcslen(parent) + 1 + wcslen(name) + 96 >= MAX_PATH) + return L"Disk storage path is too long for VM files and snapshots."; + attrs = GetFileAttributesW(parent); + if (attrs == INVALID_FILE_ATTRIBUTES) { + err = GetLastError(); + if (!use_default || (err != ERROR_FILE_NOT_FOUND && err != ERROR_PATH_NOT_FOUND)) + return L"Disk storage location must be an existing accessible folder."; + } else if (!(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + return L"Disk storage location must be a folder."; + } + swprintf_s(vm_dir, MAX_PATH, L"%s%s%s", parent, + parent[wcslen(parent) - 1] == L'\\' ? L"" : L"\\", name); + attrs = GetFileAttributesW(vm_dir); + if (attrs != INVALID_FILE_ATTRIBUTES) + return L"A file or folder with this VM name already exists in the disk storage location."; + err = GetLastError(); + if (err != ERROR_FILE_NOT_FOUND && err != ERROR_PATH_NOT_FOUND) + return L"The VM disk folder cannot be accessed."; + return NULL; +} + +ASB_API const wchar_t *asb_validate_disk_directory(const wchar_t *name, + const wchar_t *disk_directory, BOOL is_template) +{ + wchar_t parent[MAX_PATH], vm_dir[MAX_PATH]; + return resolve_disk_directory(name, disk_directory, is_template, parent, vm_dir); +} + +ASB_API const wchar_t *asb_validate_template_disk_size(const wchar_t *template_name, + DWORD hdd_gb) +{ + int i; + ULONGLONG parent_bytes; + HRESULT hr; + + if (!template_name || !template_name[0]) return NULL; + for (i = 0; i < g_template_count; i++) { + if (_wcsicmp(g_templates[i].name, template_name) == 0) { + hr = vhdx_get_virtual_size(g_templates[i].vhdx_path, &parent_bytes); + if (FAILED(hr)) { + asb_log(L"Cannot read template VHDX capacity (0x%08X): %s", + hr, g_templates[i].vhdx_path); + return L"Cannot read the selected template's virtual disk size."; + } + if ((ULONGLONG)(hdd_gb ? hdd_gb : 64) * 1024ULL * 1024ULL * 1024ULL < parent_bytes) + return L"HDD size cannot be smaller than the template's virtual disk size."; + return NULL; + } + } + return L"The selected template was not found."; +} + +/* ECMAScript String.trim whitespace, matching the creation form. */ +static BOOL username_is_space(wchar_t ch) +{ + return (ch >= 0x0009 && ch <= 0x000D) || ch == 0x0020 || + ch == 0x00A0 || ch == 0x1680 || (ch >= 0x2000 && ch <= 0x200A) || + ch == 0x2028 || ch == 0x2029 || ch == 0x202F || ch == 0x205F || + ch == 0x3000 || ch == 0xFEFF; +} + +static const wchar_t *trimmed_username(const wchar_t *username, size_t *length) +{ + if (!username) username = L""; + while (username_is_space(*username)) username++; + *length = wcslen(username); + while (*length && username_is_space(username[*length - 1])) (*length)--; + return username; +} + +ASB_API const wchar_t *asb_validate_username(const wchar_t *os_type, + const wchar_t *username, const wchar_t *vm_name, BOOL is_template) +{ + size_t length, i; + BOOL is_linux = os_type && _wcsicmp(os_type, L"Linux") == 0; + BOOL only_dots_spaces = TRUE; + + username = trimmed_username(username, &length); + if (!length) return L"Username is required."; + for (i = 0; i < length; i++) { + unsigned ch = username[i]; + if (ch >= 0xD800 && ch <= 0xDBFF) { + if (++i >= length || username[i] < 0xDC00 || username[i] > 0xDFFF) + return L"Username contains invalid Unicode."; + } else if (ch >= 0xDC00 && ch <= 0xDFFF) { + return L"Username contains invalid Unicode."; + } + } + if (is_linux) { + /* Canonical Subiquity 26.04 reserved-usernames. */ + static const wchar_t *reserved[] = { + L"root", L"daemon", L"bin", L"sys", L"sync", L"games", L"man", L"lp", + L"mail", L"news", L"uucp", L"proxy", L"www-data", L"backup", L"list", + L"irc", L"gnats", L"nobody", L"adm", L"tty", L"disk", L"kmem", L"dialout", + L"fax", L"voice", L"cdrom", L"floppy", L"tape", L"sudo", L"audio", L"dip", + L"operator", L"src", L"shadow", L"utmp", L"video", L"sasl", L"plugdev", + L"staff", L"users", L"nogroup", L"netplan", L"ftn", L"mysql", L"tac-plus", + L"alias", L"qmail", L"qmaild", L"qmails", L"qmailr", L"qmailq", L"qmaill", + L"qmailp", L"asterisk", L"vpopmail", L"vchkpw", L"slurm", L"hacluster", + L"haclient", L"grsec-tpe", L"grsec-sock-all", L"grsec-sock-clt", + L"grsec-sock-srv", L"grsec-proc", L"ceph", L"opensrf", L"libvirt-qemu", + L"admin", L"Debian-exim", L"bind", L"crontab", L"cupsys", L"dcc", L"dhcp", + L"dictd", L"dnsmasq", L"dovecot", L"fetchmail", L"firebird", L"ftp", L"fuse", + L"gdm", L"haldaemon", L"hplilp", L"identd", L"input", L"jwhois", L"klog", + L"kvm", L"lpadmin", L"maas", L"messagebus", L"mythtv", L"netdev", L"powerdev", + L"radvd", L"render", L"saned", L"sbuild", L"scanner", L"sgx", L"slocate", + L"ssh", L"sshd", L"ssl-cert", L"sslwrap", L"statd", L"syslog", L"telnetd", L"tftpd" + }; + if (length > 32) return L"Username cannot exceed 32 characters (Linux limit)."; + for (i = 0; i < length; i++) { + wchar_t ch = username[i]; + if (!((ch >= L'a' && ch <= L'z') || ch == L'_' || + (i && ((ch >= L'0' && ch <= L'9') || ch == L'-')))) + return L"Linux username: lowercase letters, digits, _ and - only; start with a letter or _."; + } + for (i = 0; i < ARRAYSIZE(reserved); i++) + if (wcslen(reserved[i]) == length && wcsncmp(username, reserved[i], length) == 0) + return L"Username is a reserved name."; + return NULL; + } + if (length > 20) return L"Username cannot exceed 20 characters."; + for (i = 0; i < length; i++) { + wchar_t ch = username[i]; + if (ch < 32 || ch == 0xFFFE || ch == 0xFFFF || wcschr(L"\"\\/[]:;|=,+*?<>%@", ch)) + return L"Username contains invalid characters."; + if (ch != L'.' && !username_is_space(ch)) only_dots_spaces = FALSE; + } + if (only_dots_spaces) return L"Username cannot be only dots or spaces."; + if (username[length - 1] == L'.') return L"Username cannot end with a period."; + { + static const wchar_t *reserved[] = { + L"NONE", L"CON", L"PRN", L"AUX", L"NUL", + L"COM1", L"COM2", L"COM3", L"COM4", L"COM5", L"COM6", L"COM7", L"COM8", L"COM9", + L"LPT1", L"LPT2", L"LPT3", L"LPT4", L"LPT5", L"LPT6", L"LPT7", L"LPT8", L"LPT9" + }; + for (i = 0; i < ARRAYSIZE(reserved); i++) + if (wcslen(reserved[i]) == length && _wcsnicmp(username, reserved[i], length) == 0) + return L"Username is a reserved name."; + } + if (!is_template && vm_name && wcslen(vm_name) == length && + _wcsnicmp(username, vm_name, length) == 0) + return L"Username cannot match the VM name (Windows computer name)."; + return NULL; +} + +ASB_API const wchar_t *asb_validate_password(const wchar_t *os_type, + const wchar_t *password) +{ + size_t units = 0, code_points = 0, utf8_bytes = 0; + BOOL is_linux = os_type && _wcsicmp(os_type, L"Linux") == 0; + + if (!password || !password[0]) return L"Password is required."; + while (password[units]) { + unsigned ch = password[units++]; + if (ch >= 0xD800 && ch <= 0xDBFF) { + unsigned low = password[units]; + if (low < 0xDC00 || low > 0xDFFF) + return L"Password contains invalid Unicode."; + units++; + utf8_bytes += 4; + } else if (ch >= 0xDC00 && ch <= 0xDFFF) { + return L"Password contains invalid Unicode."; + } else { + utf8_bytes += ch < 0x80 ? 1 : ch < 0x800 ? 2 : 3; + } + code_points++; + } + if (is_linux) { + if (code_points < 6) + return L"Password must be at least 6 characters (Ubuntu minimum)."; + if (utf8_bytes > 255) + return L"Password is too long (max 255 bytes)."; + } else if (units > 127) { + return L"Password is too long (max 127 characters for Windows)."; + } + return NULL; +} + ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) { VmConfig cfg; @@ -2613,20 +2944,48 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) BOOL is_template_create; int template_idx = -1; BOOL from_template = FALSE; + const wchar_t *effective_os; if (!config || !config->name || config->name[0] == L'\0') { asb_log(L"Error: VM name is required."); return E_INVALIDARG; } + effective_os = config->os_type; + if (config->template_name && config->template_name[0] != L'\0') { + int i; + for (i = 0; i < g_template_count; i++) { + if (_wcsicmp(g_templates[i].name, config->template_name) == 0) { + template_idx = i; + from_template = TRUE; + effective_os = g_templates[i].os_type; + break; + } + } + } + { + const wchar_t *error = asb_validate_username(effective_os, config->username, + config->name, config->is_template); + if (!error) error = asb_validate_password(effective_os, config->password); + if (error) { + asb_log(L"Error: %s", error); + asb_alert(error); + return E_INVALIDARG; + } + } + ZeroMemory(&cfg, sizeof(cfg)); /* Copy config strings into local VmConfig */ wcscpy_s(cfg.name, 256, config->name); - if (config->os_type) wcscpy_s(cfg.os_type, 32, config->os_type); + if (effective_os) wcscpy_s(cfg.os_type, 32, effective_os); 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); + { + size_t user_length; + const wchar_t *user = trimmed_username(config->username, &user_length); + wcsncpy_s(cfg.admin_user, ARRAYSIZE(cfg.admin_user), user, user_length); + } + wcscpy_s(cfg.admin_pass, ARRAYSIZE(cfg.admin_pass), config->password); cfg.ram_mb = config->ram_mb; cfg.hdd_gb = config->hdd_gb; cfg.cpu_cores = config->cpu_cores; @@ -2657,24 +3016,20 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) cfg.test_mode = TRUE; } - /* Resolve template */ - if (config->template_name && config->template_name[0] != L'\0') { - int i; - for (i = 0; i < g_template_count; i++) { - if (_wcsicmp(g_templates[i].name, config->template_name) == 0) { - template_idx = i; - from_template = TRUE; - wcscpy_s(cfg.os_type, 32, g_templates[i].os_type); - break; - } - } - } - if (is_template_create && from_template) { asb_log(L"Error: Cannot create a template from another template."); return E_INVALIDARG; } + { + const wchar_t *error = asb_validate_template_disk_size(config->template_name, cfg.hdd_gb); + if (error) { + asb_log(L"Error: %s", error); + asb_alert(error); + return E_INVALIDARG; + } + } + /* Linux v1: require an ISO. Templates aren't supported for Linux yet. */ if (_wcsicmp(cfg.os_type, L"Linux") == 0) { if (is_template_create) { @@ -2722,6 +3077,15 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) asb_log(L"Maximum VM count reached (%d)", ASB_MAX_VMS); return E_OUTOFMEMORY; } + if (is_template_create) { + int pending = 0, i; + for (i = 0; i < g_vm_count; i++) + if (g_vms[i].is_template) pending++; + if (g_template_count + pending >= ASB_MAX_TEMPLATES) { + asb_log(L"Maximum template count reached (%d)", ASB_MAX_TEMPLATES); + return E_OUTOFMEMORY; + } + } inst = &g_vms[g_vm_count]; ZeroMemory(inst, sizeof(VmInstance)); @@ -2738,26 +3102,31 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) cfg.network_mode = NET_NONE; } - if (cfg.admin_user[0] == L'\0') - wcscpy_s(cfg.admin_user, 128, L"User"); - - /* Create VHDX directory */ { - wchar_t base_dir[MAX_PATH]; - if (!GetEnvironmentVariableW(L"ProgramData", base_dir, MAX_PATH)) - wcscpy_s(base_dir, MAX_PATH, L"C:\\ProgramData"); - swprintf_s(vhdx_dir, MAX_PATH, L"%s\\AppSandbox", base_dir); - CreateDirectoryW(vhdx_dir, NULL); - - if (is_template_create) { - 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 { - swprintf_s(vhdx_dir, MAX_PATH, L"%s\\AppSandbox\\%s", base_dir, cfg.name); + wchar_t parent[MAX_PATH], default_dir[MAX_PATH]; + const wchar_t *error = resolve_disk_directory(cfg.name, config->disk_directory, + is_template_create, parent, vhdx_dir); + if (error) { + asb_log(L"Error: %s", error); + asb_alert(error); + return E_INVALIDARG; + } + asb_default_disk_directory(default_dir, MAX_PATH); + CreateDirectoryW(default_dir, NULL); + if (GetFileAttributesW(parent) == INVALID_FILE_ATTRIBUTES && + !CreateDirectoryW(parent, NULL)) { + hr = HRESULT_FROM_WIN32(GetLastError()); + asb_log(L"Error: Cannot create disk storage folder %s (0x%08X).", parent, hr); + asb_alert(L"Cannot create the disk storage folder."); + return hr; + } + if (!CreateDirectoryW(vhdx_dir, NULL)) { + hr = HRESULT_FROM_WIN32(GetLastError()); + asb_log(L"Error: Cannot create VM disk folder %s (0x%08X).", vhdx_dir, hr); + asb_alert(L"Cannot create the VM disk folder. Check the location and permissions."); + return hr; } } - CreateDirectoryW(vhdx_dir, NULL); swprintf_s(cfg.vhdx_path, MAX_PATH, L"%s\\disk.vhdx", vhdx_dir); /* GPU driver shares */ @@ -2768,8 +3137,10 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) * /usr/lib/wsl/lib by the agent on first connect. */ if (_wcsicmp(cfg.os_type, L"Linux") == 0) gpu_append_lxsslib_share(&cfg.gpu_shares); - else if (_wcsicmp(cfg.os_type, L"Windows") == 0) + else if (_wcsicmp(cfg.os_type, L"Windows") == 0) { + gpu_append_nvidia_drs_share(&g_gpu_list, &cfg.gpu_shares); prepare_gl_layers_share(&cfg.gpu_shares); + } } /* ---- VHDX-first path (Windows, from ISO) ---- */ @@ -2894,12 +3265,6 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) asb_log(L"Building Linux VM \"%s\" (direct ISO->VHDX, ~3 minutes)...", cfg.name); CloseHandle(CreateThread(NULL, 0, linux_create_thread, args, 0, NULL)); - /* Wipe the plaintext password from the local cfg; the worker - thread has its own heap copy in args->config and wipes that - after use (run_iso_patch_ubuntu doesn't consume it today — - firstboot.sh uses a static test/test123 — but the field is - retained on the config for future password injection via - --stage manifest). */ SecureZeroMemory(cfg.admin_pass, sizeof(cfg.admin_pass)); if (g_state_cb) g_state_cb(vm_handle(inst), FALSE, g_state_ud); @@ -2917,14 +3282,31 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) installs go through use_vhdx_first above). */ if (from_template) { asb_log(L"Creating differencing VHDX from template \"%s\"...", g_templates[template_idx].name); - DeleteFileW(cfg.vhdx_path); hr = vhdx_create_differencing(cfg.vhdx_path, g_templates[template_idx].vhdx_path); - if (FAILED(hr)) { asb_log(L"Error: Failed to create differencing VHDX (0x%08X)", hr); return hr; } - asb_log(L"Differencing VHDX created."); + if (FAILED(hr)) { + asb_log(L"Error: Failed to create differencing VHDX (0x%08X)", hr); + remove_dir_recursive(vhdx_dir); + return hr; + } + /* The child initially inherits the parent's capacity. Grow only the + new child before attaching it; the template remains unchanged. */ + hr = vhdx_grow(cfg.vhdx_path, (ULONGLONG)cfg.hdd_gb); + if (FAILED(hr)) { + asb_log(L"Error: Failed to size template instance VHDX to %lu GB (0x%08X).", + cfg.hdd_gb, hr); + asb_alert(L"Failed to apply the requested HDD size to the template instance."); + remove_dir_recursive(vhdx_dir); + return hr; + } + asb_log(L"Differencing VHDX ready (%lu GB).", cfg.hdd_gb); } else { asb_log(L"Creating VHDX: %s (%lu GB)...", cfg.vhdx_path, cfg.hdd_gb); hr = vhdx_create(cfg.vhdx_path, (ULONGLONG)cfg.hdd_gb); - if (FAILED(hr)) { asb_log(L"Error: Failed to create VHDX (0x%08X)", hr); return hr; } + if (FAILED(hr)) { + asb_log(L"Error: Failed to create VHDX (0x%08X)", hr); + remove_dir_recursive(vhdx_dir); + return hr; + } asb_log(L"VHDX created successfully."); } @@ -2947,7 +3329,9 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) else asb_log(L"Warning: Failed to create template resources ISO (0x%08X).", hr); } } else if (from_template) { - if (_wcsicmp(cfg.os_type, L"Windows") == 0 && cfg.admin_pass[0] != L'\0') { + /* Every Windows instance needs mini-setup, including the OS + partition extension when its child VHDX was enlarged. */ + if (_wcsicmp(cfg.os_type, L"Windows") == 0) { wchar_t template_lang[32] = L"en-US"; vm_load_language_json(g_templates[template_idx].vhdx_path, template_lang, 32); hr = iso_create_instance_resources(res_iso, cfg.name, cfg.admin_user, cfg.admin_pass, @@ -2956,7 +3340,13 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) wcscpy_s(cfg.resources_iso_path, MAX_PATH, res_iso); vm_save_language_json(cfg.vhdx_path, template_lang); } - else asb_log(L"Warning: Failed to create instance resources ISO (0x%08X).", hr); + else { + asb_log(L"Error: Failed to create instance resources ISO (0x%08X).", hr); + asb_alert(L"Failed to prepare the template instance's Windows setup."); + SecureZeroMemory(cfg.admin_pass, sizeof(cfg.admin_pass)); + remove_dir_recursive(vhdx_dir); + return hr; + } } } else { if (_wcsicmp(cfg.os_type, L"Windows") == 0 && cfg.image_path[0] != L'\0' && @@ -3024,6 +3414,7 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) hcn_delete_endpoint(&inst->endpoint_id); endpoint_guid_str[0] = L'\0'; } + remove_dir_recursive(vhdx_dir); return hr; } @@ -3106,6 +3497,19 @@ ASB_API HRESULT asb_vm_start(AsbVm vm, int snap_idx, int branch_idx, save_vm_list(); } + { + HRESULT hr = snapshot_ensure_writable(&g_snap_trees[idx], inst); + if (FAILED(hr)) { + asb_log(L"Error: Failed to create working branch (0x%08X)", hr); + if (g_state_cb) g_state_cb(vm, FALSE, g_state_ud); + return hr; + } + if (hr == S_OK) { + if (inst->handle) hcs_close_vm(inst); + save_vm_list(); + } + } + if (!inst->handle) { /* Need to re-create HCS system - do it in a background thread */ StartVmArgs *args = (StartVmArgs *)calloc(1, sizeof(StartVmArgs)); @@ -3226,12 +3630,21 @@ ASB_API HRESULT asb_vm_delete(AsbVm vm) { int idx, i; wchar_t dir[MAX_PATH]; - wchar_t *last_slash; VmInstance *inst; + DWORD attrs; + HRESULT hr; idx = vm_index_of(vm); if (idx < 0) return E_INVALIDARG; inst = &g_vms[idx]; + if (!get_vm_disk_root(inst->vhdx_path, dir)) return E_INVALIDARG; + attrs = GetFileAttributesW(dir); + if (attrs == INVALID_FILE_ATTRIBUTES) { + hr = HRESULT_FROM_WIN32(GetLastError()); + asb_log(L"Error: VM disk folder is unavailable: %s (0x%08X).", dir, hr); + return hr; + } + if (!(attrs & FILE_ATTRIBUTE_DIRECTORY)) return HRESULT_FROM_WIN32(ERROR_DIRECTORY); hcs_stop_monitor(inst); vm_ssh_proxy_stop(inst); @@ -3246,20 +3659,15 @@ ASB_API HRESULT asb_vm_delete(AsbVm vm) hcs_close_vm(inst); hcs_destroy_stale(inst->name); - /* Determine VM root directory */ - wcscpy_s(dir, MAX_PATH, inst->vhdx_path); - last_slash = wcsrchr(dir, L'\\'); - if (last_slash) *last_slash = L'\0'; - { - size_t dlen = wcslen(dir); - if (dlen >= 10 && _wcsicmp(dir + dlen - 10, L"\\snapshots") == 0) - dir[dlen - 10] = L'\0'; - } - /* Recursively remove the whole VM folder, including subdirectories (snapshots\ and the build's _vhdx_staging\); a file-only delete would leave those and orphan the dir. */ - remove_dir_recursive(dir); + if (!remove_dir_recursive(dir)) { + hr = HRESULT_FROM_WIN32(GetLastError()); + asb_log(L"Error: Cannot remove VM disk folder: %s (0x%08X).", dir, hr); + save_vm_list(); + return hr; + } /* Compact arrays */ EnterCriticalSection(&g_cs); @@ -3279,6 +3687,20 @@ ASB_API HRESULT asb_vm_delete(AsbVm vm) ASB_API int asb_vm_count(void) { return g_vm_count; } +ASB_API void asb_vm_disk_directory(AsbVm vm, wchar_t *out, size_t out_len) +{ + VmInstance *inst = vm_inst(vm); + wchar_t dir[MAX_PATH], *slash; + if (!out || !out_len) return; + out[0] = 0; + if (!inst || !get_vm_disk_root(inst->vhdx_path, dir)) return; + slash = wcsrchr(dir, L'\\'); + if (!slash) return; + if (slash == dir + 2 && dir[1] == L':') slash[1] = 0; + else *slash = 0; + wcsncpy_s(out, out_len, dir, _TRUNCATE); +} + ASB_API AsbVm asb_vm_get(int index) { if (index < 0 || index >= g_vm_count) return NULL; @@ -3622,31 +4044,35 @@ ASB_API const wchar_t *asb_template_os_type(int index) ASB_API HRESULT asb_template_delete(const wchar_t *name) { - wchar_t base_dir[MAX_PATH], tpl_dir[MAX_PATH]; - wchar_t json_path[MAX_PATH], vhdx_path[MAX_PATH]; - wchar_t vmgs[MAX_PATH], vmrs[MAX_PATH], res[MAX_PATH], snap_dir[MAX_PATH]; + wchar_t tpl_dir[MAX_PATH]; + DWORD attrs; + HRESULT hr; + int index, i; if (!name || name[0] == L'\0') return E_INVALIDARG; - - if (!GetEnvironmentVariableW(L"ProgramData", base_dir, MAX_PATH)) - wcscpy_s(base_dir, MAX_PATH, L"C:\\ProgramData"); - swprintf_s(tpl_dir, MAX_PATH, L"%s\\AppSandbox\\templates\\%s", base_dir, name); - - swprintf_s(json_path, MAX_PATH, L"%s\\%s.json", tpl_dir, name); - swprintf_s(vhdx_path, MAX_PATH, L"%s\\disk.vhdx", tpl_dir); - swprintf_s(vmgs, MAX_PATH, L"%s\\vm.vmgs", tpl_dir); - swprintf_s(vmrs, MAX_PATH, L"%s\\vm.vmrs", tpl_dir); - swprintf_s(res, MAX_PATH, L"%s\\resources.iso", tpl_dir); - DeleteFileW(json_path); - DeleteFileW(vhdx_path); - DeleteFileW(vmgs); DeleteFileW(vmrs); DeleteFileW(res); - - swprintf_s(snap_dir, MAX_PATH, L"%s\\snapshots", tpl_dir); - RemoveDirectoryW(snap_dir); - RemoveDirectoryW(tpl_dir); + for (index = 0; index < g_template_count; index++) + if (_wcsicmp(g_templates[index].name, name) == 0) break; + if (index == g_template_count) return HRESULT_FROM_WIN32(ERROR_NOT_FOUND); + if (!get_vm_disk_root(g_templates[index].vhdx_path, tpl_dir)) return E_INVALIDARG; + attrs = GetFileAttributesW(tpl_dir); + if (attrs == INVALID_FILE_ATTRIBUTES) { + hr = HRESULT_FROM_WIN32(GetLastError()); + asb_log(L"Error: Template disk folder is unavailable: %s (0x%08X).", tpl_dir, hr); + return hr; + } + if (!(attrs & FILE_ATTRIBUTE_DIRECTORY)) return HRESULT_FROM_WIN32(ERROR_DIRECTORY); + if (!remove_dir_recursive(tpl_dir)) { + hr = HRESULT_FROM_WIN32(GetLastError()); + asb_log(L"Error: Cannot remove template disk folder: %s (0x%08X).", tpl_dir, hr); + return hr; + } asb_log(L"Template \"%s\" deleted.", name); + for (i = index; i < g_template_count - 1; i++) + g_templates[i] = g_templates[i + 1]; + ZeroMemory(&g_templates[--g_template_count], sizeof(TemplateInfo)); scan_templates(); + save_vm_list(); return S_OK; } diff --git a/src/backend_win/asb_core.h b/src/backend_win/asb_core.h index d89d0cb..25c1f06 100644 --- a/src/backend_win/asb_core.h +++ b/src/backend_win/asb_core.h @@ -62,6 +62,7 @@ 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 */ + const wchar_t *disk_directory; /* parent for a new VM folder; NULL/empty = default */ } AsbVmConfig; /* ---- Snapshot/branch info (returned by query functions) ---- */ @@ -128,6 +129,20 @@ ASB_API void asb_set_vm_removed_callback(AsbVmRemovedCallback cb, void *user_dat Depending on config, this may start a background VHDX creation thread. */ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config); +ASB_API const wchar_t *asb_validate_username(const wchar_t *os_type, + const wchar_t *username, const wchar_t *vm_name, BOOL is_template); + +ASB_API const wchar_t *asb_validate_password(const wchar_t *os_type, + const wchar_t *password); + +ASB_API void asb_default_disk_directory(wchar_t *out, size_t out_len); + +ASB_API const wchar_t *asb_validate_disk_directory(const wchar_t *name, + const wchar_t *disk_directory, BOOL is_template); + +ASB_API const wchar_t *asb_validate_template_disk_size(const wchar_t *template_name, + DWORD hdd_gb); + /* Start a VM. snap_idx: snapshot index (>= 0), -2 for base, -1 for current disk. branch_idx: branch index (>= 0) to resume, or -1 to create a new branch. @@ -149,6 +164,7 @@ ASB_API HRESULT asb_vm_delete(AsbVm vm); ASB_API int asb_vm_count(void); ASB_API AsbVm asb_vm_get(int index); ASB_API AsbVm asb_vm_find(const wchar_t *name); +ASB_API void asb_vm_disk_directory(AsbVm vm, wchar_t *out, size_t out_len); ASB_API const wchar_t *asb_vm_name(AsbVm vm); ASB_API const wchar_t *asb_vm_os_type(AsbVm vm); diff --git a/src/backend_win/disk_util.c b/src/backend_win/disk_util.c index 74583c5..f690146 100644 --- a/src/backend_win/disk_util.c +++ b/src/backend_win/disk_util.c @@ -114,6 +114,102 @@ HRESULT vhdx_create_differencing(const wchar_t *child_path, const wchar_t *paren return S_OK; } +static DWORD vhdx_query_virtual_size(HANDLE vhd_handle, ULONGLONG *size_bytes) +{ + GET_VIRTUAL_DISK_INFO info; + ULONG info_size = sizeof(info); + DWORD result; + + ZeroMemory(&info, sizeof(info)); + info.Version = GET_VIRTUAL_DISK_INFO_SIZE; + result = GetVirtualDiskInformation(vhd_handle, &info_size, &info, NULL); + if (result == ERROR_SUCCESS) + *size_bytes = info.Size.VirtualSize; + return result; +} + +HRESULT vhdx_get_virtual_size(const wchar_t *path, ULONGLONG *size_bytes) +{ + VIRTUAL_STORAGE_TYPE storage_type; + OPEN_VIRTUAL_DISK_PARAMETERS open_params; + HANDLE vhd_handle = INVALID_HANDLE_VALUE; + DWORD result; + + if (!size_bytes) + return E_INVALIDARG; + *size_bytes = 0; + if (!path || !path[0]) + return E_INVALIDARG; + + storage_type.DeviceId = VIRTUAL_STORAGE_TYPE_DEVICE_VHDX; + storage_type.VendorId = VHDX_VENDOR_MS; + ZeroMemory(&open_params, sizeof(open_params)); + open_params.Version = OPEN_VIRTUAL_DISK_VERSION_2; + open_params.Version2.GetInfoOnly = TRUE; + open_params.Version2.ReadOnly = TRUE; + + result = OpenVirtualDisk(&storage_type, path, VIRTUAL_DISK_ACCESS_NONE, + OPEN_VIRTUAL_DISK_FLAG_NONE, &open_params, + &vhd_handle); + if (result != ERROR_SUCCESS) + return HRESULT_FROM_WIN32(result); + + result = vhdx_query_virtual_size(vhd_handle, size_bytes); + CloseHandle(vhd_handle); + return HRESULT_FROM_WIN32(result); +} + +HRESULT vhdx_grow(const wchar_t *path, ULONGLONG size_gb) +{ + const ULONGLONG bytes_per_gb = 1024ULL * 1024ULL * 1024ULL; + VIRTUAL_STORAGE_TYPE storage_type; + OPEN_VIRTUAL_DISK_PARAMETERS open_params; + RESIZE_VIRTUAL_DISK_PARAMETERS resize_params; + HANDLE vhd_handle = INVALID_HANDLE_VALUE; + ULONGLONG current_size = 0; + ULONGLONG requested_size; + DWORD result; + + if (!path || !path[0] || size_gb == 0 || + size_gb > (~(ULONGLONG)0) / bytes_per_gb) + return E_INVALIDARG; + requested_size = size_gb * bytes_per_gb; + + storage_type.DeviceId = VIRTUAL_STORAGE_TYPE_DEVICE_VHDX; + storage_type.VendorId = VHDX_VENDOR_MS; + ZeroMemory(&open_params, sizeof(open_params)); + open_params.Version = OPEN_VIRTUAL_DISK_VERSION_2; + open_params.Version2.GetInfoOnly = FALSE; + open_params.Version2.ReadOnly = FALSE; + + result = OpenVirtualDisk(&storage_type, path, VIRTUAL_DISK_ACCESS_NONE, + OPEN_VIRTUAL_DISK_FLAG_NONE, &open_params, + &vhd_handle); + if (result != ERROR_SUCCESS) + return HRESULT_FROM_WIN32(result); + + result = vhdx_query_virtual_size(vhd_handle, ¤t_size); + if (result == ERROR_SUCCESS && requested_size < current_size) { + CloseHandle(vhd_handle); + return E_INVALIDARG; + } + if (result == ERROR_SUCCESS && requested_size > current_size) { + ZeroMemory(&resize_params, sizeof(resize_params)); + resize_params.Version = RESIZE_VIRTUAL_DISK_VERSION_1; + resize_params.Version1.NewSize = requested_size; + result = ResizeVirtualDisk(vhd_handle, RESIZE_VIRTUAL_DISK_FLAG_NONE, + &resize_params, NULL); + if (result == ERROR_SUCCESS) { + result = vhdx_query_virtual_size(vhd_handle, ¤t_size); + if (result == ERROR_SUCCESS && current_size != requested_size) + result = ERROR_INVALID_DATA; + } + } + + CloseHandle(vhd_handle); + return HRESULT_FROM_WIN32(result); +} + HRESULT vhdx_merge(const wchar_t *child_path) { VIRTUAL_STORAGE_TYPE storage_type; @@ -194,6 +290,30 @@ static BOOL encode_unattend_password(const wchar_t *pass, wchar_t *b64_out, int return TRUE; } +static BOOL xml_escape_text(const wchar_t *text, wchar_t *out, size_t capacity) +{ + size_t used = 0; + if (!text || !out || !capacity) return FALSE; + while (*text) { + const wchar_t *replacement = NULL; + size_t count; + switch (*text) { + case L'&': replacement = L"&"; break; + case L'<': replacement = L"<"; break; + case L'>': replacement = L">"; break; + case L'"': replacement = L"""; break; + case L'\'': replacement = L"'"; break; + } + count = replacement ? wcslen(replacement) : 1; + if (used + count >= capacity) { out[0] = 0; return FALSE; } + memcpy(out + used, replacement ? replacement : text, count * sizeof(wchar_t)); + used += count; + text++; + } + out[used] = 0; + return TRUE; +} + /* Map language tag to LCID:KLID format for InputLocale */ static const wchar_t *lang_to_input_locale(const wchar_t *lang) { @@ -262,7 +382,10 @@ static BOOL generate_autounattend(const wchar_t *output_path, const wchar_t *lang) { FILE *f; + wchar_t user_xml[1024]; wchar_t comp_name[16]; + if (!xml_escape_text(admin_user, user_xml, ARRAYSIZE(user_xml))) + return FALSE; wcsncpy_s(comp_name, 16, vm_name, 15); if (_wfopen_s(&f, output_path, L"w,ccs=UTF-8") != 0 || !f) @@ -472,8 +595,8 @@ static BOOL generate_autounattend(const wchar_t *output_path, L" \n" L"\n", lang_to_input_locale(lang), lang, lang, lang, - admin_user, b64_password, - admin_user, b64_password); + user_xml, b64_password, + user_xml, b64_password); } fclose(f); @@ -825,7 +948,11 @@ static BOOL generate_unattend_instance(const wchar_t *output_path, const wchar_t *lang) { FILE *f; + BOOL written; + wchar_t user_xml[1024]; wchar_t comp_name[16]; + if (!xml_escape_text(admin_user, user_xml, ARRAYSIZE(user_xml))) + return FALSE; wcsncpy_s(comp_name, 16, vm_name, 15); if (_wfopen_s(&f, output_path, L"w,ccs=UTF-8") != 0 || !f) @@ -847,6 +974,7 @@ static BOOL generate_unattend_instance(const wchar_t *output_path, L" processorArchitecture=\"" ASB_UA_ARCH L"\"\n" L" publicKeyToken=\"31bf3856ad364e35\"\n" L" language=\"neutral\" versionScope=\"nonSxS\">\n" + L" true\n" L" \n" L" \n" L" 1\n" @@ -941,11 +1069,13 @@ static BOOL generate_unattend_instance(const wchar_t *output_path, L"\n", comp_name, lang_to_input_locale(lang), lang, lang, lang, - admin_user, b64_password, - admin_user, b64_password); + user_xml, b64_password, + user_xml, b64_password); - fclose(f); - return TRUE; + written = !ferror(f); + if (fclose(f) != 0) + written = FALSE; + return written; } /* Helper: copy agent exe to staging and write setup.cmd */ @@ -1337,8 +1467,12 @@ HRESULT iso_create_instance_resources(const wchar_t *iso_path, /* unattend.xml (NOT autounattend.xml — post-sysprep mini-setup uses this name) */ swprintf_s(file_path, MAX_PATH, L"%s\\unattend.xml", staging); - if (!generate_unattend_instance(file_path, vm_name, admin_user, b64_pass, lang)) - ui_log(L"Warning: failed to write instance unattend.xml"); + if (!generate_unattend_instance(file_path, vm_name, admin_user, b64_pass, lang)) { + ui_log(L"Error: failed to write instance unattend.xml"); + SecureZeroMemory(b64_pass, sizeof(b64_pass)); + remove_staging_dir(staging); + return E_FAIL; + } /* Agent exe + setup.cmd + SSH MSI */ stage_agent_and_setup(staging, res_dir, ssh_enabled); @@ -1862,10 +1996,11 @@ int generate_vhdx_manifest(const wchar_t *manifest_path, const wchar_t *rel_guest = ds->guest_path; if (GetFileAttributesW(ds->host_path) == INVALID_FILE_ATTRIBUTES) continue; - /* The GL/CL/Vulkan mapping-layer share is delivered by the guest - agent over Plan9 at runtime (after the GPU driver copy), not baked - into the image — skip it here. */ - if (_wcsicmp(ds->share_name, L"AppSandbox.GlLayers") == 0) + /* These shares need guest-side provisioning over Plan9. DRS is + mutable profile data: the agent seeds it with guest permissions + and excludes the host's runtime lock file. */ + if (_wcsicmp(ds->share_name, L"AppSandbox.GlLayers") == 0 || + _wcsicmp(ds->share_name, L"AppSandbox.NvidiaDrs") == 0) continue; /* Strip "C:" or any drive prefix — keep the leading backslash */ if (rel_guest[0] != L'\0' && rel_guest[1] == L':') diff --git a/src/backend_win/disk_util.h b/src/backend_win/disk_util.h index 096d3da..1b79c9c 100644 --- a/src/backend_win/disk_util.h +++ b/src/backend_win/disk_util.h @@ -20,6 +20,11 @@ HRESULT vhdx_create(const wchar_t *path, ULONGLONG size_gb); New writes go to child_path; parent_path remains unchanged. */ HRESULT vhdx_create_differencing(const wchar_t *child_path, const wchar_t *parent_path); +HRESULT vhdx_get_virtual_size(const wchar_t *path, ULONGLONG *size_bytes); + +/* Shrinking is rejected. Pass the child path, never the shared template path. */ +HRESULT vhdx_grow(const wchar_t *path, ULONGLONG size_gb); + /* Merge a differencing VHDX into its parent. After merge, child_path can be deleted. */ HRESULT vhdx_merge(const wchar_t *child_path); diff --git a/src/backend_win/gpu_enum.c b/src/backend_win/gpu_enum.c index b6fb4f1..12ba962 100644 --- a/src/backend_win/gpu_enum.c +++ b/src/backend_win/gpu_enum.c @@ -475,6 +475,62 @@ BOOL gpu_get_driver_shares(GpuList *gpu_list, GpuDriverShareList *out) return FALSE; } +BOOL gpu_append_nvidia_drs_share(const GpuList *gpu_list, GpuDriverShareList *list) +{ + wchar_t base[MAX_PATH], path[MAX_PATH]; + static const wchar_t suffix[] = L"\\NVIDIA Corporation\\Drs"; + GpuDriverShare *s; + DWORD n, attrs; + int i; + + if (!gpu_list || !list) return FALSE; + for (i = 0; i < gpu_list->count; i++) { + if (_wcsicmp(gpu_list->gpus[i].service, L"nvlddmkm") == 0) + break; + } + if (i == gpu_list->count) return FALSE; + + for (i = 0; i < list->count; i++) { + if (_wcsicmp(list->shares[i].share_name, L"AppSandbox.NvidiaDrs") == 0) + return TRUE; + } + if (list->count >= MAX_GPU_SHARES) { + ui_log(L"NVIDIA DRS: GPU share list is full, skipping."); + return FALSE; + } + + n = GetEnvironmentVariableW(L"ProgramData", base, MAX_PATH); + if (!n) + wcscpy_s(base, MAX_PATH, L"C:\\ProgramData"); + else if (n >= MAX_PATH) { + ui_log(L"NVIDIA DRS: ProgramData path is too long, skipping."); + return FALSE; + } + if (wcslen(base) + wcslen(suffix) >= MAX_PATH) { + ui_log(L"NVIDIA DRS: host directory path is too long, skipping."); + return FALSE; + } + swprintf_s(path, MAX_PATH, L"%s%s", base, suffix); + attrs = GetFileAttributesW(path); + if (attrs == INVALID_FILE_ATTRIBUTES) { + ui_log(L"NVIDIA DRS: host directory unavailable: %s (error %lu), skipping.", + path, GetLastError()); + return FALSE; + } + if (!(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + ui_log(L"NVIDIA DRS: host path is not a directory: %s, skipping.", path); + return FALSE; + } + + s = &list->shares[list->count]; + wcscpy_s(s->share_name, 128, L"AppSandbox.NvidiaDrs"); + wcscpy_s(s->host_path, MAX_PATH, path); + wcscpy_s(s->guest_path, MAX_PATH, L"C:\\ProgramData\\NVIDIA Corporation\\Drs"); + s->file_filter[0] = L'\0'; + list->count++; + return TRUE; +} + BOOL gpu_append_lxsslib_share(GpuDriverShareList *list) { wchar_t sys_dir[MAX_PATH]; diff --git a/src/backend_win/gpu_enum.h b/src/backend_win/gpu_enum.h index abce334..c7d9ce6 100644 --- a/src/backend_win/gpu_enum.h +++ b/src/backend_win/gpu_enum.h @@ -50,6 +50,9 @@ BOOL gpu_get_default_driver_path(GpuList *list, Returns TRUE if shares are available. */ BOOL gpu_get_driver_shares(GpuList *gpu_list, GpuDriverShareList *out); +/* Windows guests only; callers gate on guest OS and GPU mode. */ +BOOL gpu_append_nvidia_drs_share(const GpuList *gpu_list, GpuDriverShareList *list); + /* Append the host's lxss\lib directory as a synthetic Plan9 share entry (name "AppSandbox.HostLxssLib") to a share list. Linux guests need this — Microsoft's WSL installer stages NVIDIA's Linux userspace .so diff --git a/src/backend_win/hcs_vm.h b/src/backend_win/hcs_vm.h index d79753b..87c1595 100644 --- a/src/backend_win/hcs_vm.h +++ b/src/backend_win/hcs_vm.h @@ -40,7 +40,7 @@ typedef struct { int gpu_mode; /* GPU_NONE, GPU_DEFAULT, or GPU_MIRROR */ int network_mode; /* NET_NONE, NET_NAT, NET_EXTERNAL, or NET_INTERNAL */ wchar_t admin_user[128]; /* Guest local admin username */ - wchar_t admin_pass[128]; /* Guest local admin password */ + wchar_t admin_pass[256]; /* Guest local admin password */ wchar_t resources_iso_path[MAX_PATH]; /* ISO with autounattend + agent + helpers */ GpuDriverShareList gpu_shares; /* Plan9 shares for GPU driver files */ BOOL is_template; /* TRUE = template creation (no GPU/network) */ diff --git a/src/backend_win/snapshot.c b/src/backend_win/snapshot.c index b0a4b4f..3f13603 100644 --- a/src/backend_win/snapshot.c +++ b/src/backend_win/snapshot.c @@ -73,6 +73,11 @@ void snapshot_save(SnapshotTree *tree) int i, b; swprintf_s(path, MAX_PATH, L"%s\\tree.dat", tree->base_dir); + if (tree->count == 0 && tree->base_branch_count == 0) { + tree->base_vhdx[0] = L'\0'; + DeleteFileW(path); + return; + } if (_wfopen_s(&f, path, L"w, ccs=UTF-8") != 0 || !f) return; fwprintf(f, L"[Base]\n%s\n\n", tree->base_vhdx); @@ -221,6 +226,8 @@ void snapshot_init(SnapshotTree *tree, const wchar_t *base_dir) wcscpy_s(tree->base_dir, MAX_PATH, base_dir); CreateDirectoryW(base_dir, NULL); snapshot_load(tree); + if (tree->count == 0 && tree->base_branch_count == 0 && tree->base_vhdx[0] != L'\0') + snapshot_save(tree); } HRESULT snapshot_take(SnapshotTree *tree, VmInstance *instance, const wchar_t *name) @@ -335,6 +342,27 @@ HRESULT snapshot_select_branch(SnapshotTree *tree, VmInstance *instance, int ind return S_OK; } +HRESULT snapshot_ensure_writable(SnapshotTree *tree, VmInstance *instance) +{ + int i; + + if (!tree || !instance) return E_INVALIDARG; + if (instance->running) return E_NOT_VALID_STATE; + + if ((tree->count > 0 || tree->base_branch_count > 0) && + tree->base_vhdx[0] != L'\0' && + _wcsicmp(instance->vhdx_path, tree->base_vhdx) == 0) + return snapshot_new_branch(tree, instance, -2); + + for (i = 0; i < tree->count; i++) { + if (tree->nodes[i].valid && + _wcsicmp(instance->vhdx_path, tree->nodes[i].snap_vhdx) == 0) + return snapshot_new_branch(tree, instance, i); + } + + return S_FALSE; +} + HRESULT snapshot_delete(SnapshotTree *tree, VmInstance *instance, int index) { int i, b; diff --git a/src/backend_win/snapshot.h b/src/backend_win/snapshot.h index 77ad0f6..2ce61a2 100644 --- a/src/backend_win/snapshot.h +++ b/src/backend_win/snapshot.h @@ -79,6 +79,9 @@ HRESULT snapshot_new_branch(SnapshotTree *tree, VmInstance *instance, int index) Sets instance->vhdx_path accordingly. */ HRESULT snapshot_select_branch(SnapshotTree *tree, VmInstance *instance, int index, int branch_idx); +/* Fork a frozen disk before booting. S_FALSE if the selected disk is unchanged. */ +HRESULT snapshot_ensure_writable(SnapshotTree *tree, VmInstance *instance); + /* Delete a snapshot and all its branches. */ HRESULT snapshot_delete(SnapshotTree *tree, VmInstance *instance, int index); diff --git a/src/backend_win/vm_display_idd.c b/src/backend_win/vm_display_idd.c index 4f386da..4f95790 100644 --- a/src/backend_win/vm_display_idd.c +++ b/src/backend_win/vm_display_idd.c @@ -594,13 +594,8 @@ static BOOL idd_display_settings_load_or_create(const wchar_t *vhdx_path) * Keyboard hotkey capture * ================================================================== */ -/* Maximal reserved-hotkey set: keys the host shell would normally consume. - In Default mode these are withheld from the guest (host handles them); in - Transmit mode they are captured and forwarded to the guest instead. - alt_down must reflect whether Alt is currently held (LLKHF_ALTDOWN from the - low-level hook, or GetKeyState(VK_MENU) from the wndproc path). - Note: Ctrl+Alt+Del and Win+L are secure (SAS) sequences that no user-mode - hook can intercept — they always reach the host. */ +/* Host shortcuts withheld from the guest in Default mode. + alt_down reflects GetKeyState(VK_MENU) in the window procedure. */ static BOOL idd_is_reserved_hotkey(DWORD vk, BOOL alt_down) { switch (vk) { @@ -666,15 +661,13 @@ static LRESULT CALLBACK idd_ll_keyboard_proc(int code, WPARAM wp, LPARAM lp) VmDisplayIdd *d = t_hook_display; if (code == HC_ACTION && d && !d->stop && - d->input_focused && d->transmit_hotkeys) { + d->input_focused && d->transmit_hotkeys && + GetForegroundWindow() == d->hwnd) { const KBDLLHOOKSTRUCT *k = (const KBDLLHOOKSTRUCT *)lp; BOOL up = (wp == WM_KEYUP || wp == WM_SYSKEYUP); - BOOL alt = (k->flags & LLKHF_ALTDOWN) != 0; - if (idd_is_reserved_hotkey(k->vkCode, alt)) { - idd_forward_key(d, k->vkCode, k->scanCode, - (k->flags & LLKHF_EXTENDED) != 0, up); - return 1; /* swallow so the host shell doesn't act on it */ - } + idd_forward_key(d, k->vkCode, k->scanCode, + (k->flags & LLKHF_EXTENDED) != 0, up); + return 1; } return CallNextHookEx(NULL, code, wp, lp); } @@ -1348,7 +1341,9 @@ static HCURSOR create_cursor_from_bitmap(UINT width, UINT height, B,G,R = XOR color values. We extract A into a 1bpp monochrome hbmMask and BGR into hbmColor. */ UINT mask_row_bytes = (width + 7) / 8; - UINT mask_pitch = ((mask_row_bytes + 3) & ~3u); + /* CreateBitmap consumes WORD-aligned rows, unlike a DWORD-aligned + DIB. A 48-pixel cursor needs 6 bytes per mask row, not 8. */ + UINT mask_pitch = (mask_row_bytes + 1) & ~1u; void *color_bits = NULL; BYTE *mask_buf; UINT row, col; @@ -1462,9 +1457,9 @@ static HCURSOR create_cursor_from_bitmap(UINT width, UINT height, dst_pitch); } - /* AND mask, 1bpp, DWORD-aligned rows. Bit set = transparent. */ + /* AND mask, 1bpp, WORD-aligned for CreateBitmap. Bit set = transparent. */ mask_row_bytes = (width + 7) / 8; - mask_pitch = (mask_row_bytes + 3) & ~3u; + mask_pitch = (mask_row_bytes + 1) & ~1u; mask_buf = (BYTE *)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, (size_t)mask_pitch * height); if (!mask_buf) { @@ -2326,7 +2321,7 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) return 0; /* ---- Keyboard input forwarding (gated on window activation) ---- - In Transmit mode reserved hotkeys are captured by the low-level hook + In Transmit mode key events are captured by the low-level hook and never reach here. The mode check below covers Default mode: a reserved hotkey is neither forwarded to the guest nor consumed — it falls through to DefWindowProc so the host handles it normally (this diff --git a/tools/agent/agent.c b/tools/agent/agent.c index 59cef20..adfa174 100644 --- a/tools/agent/agent.c +++ b/tools/agent/agent.c @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -29,6 +30,7 @@ #include "../transport/asb_transport.h" #pragma comment(lib, "ws2_32.lib") +#pragma comment(lib, "iphlpapi.lib") #pragma comment(lib, "advapi32.lib") #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "cfgmgr32.lib") @@ -394,18 +396,28 @@ static BOOL logoff_session(DWORD session_id) /* Run a command hidden and wait for it (used for takeown/icacls). The cmd buffer must be writable (CreateProcessW may modify it). */ -static void run_quiet(wchar_t *cmd) +static DWORD run_quiet(wchar_t *cmd) { STARTUPINFOW si = { sizeof(si) }; PROCESS_INFORMATION pi = { 0 }; + DWORD result; si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_HIDE; if (CreateProcessW(NULL, cmd, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) { - WaitForSingleObject(pi.hProcess, 30000); + DWORD wait = WaitForSingleObject(pi.hProcess, 30000); + if (wait == WAIT_OBJECT_0) { + if (!GetExitCodeProcess(pi.hProcess, &result)) + result = GetLastError(); + } else { + result = wait == WAIT_TIMEOUT ? ERROR_TIMEOUT : GetLastError(); + } CloseHandle(pi.hThread); CloseHandle(pi.hProcess); + } else { + result = GetLastError(); } + return result; } /* Deploy the AppSandbox public key into administrators_authorized_keys -- the file @@ -542,6 +554,50 @@ static void gl_provision(const wchar_t *dir) agent_log("GL: provisioning complete."); } +/* NVIDIA's installer makes DRS writable by desktop and packaged applications. + Plan9 transfers file bytes, not the host ACL, so establish these permissions + on the guest before seeding the profile database. */ +static BOOL nvidia_drs_prepare(const wchar_t *dir) +{ + wchar_t parent[MAX_PATH], sys[MAX_PATH], cmd[MAX_PATH * 2 + 160]; + wchar_t *slash; + DWORD err, attrs; + + wcscpy_s(parent, MAX_PATH, dir); + slash = wcsrchr(parent, L'\\'); + if (!slash) return FALSE; + *slash = L'\0'; + if (!CreateDirectoryW(parent, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) { + agent_log("NVIDIA DRS: cannot create parent directory (%lu).", GetLastError()); + return FALSE; + } + if (!CreateDirectoryW(dir, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) { + agent_log("NVIDIA DRS: cannot create directory (%lu).", GetLastError()); + return FALSE; + } + attrs = GetFileAttributesW(dir); + if (attrs == INVALID_FILE_ATTRIBUTES || !(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + agent_log("NVIDIA DRS: destination is not an accessible directory."); + return FALSE; + } + if (!GetSystemDirectoryW(sys, MAX_PATH)) { + agent_log("NVIDIA DRS: GetSystemDirectory failed (%lu).", GetLastError()); + return FALSE; + } + + /* Grant Everyone and ALL APPLICATION PACKAGES inherited full control + within DRS. Numeric SIDs work with every guest language; preserve ACEs. */ + swprintf_s(cmd, MAX_PATH * 2 + 160, + L"\"%s\\icacls.exe\" \"%s\" /grant *S-1-1-0:(OI)(CI)F " + L"*S-1-15-2-1:(OI)(CI)F /T /C /Q", sys, dir); + err = run_quiet(cmd); + if (err != ERROR_SUCCESS) { + agent_log("NVIDIA DRS: setting guest permissions failed (%lu).", err); + return FALSE; + } + return TRUE; +} + static DWORD WINAPI gpu_copy_thread(LPVOID param) { GpuCopyState *state = (GpuCopyState *)param; @@ -577,8 +633,21 @@ static DWORD WINAPI gpu_copy_thread(LPVOID param) si->filter[0] ? " [filter: " : "", si->filter[0] ? si->filter : ""); - rc = p9_copy_share(50001, si->share_name, dest_wide, - si->filter[0] ? si->filter : NULL, &files); + if (strcmp(si->share_name, "AppSandbox.NvidiaDrs") == 0) { + /* This list is also sent after reconnects. Seed missing files; + existing profiles belong to the guest. The host's runtime + lock file is recreated locally by NVIDIA when needed. */ + const P9CopyOptions options = { TRUE, "nvdrswr.lk" }; + if (nvidia_drs_prepare(dest_wide)) + rc = p9_copy_share_ex(50001, si->share_name, dest_wide, + si->filter[0] ? si->filter : NULL, + &options, &files); + else + rc = P9_ERR_IO; + } else { + rc = p9_copy_share(50001, si->share_name, dest_wide, + si->filter[0] ? si->filter : NULL, &files); + } if (rc != P9_OK) { agent_log("GPU copy share '%s' failed (rc=%d).", si->share_name, rc); @@ -1807,6 +1876,38 @@ static void disable_hyperv_video(AsbConn *notify_sock) /* Forward declaration — defined after SSH proxy section */ static void handle_ssh_enable(AsbConn *client, const char *tag); +static ULONG primary_nic_index(void) +{ + ULONG buf_len = 15000; + ULONG index = 0; + + for (int attempt = 0; attempt < 3; attempt++) { + IP_ADAPTER_ADDRESSES *addrs, *cur; + ULONG ret; + + addrs = (IP_ADAPTER_ADDRESSES *)HeapAlloc(GetProcessHeap(), 0, buf_len); + if (!addrs) return 0; + ret = GetAdaptersAddresses(AF_INET, + GAA_FLAG_INCLUDE_ALL_INTERFACES | GAA_FLAG_SKIP_ANYCAST | + GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, + NULL, addrs, &buf_len); + if (ret == NO_ERROR) { + for (cur = addrs; cur; cur = cur->Next) { + if (cur->IfType != IF_TYPE_ETHERNET_CSMACD || !cur->IfIndex) + continue; + if (!index) index = cur->IfIndex; + if (cur->OperStatus == IfOperStatusUp) { + index = cur->IfIndex; + break; + } + } + } + HeapFree(GetProcessHeap(), 0, addrs); + if (ret != ERROR_BUFFER_OVERFLOW) break; + } + return index; +} + static void handle_client(AsbConn *client) { char buf[256]; @@ -1961,13 +2062,18 @@ static void handle_client(AsbConn *client) if (ip[0] && prefix[0] && gateway[0]) { wchar_t wcmd[512]; + wchar_t nic[32] = L"Ethernet"; + ULONG nic_index = primary_nic_index(); STARTUPINFOW si; PROCESS_INFORMATION pi; DWORD exit_code = 1; + if (nic_index) + swprintf_s(nic, 32, L"%lu", nic_index); + swprintf_s(wcmd, 512, - L"netsh interface ip set address \"Ethernet\" static %S %S %S", - ip, + L"netsh interface ip set address \"%s\" static %S %S %S", + nic, ip, /* Convert prefix length to subnet mask */ atoi(prefix) == 16 ? "255.255.0.0" : atoi(prefix) == 24 ? "255.255.255.0" : @@ -1995,7 +2101,7 @@ static void handle_client(AsbConn *client) PROCESS_INFORMATION pi2; swprintf_s(dns_cmd, 512, - L"netsh interface ip set dns \"Ethernet\" static %S", gateway); + L"netsh interface ip set dns \"%s\" static %S", nic, gateway); ZeroMemory(&si2, sizeof(si2)); si2.cb = sizeof(si2); ZeroMemory(&pi2, sizeof(pi2)); @@ -2007,7 +2113,7 @@ static void handle_client(AsbConn *client) } swprintf_s(dns_cmd, 512, - L"netsh interface ip add dns \"Ethernet\" 8.8.8.8 index=2"); + L"netsh interface ip add dns \"%s\" 8.8.8.8 index=2", nic); ZeroMemory(&si2, sizeof(si2)); si2.cb = sizeof(si2); ZeroMemory(&pi2, sizeof(pi2)); diff --git a/tools/agent/agent.vcxproj b/tools/agent/agent.vcxproj index 6674755..990bf2b 100644 --- a/tools/agent/agent.vcxproj +++ b/tools/agent/agent.vcxproj @@ -85,7 +85,7 @@ Console true - ws2_32.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) + ws2_32.lib;iphlpapi.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) @@ -101,7 +101,7 @@ Console true - ws2_32.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) + ws2_32.lib;iphlpapi.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) @@ -121,7 +121,7 @@ true true true - ws2_32.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) + ws2_32.lib;iphlpapi.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) if not exist "$(SolutionDir)release\resources\" mkdir "$(SolutionDir)release\resources\" @@ -146,7 +146,7 @@ xcopy /Y "$(TargetPath)" "$(SolutionDir)release\resources\" true true true - ws2_32.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) + ws2_32.lib;iphlpapi.lib;advapi32.lib;setupapi.lib;cfgmgr32.lib;wtsapi32.lib;userenv.lib;%(AdditionalDependencies) if not exist "$(SolutionDir)release\resources\" mkdir "$(SolutionDir)release\resources\" diff --git a/tools/agent/appsandbox-input.c b/tools/agent/appsandbox-input.c index 2aa0b3e..3a438ee 100644 --- a/tools/agent/appsandbox-input.c +++ b/tools/agent/appsandbox-input.c @@ -60,6 +60,20 @@ static void input_log(const char *fmt, ...) fclose(f); } +/* Per-monitor DPI awareness keeps GetSystemMetrics in the same physical pixel + * space as the host framebuffer coordinates normalized for SendInput. */ +static BOOL initialize_dpi_awareness(void) +{ + if (SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) + return TRUE; + /* A manifest or compatibility setting may already have set the process + * default. Input is received/injected on this thread, so override it here. */ + if (SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) + return TRUE; + input_log("Cannot enable per-monitor DPI awareness: %lu", GetLastError()); + return FALSE; +} + /* ---- Desktop switching ---- */ static void switch_to_input_desktop(void) @@ -196,6 +210,8 @@ int main(void) { AsbListener *l; + if (!initialize_dpi_awareness()) return 1; + input_log("Starting (PID=%lu, session=%lu).", GetCurrentProcessId(), WTSGetActiveConsoleSessionId()); diff --git a/tools/agent/p9copy.c b/tools/agent/p9copy.c index 380d93e..367e038 100644 --- a/tools/agent/p9copy.c +++ b/tools/agent/p9copy.c @@ -95,6 +95,7 @@ typedef struct { UINT32 next_fid; UINT32 msize; int files_copied; + P9CopyOptions options; } P9Session; /* ---- Logging ---- */ @@ -522,6 +523,27 @@ static void utf8_to_wide(const char *utf8, wchar_t *wide, int wide_max) MultiByteToWideChar(CP_UTF8, 0, utf8, -1, wide, wide_max); } +static BOOL skip_excluded_file(P9Session *s, const char *name) +{ + if (s->options.exclude_file && s->options.exclude_file[0] && + _stricmp(name, s->options.exclude_file) == 0) { + P9LOG("9P skip (excluded): %s", name); + return TRUE; + } + return FALSE; +} + +static BOOL skip_preserved_file(P9Session *s, const char *name, + const wchar_t *local_path) +{ + if (s->options.keep_existing && + GetFileAttributesW(local_path) != INVALID_FILE_ATTRIBUTES) { + P9LOG("9P skip (preserve existing): %s", name); + return TRUE; + } + return FALSE; +} + /* Copy a single file from the 9P share to local disk. */ static BOOL copy_file(P9Session *s, UINT32 parent_fid, const char *name, const wchar_t *local_path, UINT64 file_size) @@ -531,8 +553,12 @@ static BOOL copy_file(P9Session *s, UINT32 parent_fid, const char *name, UINT64 offset = 0; HANDLE hfile; BOOL ok = TRUE; + BOOL created; const char *names[1]; + if (skip_excluded_file(s, name) || skip_preserved_file(s, name, local_path)) + return TRUE; + /* Check if file exists and same size — skip if so */ { WIN32_FILE_ATTRIBUTE_DATA attr; @@ -558,23 +584,38 @@ static BOOL copy_file(P9Session *s, UINT32 parent_fid, const char *name, iounit = s->msize - 24; hfile = CreateFileW(local_path, GENERIC_WRITE, 0, NULL, - CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + s->options.keep_existing ? CREATE_NEW : CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, NULL); if (hfile == INVALID_HANDLE_VALUE) { - P9LOG("9P cannot create file: %ls (error %lu)", local_path, GetLastError()); + DWORD err = GetLastError(); p9_clunk(s, fid); + if (s->options.keep_existing && + (err == ERROR_FILE_EXISTS || err == ERROR_ALREADY_EXISTS)) { + P9LOG("9P skip (destination created during copy): %s", name); + return TRUE; + } + P9LOG("9P cannot create file: %ls (error %lu)", local_path, err); return FALSE; } + created = s->options.keep_existing || GetLastError() != ERROR_ALREADY_EXISTS; while (offset < file_size) { BYTE *data; UINT32 nread; + UINT64 remaining = file_size - offset; + UINT32 count = remaining < iounit ? (UINT32)remaining : iounit; DWORD written; - if (!p9_read(s, fid, offset, iounit, &data, &nread)) { + if (!p9_read(s, fid, offset, count, &data, &nread)) { + ok = FALSE; + break; + } + if (nread == 0) { + P9LOG("9P unexpected end of file: %s (%llu/%llu bytes)", name, + (unsigned long long)offset, (unsigned long long)file_size); ok = FALSE; break; } - if (nread == 0) break; if (!WriteFile(hfile, data, nread, &written, NULL) || written != nread) { P9LOG("9P write failed for %s (error %lu)", name, GetLastError()); @@ -587,6 +628,10 @@ static BOOL copy_file(P9Session *s, UINT32 parent_fid, const char *name, CloseHandle(hfile); p9_clunk(s, fid); + if (!ok && created && !DeleteFileW(local_path)) + P9LOG("9P cannot remove incomplete file: %ls (error %lu)", + local_path, GetLastError()); + if (ok) { s->files_copied++; P9LOG("9P copied: %s (%llu bytes)", name, (unsigned long long)file_size); @@ -670,21 +715,33 @@ static BOOL copy_dir_contents(P9Session *s, UINT32 dir_fid, const wchar_t *local P9LOG("9P dir: %s", entry_name); if (p9_walk(s, dir_fid, child_fid, names, 1, NULL)) { - copy_dir_contents(s, child_fid, child_path); + if (!copy_dir_contents(s, child_fid, child_path)) + ok = FALSE; p9_clunk(s, child_fid); + } else { + ok = FALSE; } } else { UINT32 child_fid = alloc_fid(s); const char *names[1]; names[0] = entry_name; + /* Skip excluded/preserved files before contacting the source, + including files that may be locked on the host. */ + if (skip_excluded_file(s, entry_name) || + skip_preserved_file(s, entry_name, child_path)) + continue; + if (p9_walk(s, dir_fid, child_fid, names, 1, NULL)) { UINT64 fsize = 0; UINT32 fmode = 0; - p9_getattr(s, child_fid, &fmode, &fsize); + BOOL got_attr = p9_getattr(s, child_fid, &fmode, &fsize); p9_clunk(s, child_fid); - copy_file(s, dir_fid, entry_name, child_path, fsize); + if (!got_attr || !copy_file(s, dir_fid, entry_name, child_path, fsize)) + ok = FALSE; + } else { + ok = FALSE; } } } @@ -717,6 +774,13 @@ static BOOL copy_filtered_files(P9Session *s, UINT32 root_fid, names[0] = tok; + utf8_to_wide(tok, wide_name, 512); + swprintf_s(local_path, MAX_PATH, L"%s\\%s", local_dir, wide_name); + if (skip_excluded_file(s, tok) || skip_preserved_file(s, tok, local_path)) { + tok = strtok_s(NULL, ";", &ctx); + continue; + } + if (!p9_walk(s, root_fid, fid, names, 1, NULL)) { P9LOG("9P walk failed for '%s'", tok); failed++; @@ -724,12 +788,14 @@ static BOOL copy_filtered_files(P9Session *s, UINT32 root_fid, continue; } - p9_getattr(s, fid, &fmode, &fsize); + if (!p9_getattr(s, fid, &fmode, &fsize)) { + p9_clunk(s, fid); + failed++; + tok = strtok_s(NULL, ";", &ctx); + continue; + } p9_clunk(s, fid); - utf8_to_wide(tok, wide_name, 512); - swprintf_s(local_path, MAX_PATH, L"%s\\%s", local_dir, wide_name); - if (!copy_file(s, root_fid, tok, local_path, fsize)) failed++; @@ -773,6 +839,13 @@ static SOCKET connect_hvsocket(UINT32 port) int p9_copy_share(UINT32 port, const char *share_name, const wchar_t *local_dir, const char *filter, int *files_copied) +{ + return p9_copy_share_ex(port, share_name, local_dir, filter, NULL, files_copied); +} + +int p9_copy_share_ex(UINT32 port, const char *share_name, + const wchar_t *local_dir, const char *filter, + const P9CopyOptions *options, int *files_copied) { P9Session s; UINT32 root_fid; @@ -784,6 +857,8 @@ int p9_copy_share(UINT32 port, const char *share_name, s.next_fid = 10; s.msize = P9_MSIZE; s.files_copied = 0; + if (options) + s.options = *options; P9LOG("9P connecting to share '%s' on port %u...", share_name, port); diff --git a/tools/agent/p9copy.h b/tools/agent/p9copy.h index 6b945a9..6367068 100644 --- a/tools/agent/p9copy.h +++ b/tools/agent/p9copy.h @@ -16,6 +16,11 @@ typedef void (*P9LogFn)(const char *fmt, ...); /* Set the log function used by p9copy. If not set, no logging occurs. */ void p9_set_log(P9LogFn fn); +typedef struct { + BOOL keep_existing; + const char *exclude_file; /* Optional case-insensitive leaf filename to skip. */ +} P9CopyOptions; + /* Copy a single Plan9 share to a local directory via HvSocket. Connects to the host via AF_HYPERV on the given port, attaches to share_name, and recursively copies all files to local_dir. @@ -28,4 +33,10 @@ int p9_copy_share(UINT32 port, const char *share_name, const wchar_t *local_dir, const char *filter, int *files_copied); +/* NULL options uses size-based skipping. Options and exclude_file must remain + valid until this synchronous call returns. */ +int p9_copy_share_ex(UINT32 port, const char *share_name, + const wchar_t *local_dir, const char *filter, + const P9CopyOptions *options, int *files_copied); + #endif /* P9COPY_H */ diff --git a/tools/iso-patch-mac/iso-patch-mac.m b/tools/iso-patch-mac/iso-patch-mac.m index a9ca006..09e3091 100644 --- a/tools/iso-patch-mac/iso-patch-mac.m +++ b/tools/iso-patch-mac/iso-patch-mac.m @@ -446,6 +446,58 @@ static BOOL add_user_to_admin_group(NSString *mountPt, NSString *shortname, return write_plist(admin, adminPath, 0, 80, 0600, errOut); } +/* Offline account creation bypasses Open Directory's collision checks. */ +static BOOL new_user_paths_available(NSString *mountPt, NSString *shortname, + NSString **errOut) { + NSFileManager *fm = [NSFileManager defaultManager]; + NSArray *roots = @[@"private/var/db/dslocal/nodes/Default/users", + @"var/db/dslocal/nodes/Default/users", @"Users"]; + for (NSString *relativeRoot in roots) { + NSString *root = [mountPt stringByAppendingPathComponent:relativeRoot]; + struct stat st; + if (lstat(root.fileSystemRepresentation, &st) != 0) { + if (errno == ENOENT) continue; + if (errOut) *errOut = [NSString stringWithFormat: + @"Cannot check guest account directory %@: %s", relativeRoot, strerror(errno)]; + return NO; + } + NSError *error = nil; + NSArray *entries = [fm contentsOfDirectoryAtPath:root error:&error]; + if (!entries) { + if (errOut) *errOut = [NSString stringWithFormat: + @"Cannot check guest account directory %@: %@", relativeRoot, error.localizedDescription]; + return NO; + } + BOOL homes = [relativeRoot isEqualToString:@"Users"]; + for (NSString *entry in entries) { + if (!homes && [entry.pathExtension caseInsensitiveCompare:@"plist"] != NSOrderedSame) + continue; + NSString *entryName = homes ? entry : entry.stringByDeletingPathExtension; + BOOL collision = [entryName caseInsensitiveCompare:shortname] == NSOrderedSame; + if (!homes && !collision) { + NSDictionary *record = [NSDictionary dictionaryWithContentsOfFile: + [root stringByAppendingPathComponent:entry]]; + id names = record[@"name"]; + if ([names isKindOfClass:[NSArray class]]) { + for (id alias in names) { + if ([alias isKindOfClass:[NSString class]] && + [alias caseInsensitiveCompare:shortname] == NSOrderedSame) { + collision = YES; + break; + } + } + } + } + if (collision) { + if (errOut) *errOut = [NSString stringWithFormat: + @"Username '%@' already exists in the guest account or home directories.", shortname]; + return NO; + } + } + } + return YES; +} + static BOOL inject_user(NSString *mountPt, NSString *shortname, NSString *realname, int uid, NSData *shd, @@ -456,6 +508,7 @@ static BOOL inject_user(NSString *mountPt, if (errOut) *errOut = @"missing ShadowHashData"; return NO; } + if (!new_user_paths_available(mountPt, shortname, errOut)) return NO; NSString *uuidStr = [[NSUUID UUID] UUIDString]; NSDictionary *userPlist = @{ @@ -1142,11 +1195,12 @@ static int cmd_fetch_ipsw(int argc, char **argv) { static VZVirtualMachine *g_installVM = nil; static int cmd_install(int argc, char **argv) { - NSString *name = nil, *vmDir = nil, *ipsw = nil; + NSString *name = nil, *vmDir = nil, *ipsw = nil, *diskPath = nil; int ramMb = 0, cpus = 0, diskGb = 0; for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "--name") == 0 && i + 1 < argc) name = @(argv[++i]); else if (strcmp(argv[i], "--vm-dir") == 0 && i + 1 < argc) vmDir = @(argv[++i]); + else if (strcmp(argv[i], "--disk-path") == 0 && i + 1 < argc) diskPath = @(argv[++i]); else if (strcmp(argv[i], "--ipsw") == 0 && i + 1 < argc) ipsw = @(argv[++i]); else if (strcmp(argv[i], "--ram-mb") == 0 && i + 1 < argc) ramMb = atoi(argv[++i]); else if (strcmp(argv[i], "--cpus") == 0 && i + 1 < argc) cpus = atoi(argv[++i]); @@ -1165,7 +1219,7 @@ static int cmd_install(int argc, char **argv) { return 3; } - NSString *diskPath = [vmDir stringByAppendingPathComponent:@"disk.img"]; + if (!diskPath) diskPath = [vmDir stringByAppendingPathComponent:@"disk.img"]; NSString *auxPath = [vmDir stringByAppendingPathComponent:@"aux.img"]; NSString *hwPath = [vmDir stringByAppendingPathComponent:@"hardware.bin"]; NSString *midPath = [vmDir stringByAppendingPathComponent:@"machine-id.bin"]; diff --git a/tools/iso-patch/iso-patch.c b/tools/iso-patch/iso-patch.c index a85da68..86d5f4d 100644 --- a/tools/iso-patch/iso-patch.c +++ b/tools/iso-patch/iso-patch.c @@ -847,7 +847,7 @@ static int do_to_vhdx(const wchar_t *iso_path_arg, int image_index, int size_gb, const char *detected_lang = "en-US"; /* fallback */ GetSystemDirectoryW(sys_dir, MAX_PATH); swprintf_s(dism_cmd, 1024, - L"%s\\dism.exe /Get-WimInfo /WimFile:\"%s\" /Index:%d", + L"%s\\dism.exe /English /Get-WimInfo /WimFile:\"%s\" /Index:%d", sys_dir, wim_path, image_index); if (run_command_capture(dism_cmd, dism_output, sizeof(dism_output)) == 0) { /* Look for "xx-YY (Default)" pattern in DISM output */ diff --git a/tools/iso-patch/ubuntu_vhdx.c b/tools/iso-patch/ubuntu_vhdx.c index 696650f..d45f0a1 100644 --- a/tools/iso-patch/ubuntu_vhdx.c +++ b/tools/iso-patch/ubuntu_vhdx.c @@ -1220,6 +1220,8 @@ static void plant_firstboot_service(ext4_writer_t *ew) " HOME_DIR=$(getent passwd \"$ASB_USER\" | cut -d: -f6)\n" " install -d -o \"$ASB_USER\" -g \"$ASB_USER\" \"$HOME_DIR/.config\" 2>/dev/null || true\n" " su \"$ASB_USER\" -c \"touch '$HOME_DIR/.config/gnome-initial-setup-done'\" 2>/dev/null || true\n" + " su \"$ASB_USER\" -c \"dbus-run-session -- gsettings set org.gnome.desktop.input-sources sources \\\"[('xkb', '$KB')]\\\"\" \\\n" + " && echo \"OK: GNOME keyboard=$KB\" || echo \"WARN: GNOME keyboard setup failed\"\n" "fi\n" "rm -f /etc/xdg/autostart/gnome-initial-setup-first-login.desktop || true\n" "\n" diff --git a/tools/provision/win_provision.c b/tools/provision/win_provision.c index 652b654..6bcba6f 100644 --- a/tools/provision/win_provision.c +++ b/tools/provision/win_provision.c @@ -106,9 +106,34 @@ static const char *input_locale(const char *lang) { return "0409:00000409"; } +static int xml_escape_text(const char *text, char *out, size_t capacity) { + size_t used = 0; + if (!text || !out || !capacity) return 0; + while (*text) { + const char *replacement = NULL; + size_t count; + switch (*text) { + case '&': replacement = "&"; break; + case '<': replacement = "<"; break; + case '>': replacement = ">"; break; + case '"': replacement = """; break; + case '\'': replacement = "'"; break; + } + count = replacement ? strlen(replacement) : 1; + if (used + count >= capacity) { out[0] = 0; return 0; } + memcpy(out + used, replacement ? replacement : text, count); + used += count; + text++; + } + out[used] = 0; + return 1; +} + int asb_provision_unattend(FILE *f, const char *vm_name, const char *user, const char *pass, const char *arch, int test_mode, int is_arm64, const char *lang) { if (!f) return -1; + char user_xml[1024]; + if (!xml_escape_text(user, user_xml, sizeof user_xml)) return -1; char comp[64]; /* up to 15 code points (NetBIOS), each <=4 UTF-8 bytes, + NUL */ { const unsigned char *s = (const unsigned char *)vm_name; @@ -231,7 +256,7 @@ int asb_provision_unattend(FILE *f, const char *vm_name, const char *user, const " \n" " \n" "\n", - arch, loc, lang, lang, lang, arch, user, b64, user, b64); + arch, loc, lang, lang, lang, arch, user_xml, b64, user_xml, b64); return 0; } diff --git a/web/app.js b/web/app.js index ece3a1d..75d63af 100644 --- a/web/app.js +++ b/web/app.js @@ -10,6 +10,11 @@ let editingCell = null; /* {row, col, element} */ let pendingConfirm = null; /* {resolve} */ let minSizeReported = false; let lastHostInfo = null; +let diskSpaceInfo = null; +let diskSpaceRequestId = 0; +let diskSpaceTimer = null; +let diskSpacePending = false; +let diskDirectoryBeforeEdit = null; let rowCache = {}; /* vm.name -> — persistent rows so the status spinner doesn't reset on every update */ let rowSigCache = {}; /* vm.name -> last render signature; skip rebuild when unchanged */ @@ -87,22 +92,16 @@ if (hostBridge.isMac) { }); } -/* 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. */ +/* Apply Create-modal visibility rules for the currently selected OS type. */ function applyOsTypeUI() { + var modal = document.getElementById('create-vm-overlay'); 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 needsWindows = document.querySelectorAll('.needs-windows'); - var needsLinuxVersion = document.querySelectorAll('.needs-linux-version'); + var winOnly = modal.querySelectorAll('.win-only'); + var needsIso = modal.querySelectorAll('.needs-iso'); + var needsWindows = modal.querySelectorAll('.needs-windows'); + var needsLinuxVersion = modal.querySelectorAll('.needs-linux-version'); /* .win-only = template/snapshot features that exist only on a Windows *host*; never shown on a Mac host, even for a Windows guest. */ for (var i = 0; i < winOnly.length; i++) @@ -142,6 +141,8 @@ window.onHostMessage = function(msg) { case 'log': appendLog(msg.message); break; case 'hostInfo': updateHostInfo(msg); break; case 'browseResult': onBrowseResult(msg.path); break; + case 'diskDirectoryBrowseResult': onDiskDirectoryBrowseResult(msg.path); break; + case 'diskSpace': onDiskSpace(msg); break; case 'confirmResult': if (pendingConfirm) pendingConfirm.resolve(msg.confirmed); break; case 'adapters': populateAdapters(msg.adapters, msg.defaultIndex); break; case 'templates': populateTemplates(msg.templates); break; @@ -201,14 +202,66 @@ function onVmStateChanged(msg) { function updateHostInfo(info) { if (!info) return; + var previousDefault = lastHostInfo && lastHostInfo.defaultDiskDirectory; lastHostInfo = info; + var diskDirectory = document.getElementById('disk-directory'); + if (diskDirectoryBeforeEdit === null && info.defaultDiskDirectory && + (!diskDirectory.value || diskDirectory.value === previousDefault)) + diskDirectory.value = info.defaultDiskDirectory; 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'; + if (previousDefault !== info.defaultDiskDirectory) refreshDiskSpaceInfo(); + else { + updateDiskSpaceInfo(); + if (document.getElementById('create-vm-overlay').classList.contains('active') && !diskSpacePending) + refreshDiskSpaceInfo(true); + } +} + +function selectedDiskDirectory() { + return document.getElementById('disk-directory').value.trim() || + (lastHostInfo && lastHostInfo.defaultDiskDirectory) || ''; +} + +function updateDiskSpaceInfo() { + if (!lastHostInfo) return; + var path = selectedDiskDirectory(); + var free = 'Checking...'; + if (!path || validateDiskDirectory(path)) free = 'Unavailable'; + else if (diskSpaceInfo && diskSpaceInfo.path === path) + free = diskSpaceInfo.freeGb >= 0 ? diskSpaceInfo.freeGb + ' GB' : 'Unavailable'; + document.getElementById('host-hdd').textContent = + 'Free: ' + free + ' | VMs allocated: ' + lastHostInfo.vmHddGb + ' GB'; + document.getElementById('disk-location-space').textContent = 'Free: ' + free; + document.getElementById('btn-disk-location').title = 'HDD Location: ' + path; +} + +function refreshDiskSpaceInfo(keepPrevious) { + clearTimeout(diskSpaceTimer); + diskSpaceTimer = null; + var path = selectedDiskDirectory(); + var requestId = ++diskSpaceRequestId; + if (!keepPrevious) diskSpaceInfo = null; + diskSpacePending = !!path && !validateDiskDirectory(path); + updateDiskSpaceInfo(); + if (!diskSpacePending) return; + diskSpaceTimer = setTimeout(function() { + diskSpaceTimer = null; + sendCmd('getDiskSpace', { path: path, requestId: requestId }); + }, 200); +} + +function onDiskSpace(msg) { + if (msg.requestId !== diskSpaceRequestId || msg.path !== selectedDiskDirectory()) return; + diskSpacePending = false; + diskSpaceInfo = { + path: msg.path, + freeGb: typeof msg.freeGb === 'number' && Number.isFinite(msg.freeGb) ? msg.freeGb : -1 + }; + updateDiskSpaceInfo(); } /* ---- Adapters ---- */ @@ -342,6 +395,72 @@ function onBrowseResult(path) { } } +function onDiskDirectoryBrowseResult(path) { + if (!path || diskDirectoryBeforeEdit === null) return; + document.getElementById('disk-directory').value = path; + revalidateDiskDirectory(); +} + +function validateDiskDirectory(path) { + if (!path) return null; /* Empty uses the host's default. */ + if (/[\x00-\x1f\x7f]/.test(path)) return 'Disk folder cannot contain control characters.'; + if (hostBridge.isMac) { + if (path[0] !== '/') return 'Disk folder must be an absolute path.'; + } else { + if (!/^(?:[a-zA-Z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)/.test(path)) + return 'Disk folder must be an absolute path.'; + if (/["<>|?*]/.test(path) || /:/.test(path.slice(2))) + return 'Disk folder contains invalid characters.'; + } + return null; +} + +function revalidateDiskDirectory() { + var path = document.getElementById('disk-directory').value.trim(); + var error = validateDiskDirectory(path); + document.getElementById('disk-directory-warn').textContent = error || ''; + document.getElementById('btn-save-disk-location').disabled = !!error; + refreshDiskSpaceInfo(); +} +document.getElementById('disk-directory').addEventListener('input', revalidateDiskDirectory); + +function openDiskLocationModal() { + var createOverlay = document.getElementById('create-vm-overlay'); + if (!createOverlay.classList.contains('active') || diskDirectoryBeforeEdit !== null) return; + diskDirectoryBeforeEdit = document.getElementById('disk-directory').value; + document.getElementById('disk-location-overlay').classList.add('active'); + createOverlay.inert = true; + revalidateDiskDirectory(); + document.getElementById('disk-directory').focus(); + document.getElementById('disk-directory').select(); +} + +function closeDiskLocationModal(save) { + if (diskDirectoryBeforeEdit === null) return; + var input = document.getElementById('disk-directory'); + if (save && validateDiskDirectory(input.value.trim())) { + revalidateDiskDirectory(); + input.focus(); + return; + } + var previousPath = selectedDiskDirectory(); + input.value = save ? input.value.trim() : diskDirectoryBeforeEdit; + diskDirectoryBeforeEdit = null; + document.getElementById('disk-location-overlay').classList.remove('active'); + document.getElementById('create-vm-overlay').inert = false; + if (selectedDiskDirectory() !== previousPath) revalidateDiskDirectory(); + document.getElementById('btn-disk-location').focus(); +} + +let diskLocationBackdropPress = false; +document.getElementById('disk-location-overlay').addEventListener('mousedown', function(e) { + diskLocationBackdropPress = (e.target === this); +}); +document.getElementById('disk-location-overlay').addEventListener('click', function(e) { + if (e.target === this && diskLocationBackdropPress) closeDiskLocationModal(false); + diskLocationBackdropPress = false; +}); + /* ---- Create buttons state ---- */ function updateCreateButtons() { @@ -377,7 +496,10 @@ 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); +document.getElementById('vm-name').addEventListener('input', function() { + revalidateVmName(); + revalidateUsername(); +}); function revalidateUsername() { var u = document.getElementById('admin-user').value.trim(); @@ -440,6 +562,9 @@ function gatherConfig() { name: document.getElementById('vm-name').value.trim(), osType: osType, imagePath: imagePath, + diskDirectory: document.getElementById('disk-directory').value.trim() === + (lastHostInfo && lastHostInfo.defaultDiskDirectory) ? '' : + document.getElementById('disk-directory').value.trim(), templateName: document.getElementById('template-select').value, hddGb: parseInt(document.getElementById('hdd-size').value) || 64, ramMb: alignRamMb(parseInt(document.getElementById('ram-size').value) || 16384), @@ -501,48 +626,84 @@ function validateVmName(name) { /* 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) { +function validateUsername(name, isTemplate) { + if (name === undefined) return 'Username is required.'; + if (typeof name !== 'string') return 'Username must be a string.'; + name = name.trim(); if (!name) return 'Username is required.'; + if (name.indexOf('\u0000') >= 0) return 'Username cannot contain NUL characters.'; + var bytes; + try { + bytes = unescape(encodeURIComponent(name)).length; + } catch (e) { + return 'Username contains invalid Unicode.'; + } 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. */ + /* Ubuntu 26.04's installer grammar and reserved-usernames list. + https://github.com/canonical/subiquity/tree/26.04 */ 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 'Linux username: lowercase letters, digits, _ and - only; start with a letter or _.'; + var linuxReserved = ( + 'root daemon bin sys sync games man lp mail news uucp proxy www-data backup list irc gnats nobody ' + + 'adm tty disk kmem dialout fax voice cdrom floppy tape sudo audio dip operator src shadow utmp video ' + + 'sasl plugdev staff users nogroup netplan ftn mysql tac-plus alias qmail qmaild qmails qmailr qmailq ' + + 'qmaill qmailp asterisk vpopmail vchkpw slurm hacluster haclient grsec-tpe grsec-sock-all grsec-sock-clt ' + + 'grsec-sock-srv grsec-proc ceph opensrf libvirt-qemu admin Debian-exim bind crontab cupsys dcc dhcp ' + + 'dictd dnsmasq dovecot fetchmail firebird ftp fuse gdm haldaemon hplilp identd input jwhois klog kvm ' + + 'lpadmin maas messagebus mythtv netdev powerdev radvd render saned sbuild scanner sgx slocate ssh ' + + 'sshd ssl-cert sslwrap statd syslog telnetd tftpd' + ).split(' '); + if (linuxReserved.indexOf(name) >= 0) return 'Username is a reserved name.'; + return null; + } + if (osType === 'macOS') { + if (bytes > 63) return 'Username is too long (max 63 UTF-8 bytes in AppSandbox).'; + if (/\s/.test(name)) return 'Username cannot contain spaces (macOS short account name).'; + if (/[\x00-\x1f\x7f\ufffe\uffff/\\:]/.test(name)) return 'Username contains invalid characters.'; + if (name === '.' || name === '..') return 'Username cannot be . or .. (macOS short account name).'; + if (['root', 'daemon', 'nobody', 'guest', 'shared'].indexOf(name.toLowerCase()) >= 0) + return 'Username is a reserved name.'; 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 (/[\x00-\x1f\ufffe\uffff"\\/\[\]:;|=,+*?<>%@]/.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', + var reserved = ['NONE','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.'; + if (!isTemplate && + name.toLowerCase() === document.getElementById('vm-name').value.trim().toLowerCase()) + return 'Username cannot match the VM name (Windows computer 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) { + if (!pass) return 'Password is required.'; + if (pass.indexOf('\u0000') >= 0) return 'Password cannot contain NUL characters.'; + var bytes; + try { + bytes = unescape(encodeURIComponent(pass)).length; + } catch (e) { + return 'Password contains invalid Unicode.'; + } 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 (Array.from(pass).length < 6) + return 'Password must be at least 6 characters (Ubuntu minimum).'; if (bytes > 255) return 'Password is too long (max 255 bytes).'; - return null; + } else if (osType === 'macOS') { + if (Array.from(pass).length < 4) + return 'Password must be at least 4 characters (macOS minimum).'; + if (bytes > 127) return 'Password is too long (max 127 UTF-8 bytes in AppSandbox).'; + } else if (pass.length > 127) { + return 'Password is too long (max 127 characters for Windows).'; } - /* macOS / Windows: no additional constraints today. */ return null; } @@ -550,6 +711,8 @@ function onCreateVm() { var cfg = gatherConfig(); var nameErr = validateVmName(cfg.name); if (nameErr) { sendCmd('log', { message: nameErr }); return; } + var diskErr = validateDiskDirectory(cfg.diskDirectory); + if (diskErr) { sendCmd('log', { message: diskErr }); return; } var userErr = validateUsername(cfg.adminUser); if (userErr) { sendCmd('log', { message: userErr }); return; } var passErr = validatePassword(cfg.adminPass); @@ -567,6 +730,12 @@ function onCreateTemplate() { var cfg = gatherConfig(); var nameErr = validateVmName(cfg.name); if (nameErr) { sendCmd('log', { message: nameErr }); return; } + var diskErr = validateDiskDirectory(cfg.diskDirectory); + if (diskErr) { sendCmd('log', { message: diskErr }); return; } + var userErr = validateUsername(cfg.adminUser, true); + 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; @@ -580,9 +749,12 @@ function onCreateTemplate() { /* ---- Create Sandbox modal ---- */ function openCreateModal() { + closeDiskLocationModal(false); /* Reset to defaults every time the modal opens */ document.getElementById('vm-name').value = 'MyAppSandbox'; document.getElementById('image-path').value = ''; + document.getElementById('disk-directory').value = (lastHostInfo && lastHostInfo.defaultDiskDirectory) || ''; + revalidateDiskDirectory(); selectTemplate('', templateDefaultLabel()); document.getElementById('hdd-size').value = 64; document.getElementById('gpu-mode').value = '1'; @@ -610,11 +782,18 @@ function openCreateModal() { applyOsTypeUI(); /* fires updateCreateButtons + revalidateVmName */ document.getElementById('create-vm-overlay').classList.add('active'); - setTimeout(function() { document.getElementById('vm-name').focus(); }, 0); + setTimeout(function() { + if (diskDirectoryBeforeEdit === null) document.getElementById('vm-name').focus(); + }, 0); } function closeCreateModal() { + closeDiskLocationModal(false); document.getElementById('create-vm-overlay').classList.remove('active'); + clearTimeout(diskSpaceTimer); + diskSpaceTimer = null; + diskSpacePending = false; + ++diskSpaceRequestId; } /* Close on backdrop click — but only when the press also STARTED on the backdrop. @@ -630,8 +809,37 @@ document.getElementById('create-vm-overlay').addEventListener('click', function( createBackdropPress = false; }); -/* Close on Escape */ +function trapModalFocus(event, overlay) { + if (event.key !== 'Tab') return; + var controls = Array.from(overlay.querySelectorAll('button, input, select, [tabindex]')).filter(function(el) { + return !el.disabled && el.tabIndex >= 0 && el.getClientRects().length; + }); + if (!controls.length) return; + var first = controls[0], last = controls[controls.length - 1]; + if (!overlay.contains(document.activeElement) || + (event.shiftKey && document.activeElement === first) || + (!event.shiftKey && document.activeElement === last)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } +} + document.addEventListener('keydown', function(e) { + var confirmOverlay = document.getElementById('modal-overlay'); + if (confirmOverlay.classList.contains('active')) { + if (e.key === 'Escape') { e.preventDefault(); modalResolve(false); } + else trapModalFocus(e, confirmOverlay); + return; + } + var diskOverlay = document.getElementById('disk-location-overlay'); + if (diskOverlay.classList.contains('active')) { + if (e.key === 'Escape') { e.preventDefault(); closeDiskLocationModal(false); } + else if (e.key === 'Enter' && e.target.id === 'disk-directory') { + e.preventDefault(); + closeDiskLocationModal(true); + } else trapModalFocus(e, diskOverlay); + return; + } if (e.key !== 'Escape') return; if (document.getElementById('create-vm-overlay').classList.contains('active')) { closeCreateModal(); @@ -1400,6 +1608,7 @@ function onPrereqResult(msg) { /* ---- Modal ---- */ function showModal(title, message, confirmText, opts) { + var previousFocus = document.activeElement; document.getElementById('modal-title').textContent = title; document.getElementById('modal-message').textContent = message; var confirmBtn = document.getElementById('modal-confirm-btn'); @@ -1425,15 +1634,17 @@ function showModal(title, message, confirmText, opts) { inputRow.style.display = 'none'; } document.getElementById('modal-overlay').classList.add('active'); + if (!(opts && opts.input)) confirmBtn.focus(); return new Promise(function(resolve) { - pendingConfirm = { resolve: resolve, hasInput: !!(opts && opts.input) }; + pendingConfirm = { resolve: resolve, hasInput: !!(opts && opts.input), previousFocus: previousFocus }; }); } function modalResolve(result) { document.getElementById('modal-overlay').classList.remove('active'); if (pendingConfirm) { + if (pendingConfirm.previousFocus) pendingConfirm.previousFocus.focus(); if (result && pendingConfirm.hasInput) { pendingConfirm.resolve(document.getElementById('modal-input').value); } else { diff --git a/web/index.html b/web/index.html index c14c2be..6dc6b26 100644 --- a/web/index.html +++ b/web/index.html @@ -65,6 +65,7 @@

Virtual Machine Platform Required