diff --git a/AppSandbox.xcodeproj/project.pbxproj b/AppSandbox.xcodeproj/project.pbxproj index 790387c..cd4e90c 100644 --- a/AppSandbox.xcodeproj/project.pbxproj +++ b/AppSandbox.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 6033000000000000000000DD /* firstboot.sh in Embed agent_mac */ = {isa = PBXBuildFile; fileRef = 1033000000000000000000DD /* firstboot.sh */; }; /* Framework target — Frameworks */ 4011000000000000000000BB /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3001000000000000000000AA /* Cocoa.framework */; }; + 4016000000000000000000BB /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3016000000000000000000BB /* Carbon.framework */; }; 4012000000000000000000BB /* Virtualization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3003000000000000000000AA /* Virtualization.framework */; }; 4013000000000000000000BB /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3004000000000000000000AA /* Metal.framework */; }; 4014000000000000000000BB /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3005000000000000000000AA /* Security.framework */; }; @@ -197,8 +198,10 @@ 1040000000000000000000EE /* appsandbox-clipboard.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "appsandbox-clipboard.m"; sourceTree = ""; }; C040000000000000000000EE /* appsandbox-clipboard */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "appsandbox-clipboard"; sourceTree = BUILT_PRODUCTS_DIR; }; 1018000000000000000000AA /* asb_types.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = asb_types.h; sourceTree = ""; }; + 1070000000000000000000AA /* protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = protocol.h; sourceTree = ""; }; 1020000000000000000000BB /* AppSandboxCore-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "AppSandboxCore-Info.plist"; sourceTree = ""; }; 3001000000000000000000AA /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; + 3016000000000000000000BB /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; 3002000000000000000000AA /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 3003000000000000000000AA /* Virtualization.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Virtualization.framework; path = System/Library/Frameworks/Virtualization.framework; sourceTree = SDKROOT; }; 3004000000000000000000AA /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; }; @@ -254,6 +257,7 @@ buildActionMask = 2147483647; files = ( 4011000000000000000000BB /* Cocoa.framework in Frameworks */, + 4016000000000000000000BB /* Carbon.framework in Frameworks */, 4012000000000000000000BB /* Virtualization.framework in Frameworks */, 4013000000000000000000BB /* Metal.framework in Frameworks */, 4014000000000000000000BB /* Security.framework in Frameworks */, @@ -393,6 +397,7 @@ isa = PBXGroup; children = ( 1018000000000000000000AA /* asb_types.h */, + 1070000000000000000000AA /* protocol.h */, ); name = core; path = src/core; @@ -436,6 +441,7 @@ isa = PBXGroup; children = ( 3001000000000000000000AA /* Cocoa.framework */, + 3016000000000000000000BB /* Carbon.framework */, 3002000000000000000000AA /* WebKit.framework */, 3003000000000000000000AA /* Virtualization.framework */, 3004000000000000000000AA /* Metal.framework */, diff --git a/AppSandboxCore.vcxproj b/AppSandboxCore.vcxproj index b2b6112..4e4eb59 100644 --- a/AppSandboxCore.vcxproj +++ b/AppSandboxCore.vcxproj @@ -184,6 +184,7 @@ + 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..3b6de6a 100644 --- a/src/backend_mac/idd_display.m +++ b/src/backend_mac/idd_display.m @@ -18,10 +18,13 @@ #import "idd_display.h" #import "asb_ivshmem_transport.h" +#import "vm_dir.h" #import #import #import #import +#import +#import #include #include @@ -32,10 +35,13 @@ #include #include #include +#include #include +#include #include #include #include "../../tools/transport/asb_transport.h" /* ASB_CH_DISPLAY/INPUT/AUDIO/CLIPBOARD[_READER], AsbCursor, ASB_CURSOR_MAGIC */ +#include "../core/protocol.h" /* ---- VDD wire protocol (mirror of tools/vdd/vdd.h + src/backend_win/vm_display_idd.c). * The VDD pushes the same stream it sends over HvSocket on a @@ -52,19 +58,6 @@ } WireCursorHeader; #pragma pack(pop) -/* ---- ch3 input record (mirror of tools/agent/appsandbox-input.c's InputPacket). ---- */ -#define INPUT_MAGIC 0x4E495341u /* 'ASIN' */ -#define INPUT_MOUSE_MOVE 0 -#define INPUT_MOUSE_BUTTON 1 -#define INPUT_MOUSE_WHEEL 2 -#define INPUT_KEY 3 -#define INPUT_BTN_LEFT 0 -#define INPUT_BTN_RIGHT 1 -#define INPUT_BTN_MIDDLE 2 -#pragma pack(push, 1) -typedef struct { uint32_t magic, type, param1, param2, param3; } InputPacket; -#pragma pack(pop) - /* ---- ch4 AUDIO wire format (mirror of tools/agent/appsandbox-audio.c). The guest sends one * AudioHeader (format) then a stream of AudioFrameHeader+PCM. ---- */ #define AUDIO_HEADER_MAGIC 0x31415341u /* 'ASA1' */ @@ -148,6 +141,134 @@ static void build_keymap(void) g_vk[0x2F]=0xBE; g_vk[0x2C]=0xBF; g_vk[0x32]=0xC0; g_vk[0x21]=0xDB; g_vk[0x2A]=0xDC; g_vk[0x1E]=0xDD; g_vk[0x27]=0xDE; } + +/* Set-1 make codes; 0xe000 marks the Windows E0 prefix. */ +static const uint16_t g_scan[128] = { + [kVK_ANSI_A]=0x1e, [kVK_ANSI_S]=0x1f, [kVK_ANSI_D]=0x20, [kVK_ANSI_F]=0x21, + [kVK_ANSI_H]=0x23, [kVK_ANSI_G]=0x22, [kVK_ANSI_Z]=0x2c, [kVK_ANSI_X]=0x2d, + [kVK_ANSI_C]=0x2e, [kVK_ANSI_V]=0x2f, [kVK_ANSI_B]=0x30, [kVK_ANSI_Q]=0x10, + [kVK_ANSI_W]=0x11, [kVK_ANSI_E]=0x12, [kVK_ANSI_R]=0x13, [kVK_ANSI_Y]=0x15, + [kVK_ANSI_T]=0x14, [kVK_ANSI_U]=0x16, [kVK_ANSI_I]=0x17, [kVK_ANSI_O]=0x18, + [kVK_ANSI_P]=0x19, [kVK_ANSI_L]=0x26, [kVK_ANSI_J]=0x24, [kVK_ANSI_K]=0x25, + [kVK_ANSI_N]=0x31, [kVK_ANSI_M]=0x32, + [kVK_ANSI_1]=0x02, [kVK_ANSI_2]=0x03, [kVK_ANSI_3]=0x04, [kVK_ANSI_4]=0x05, + [kVK_ANSI_5]=0x06, [kVK_ANSI_6]=0x07, [kVK_ANSI_7]=0x08, [kVK_ANSI_8]=0x09, + [kVK_ANSI_9]=0x0a, [kVK_ANSI_0]=0x0b, + [kVK_ANSI_Equal]=0x0d, [kVK_ANSI_Minus]=0x0c, + [kVK_ANSI_LeftBracket]=0x1a, [kVK_ANSI_RightBracket]=0x1b, + [kVK_ANSI_Quote]=0x28, [kVK_ANSI_Semicolon]=0x27, [kVK_ANSI_Backslash]=0x2b, + [kVK_ANSI_Comma]=0x33, [kVK_ANSI_Slash]=0x35, [kVK_ANSI_Period]=0x34, + [kVK_ANSI_Grave]=0x29, [kVK_ISO_Section]=0x56, + [kVK_Return]=0x1c, [kVK_Tab]=0x0f, [kVK_Space]=0x39, + [kVK_Delete]=0x0e, [kVK_Escape]=0x01, + [kVK_Command]=0xe05b, [kVK_RightCommand]=0xe05c, + [kVK_Shift]=0x2a, [kVK_RightShift]=0x36, [kVK_CapsLock]=0x3a, + [kVK_Option]=0x38, [kVK_RightOption]=0xe038, + [kVK_Control]=0x1d, [kVK_RightControl]=0xe01d, + [kVK_ANSI_Keypad0]=0x52, [kVK_ANSI_Keypad1]=0x4f, [kVK_ANSI_Keypad2]=0x50, + [kVK_ANSI_Keypad3]=0x51, [kVK_ANSI_Keypad4]=0x4b, [kVK_ANSI_Keypad5]=0x4c, + [kVK_ANSI_Keypad6]=0x4d, [kVK_ANSI_Keypad7]=0x47, [kVK_ANSI_Keypad8]=0x48, + [kVK_ANSI_Keypad9]=0x49, [kVK_ANSI_KeypadDecimal]=0x53, + [kVK_ANSI_KeypadMultiply]=0x37, [kVK_ANSI_KeypadPlus]=0x4e, + [kVK_ANSI_KeypadMinus]=0x4a, [kVK_ANSI_KeypadDivide]=0xe035, + [kVK_ANSI_KeypadEnter]=0xe01c, [kVK_ANSI_KeypadClear]=0x45, + [0x34]=0xe01c, [0x6e]=0xe05d, + [kVK_F1]=0x3b, [kVK_F2]=0x3c, [kVK_F3]=0x3d, [kVK_F4]=0x3e, + [kVK_F5]=0x3f, [kVK_F6]=0x40, [kVK_F7]=0x41, [kVK_F8]=0x42, + [kVK_F9]=0x43, [kVK_F10]=0x44, [kVK_F11]=0x57, [kVK_F12]=0x58, + [kVK_F13]=0x64, [kVK_F14]=0x65, [kVK_F15]=0x66, [kVK_F16]=0x67, + [kVK_F17]=0x68, [kVK_F18]=0x69, [kVK_F19]=0x6a, [kVK_F20]=0x6b, + [kVK_Help]=0xe052, [kVK_Home]=0xe047, [kVK_End]=0xe04f, + [kVK_PageUp]=0xe049, [kVK_PageDown]=0xe051, [kVK_ForwardDelete]=0xe053, + [kVK_LeftArrow]=0xe04b, [kVK_RightArrow]=0xe04d, + [kVK_UpArrow]=0xe048, [kVK_DownArrow]=0xe050, + [kVK_JIS_Yen]=0x7d, [kVK_JIS_Underscore]=0x73, + [kVK_JIS_Kana]=0xe0f2, [kVK_JIS_Eisu]=0xe0f1 +}; + +static BOOL idd_modifier_down(NSEventModifierFlags flags, unsigned short kc) { + NSEventModifierFlags side = 0, pair = 0, common = 0; + switch (kc) { + case kVK_Shift: side = NX_DEVICELSHIFTKEYMASK; pair = NX_DEVICERSHIFTKEYMASK; common = NSEventModifierFlagShift; break; + case kVK_RightShift: side = NX_DEVICERSHIFTKEYMASK; pair = NX_DEVICELSHIFTKEYMASK; common = NSEventModifierFlagShift; break; + case kVK_Control: side = NX_DEVICELCTLKEYMASK; pair = NX_DEVICERCTLKEYMASK; common = NSEventModifierFlagControl; break; + case kVK_RightControl: side = NX_DEVICERCTLKEYMASK; pair = NX_DEVICELCTLKEYMASK; common = NSEventModifierFlagControl; break; + case kVK_Option: side = NX_DEVICELALTKEYMASK; pair = NX_DEVICERALTKEYMASK; common = NSEventModifierFlagOption; break; + case kVK_RightOption: side = NX_DEVICERALTKEYMASK; pair = NX_DEVICELALTKEYMASK; common = NSEventModifierFlagOption; break; + case kVK_Command: side = NX_DEVICELCMDKEYMASK; pair = NX_DEVICERCMDKEYMASK; common = NSEventModifierFlagCommand; break; + case kVK_RightCommand: side = NX_DEVICERCMDKEYMASK; pair = NX_DEVICELCMDKEYMASK; common = NSEventModifierFlagCommand; break; + default: return NO; + } + if (!(flags & common)) return NO; + return (flags & (side | pair)) ? (flags & side) != 0 : YES; +} + +static int idd_input_versions(int fd, volatile int *stop, int *keyboardVersion, int *mouseVersion) { + InputPacket queries[] = { + { INPUT_MAGIC, INPUT_KEYBOARD_QUERY, INPUT_KEYBOARD_VERSION, arc4random() | 1u, 0 }, + { INPUT_MAGIC, INPUT_MOUSE_QUERY, INPUT_MOUSE_VERSION, arc4random() | 1u, 0 } + }; + uint8_t received[sizeof(uint32_t) + sizeof(InputPacket)]; + size_t sent = 0, count = 0; + BOOL greeted = NO, keyboardReplied = NO, mouseReplied = NO; + *keyboardVersion = 1; + *mouseVersion = 0; + struct timespec start, now; + clock_gettime(CLOCK_MONOTONIC, &start); + while (!*stop) { + clock_gettime(CLOCK_MONOTONIC, &now); + int64_t left = 500000 - ((now.tv_sec - start.tv_sec) * 1000000 + + (now.tv_nsec - start.tv_nsec) / 1000); + if (left <= 0) break; + fd_set reads, writes; + FD_ZERO(&reads); FD_SET(fd, &reads); + FD_ZERO(&writes); if (sent < sizeof(queries)) FD_SET(fd, &writes); + struct timeval timeout = { 0, (suseconds_t)(left < 50000 ? left : 50000) }; + int ready = select(fd + 1, &reads, &writes, NULL, &timeout); + if (ready < 0) { if (errno == EINTR) continue; return 0; } + if (FD_ISSET(fd, &writes)) { + ssize_t n = send(fd, (uint8_t *)queries + sent, sizeof(queries) - sent, MSG_DONTWAIT); + if (n > 0) sent += (size_t)n; + else if (n == 0 || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) return 0; + } + if (FD_ISSET(fd, &reads)) { + ssize_t n = recv(fd, received + count, sizeof(received) - count, 0); + if (n == 0) return 0; + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue; + return 0; + } + count += (size_t)n; + if (!greeted && count >= sizeof(uint32_t)) { + uint32_t magic; + memcpy(&magic, received, sizeof(magic)); + if (magic != INPUT_READY_MAGIC) return sent == sizeof(queries); + greeted = YES; + count -= sizeof(magic); + memmove(received, received + sizeof(magic), count); + } + if (greeted && count >= sizeof(InputPacket)) { + InputPacket reply; + memcpy(&reply, received, sizeof(reply)); + count -= sizeof(reply); + memmove(received, received + sizeof(reply), count); + if (reply.magic == INPUT_MAGIC && reply.param3 == 0) { + if (reply.type == INPUT_KEYBOARD_REPLY && reply.param2 == queries[0].param2) { + *keyboardVersion = reply.param1 == INPUT_KEYBOARD_VERSION ? INPUT_KEYBOARD_VERSION : 1; + keyboardReplied = YES; + } else if (reply.type == INPUT_MOUSE_REPLY && reply.param2 == queries[1].param2) { + *mouseVersion = reply.param1 == INPUT_MOUSE_VERSION ? INPUT_MOUSE_VERSION : 0; + mouseReplied = YES; + } + } + } + } + if (sent == sizeof(queries) && keyboardReplied && mouseReplied) + return *mouseVersion == INPUT_MOUSE_VERSION && count ? 0 : 1; + } + if (*mouseVersion == INPUT_MOUSE_VERSION && count) return 0; + return sent && sent < sizeof(queries) ? 0 : 1; +} /* extended-key keycodes (arrows + nav + fwd-delete) */ static int is_extended(unsigned short kc) { @@ -160,6 +281,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,11 +376,14 @@ 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; +static __weak IddDisplayWindow *g_mouseOwner; @interface IddDisplayView : NSView @property (nonatomic, weak) IddDisplayWindow *owner; @property (nonatomic, strong) NSTrackingArea *track; - (void)updateGuestCursor; /* mirror the guest HW cursor onto this view's NSCursor (render timer) */ +- (void)recordAbsoluteMove:(NSPoint)point; @end @interface IddDisplayWindow () @@ -281,8 +420,20 @@ - (uint32_t)frameHeight; - (NSCursor *)currentCursorForScale:(double)scale; - (NSCursor *)appliedCursor; - (void)sendInput:(uint32_t)type p1:(uint32_t)p1 p2:(uint32_t)p2 p3:(uint32_t)p3; +- (void)enqueueInputLocked:(InputPacket)packet; - (void)recordMoveX:(uint32_t)x y:(uint32_t)y; - (void)flushMove; +- (BOOL)recordRelativeMove:(NSEvent *)event; +- (void)releaseMouseCapture; +- (void)updateMouseCapture; +- (void)discardMouseMovesLocked; +- (void)beginMousePosition; +- (void)fallbackMousePosition; +- (void)finishMousePosition:(InputPacket)reply connection:(uint64_t)connection; +- (void)forwardKeyEvent:(NSEvent *)event; +- (void)releaseHeldKeys; +- (void)releaseKeyboardCapture; +- (void)updateKeyboardCapture; @end @implementation IddDisplayWindow { @@ -322,16 +473,28 @@ main thread by the render timer (rebuilt on shape/visibility/scale change). */ uint32_t _curBlobApplied; double _curScale; NSCursor *_nsCursor; + BOOL _displayReady; + BOOL _cursorKnown; + BOOL _guestCursorVisible; /* Coalesced mouse-move state (main thread only). A retina trackpad fires far more move events than the guest can inject, so we keep only the latest position and emit at most one move per render tick; discrete events flush the pending move first to keep ordering. */ 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; + double _relativeX, _relativeY; + NSTimeInterval _mousePositionDeadline, _mousePositionResumeTime; + + id _eventMonitor; + BOOL _transmitHotkeys; + BOOL _hotkeysCaptured; + int32_t _hotkeyConnection; + int _savedHotkeyMode; + NSUInteger _trackingMenus; + BOOL _heldKeys[128]; + InputPacket _heldPackets[128]; + + /* Published reader fds shared with teardown. */ pthread_mutex_t _inputLock; /* Input send queue: the AppKit main thread (-sendInput:) enqueues InputPackets here and returns @@ -343,6 +506,12 @@ main thread by the render timer (rebuilt on shape/visibility/scale change). */ #define IDD_INQ_CAP 256 InputPacket _inq[IDD_INQ_CAP]; uint32_t _inqHead, _inqTail; + BOOL _inputReady; + int _keyboardVersion; + int _mouseVersion; + BOOL _relativeMouse; + uint32_t _mousePositionRequest, _mousePositionSerial; + uint64_t _inputConnection, _mouseCaptureConnection, _mouseMoveGeneration; pthread_mutex_t _inqLock; pthread_cond_t _inqCond; /* Display thread's current ch2 fd, published so teardown can shutdown() it to unblock the @@ -357,6 +526,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 +566,7 @@ - (instancetype)initWithName:(NSString *)name _name = [name copy]; _transport = transport; - _inputFd = -1; + _stop = 1; _displayFd = -1; _audioFd = -1; _clipWriterFd = -1; @@ -415,10 +585,415 @@ - (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]; + [self releaseMouseCapture]; + [menu popUpMenuPositioningItem:nil atLocation:point inView:nil]; + if (_trackingMenus) _trackingMenus--; + [self updateKeyboardCapture]; + [self updateMouseCapture]; +} + +- (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; + if (kc >= 128) return; + pthread_mutex_lock(&_inqLock); + if (!_inputReady || _stop) { pthread_mutex_unlock(&_inqLock); return; } + uint32_t vk = 0; + uint32_t scan = 0; + BOOL up = event.type == NSEventTypeKeyUp; + BOOL extended = is_extended(kc); + BOOL physical = _keyboardVersion == INPUT_KEYBOARD_VERSION; + if (physical) { + unsigned short position = kc; + if (kc == kVK_ISO_Section || kc == kVK_ANSI_Grave) { + /* macOS swaps these two physical positions on ISO hardware. */ + CGEventRef cg = event.CGEvent; + SInt16 keyboardType = cg ? (SInt16)CGEventGetIntegerValueField(cg, kCGKeyboardEventKeyboardType) : LMGetKbdType(); + if (KBGetLayoutType(keyboardType) == kKeyboardISO) + position = kVK_ISO_Section + kVK_ANSI_Grave - kc; + } + scan = g_scan[position] & 0xff; + extended = (g_scan[position] & 0xff00) == 0xe000; + vk = g_vk[kc]; + if (event.type == NSEventTypeFlagsChanged) { + if (kc == kVK_CapsLock) { + InputPacket caps = { INPUT_MAGIC, INPUT_KEY_PHYSICAL, 0, 0x3a, 0 }; + [self enqueueInputLocked:caps]; + caps.param3 = INPUT_KEY_UP; + [self enqueueInputLocked:caps]; + pthread_mutex_unlock(&_inqLock); + return; + } + up = !idd_modifier_down(event.modifierFlags, kc); + } + if (!scan) { + switch (kc) { + case kVK_VolumeUp: vk = 0xaf; break; + case kVK_VolumeDown: vk = 0xae; break; + case kVK_Mute: vk = 0xad; break; + default: vk = 0; break; + } + } + } else 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: pthread_mutex_unlock(&_inqLock); return; + } + up = (event.modifierFlags & mask) == 0; + } else { + vk = g_vk[kc]; + } + if ((!vk && !scan) || (up && !_heldKeys[kc]) || + (event.type == NSEventTypeKeyDown && event.isARepeat && !_heldKeys[kc])) { + pthread_mutex_unlock(&_inqLock); + return; + } + InputPacket packet = { INPUT_MAGIC, physical ? INPUT_KEY_PHYSICAL : INPUT_KEY, + vk, scan, (extended ? INPUT_KEY_EXTENDED : 0) | (up ? INPUT_KEY_UP : 0) }; + if (up) { + packet = _heldPackets[kc]; + packet.param3 |= INPUT_KEY_UP; + } + [self enqueueInputLocked:packet]; + _heldKeys[kc] = !up; + _heldPackets[kc] = packet; + pthread_mutex_unlock(&_inqLock); +} + +- (void)discardMouseMovesLocked { + _mouseMoveGeneration++; + uint32_t tail = _inqHead; + for (uint32_t i = _inqHead; i != _inqTail; i = (i + 1) % IDD_INQ_CAP) { + if (_inq[i].type == INPUT_MOUSE_MOVE || _inq[i].type == INPUT_MOUSE_RELATIVE || + _inq[i].type == INPUT_MOUSE_POSITION_QUERY) continue; + _inq[tail] = _inq[i]; + tail = (tail + 1) % IDD_INQ_CAP; + } + _inqTail = tail; +} + +- (void)releaseMouseCapture { + pthread_mutex_lock(&_inqLock); + if (!_relativeMouse && !_mousePositionRequest) { pthread_mutex_unlock(&_inqLock); return; } + _relativeMouse = NO; + _mousePositionRequest = 0; + [self discardMouseMovesLocked]; + pthread_mutex_unlock(&_inqLock); + _hasMove = 0; + _relativeX = _relativeY = 0; + if (g_mouseOwner == self) { + CGAssociateMouseAndMouseCursorPosition(true); + g_mouseOwner = nil; + } + [NSCursor unhide]; + [self.window invalidateCursorRectsForView:self.view]; +} + +- (void)beginMousePosition { + pthread_mutex_lock(&_inqLock); + if (!_inputReady || _mouseVersion != INPUT_MOUSE_VERSION || !_relativeMouse || + _mouseCaptureConnection != _inputConnection) { + pthread_mutex_unlock(&_inqLock); + [self releaseMouseCapture]; + return; + } + _relativeMouse = NO; + [self discardMouseMovesLocked]; + if (++_mousePositionSerial == 0) ++_mousePositionSerial; + _mousePositionRequest = _mousePositionSerial; + InputPacket packet = { INPUT_MAGIC, INPUT_MOUSE_POSITION_QUERY, _mousePositionRequest, 0, 0 }; + [self enqueueInputLocked:packet]; + pthread_mutex_unlock(&_inqLock); + _hasMove = 0; + _relativeX = _relativeY = 0; + _mousePositionDeadline = NSProcessInfo.processInfo.systemUptime + 0.25; +} + +- (void)fallbackMousePosition { + if (!_mousePositionRequest) return; + [self releaseMouseCapture]; + NSPoint point = [self.view convertPoint: + [self.window convertPointFromScreen:[NSEvent mouseLocation]] fromView:nil]; + [self.view recordAbsoluteMove:point]; + [self flushMove]; +} + +- (void)finishMousePosition:(InputPacket)reply connection:(uint64_t)connection { + pthread_mutex_lock(&_inqLock); + BOOL matches = _inputReady && _inputConnection == connection && + _mousePositionRequest && _mousePositionRequest == reply.param3; + pthread_mutex_unlock(&_inqLock); + if (!matches) return; + [self updateMouseCapture]; + if (!_mousePositionRequest || _mousePositionRequest != reply.param3) return; + + uint32_t gw = [self frameWidth], gh = [self frameHeight]; + int32_t x = (int32_t)reply.param1, y = (int32_t)reply.param2; + NSScreen *primary = NSScreen.screens.firstObject; + NSRect bounds = self.view.bounds; + CGRect frame = idd_letterbox(bounds.size.width, bounds.size.height, gw, gh, NULL); + if (!primary || gw <= 1 || gh <= 1 || x < 0 || y < 0 || + (uint32_t)x >= gw || (uint32_t)y >= gh || frame.size.width < 1 || frame.size.height < 1) { + [self fallbackMousePosition]; + return; + } + NSPoint point = NSMakePoint(frame.origin.x + (double)x / (gw - 1) * frame.size.width, + CGRectGetMaxY(frame) - (double)y / (gh - 1) * frame.size.height); + point.x = fmin(point.x, NSMaxX(bounds) - fmin(1.0, bounds.size.width * 0.5)); + point.y = fmin(point.y, NSMaxY(bounds) - fmin(1.0, bounds.size.height * 0.5)); + NSPoint screen = [self.window convertPointToScreen:[self.view convertPoint:point toView:nil]]; + pthread_mutex_lock(&_inqLock); + matches = _inputReady && _inputConnection == connection && + _mousePositionRequest && _mousePositionRequest == reply.param3; + CGError error = matches ? + CGWarpMouseCursorPosition(CGPointMake(screen.x, NSMaxY(primary.frame) - screen.y)) : kCGErrorFailure; + pthread_mutex_unlock(&_inqLock); + if (!matches) { [self releaseMouseCapture]; return; } + if (error != kCGErrorSuccess) { + [self fallbackMousePosition]; + return; + } + _mousePositionResumeTime = NSProcessInfo.processInfo.systemUptime; + [self releaseMouseCapture]; +} + +- (void)updateMouseCapture { + pthread_mutex_lock(&_inqLock); + BOOL ready = _inputReady && _mouseVersion == INPUT_MOUSE_VERSION && + ((!_relativeMouse && !_mousePositionRequest) || _mouseCaptureConnection == _inputConnection); + pthread_mutex_unlock(&_inqLock); + pthread_mutex_lock(&_curLock); + BOOL hidden = _displayReady && _cursorKnown && !_guestCursorVisible; + BOOL visible = _displayReady && _cursorKnown && _guestCursorVisible; + pthread_mutex_unlock(&_curLock); + BOOL active = ready && (hidden || ((_relativeMouse || _mousePositionRequest) && visible)) && + !_stop && !_trackingMenus && NSApp.isActive && + self.window.isKeyWindow && !self.window.isMiniaturized && self.window.isVisible && + self.window.firstResponder == self.view && !self.window.attachedSheet; + NSPoint point = NSZeroPoint; + if (active) { + NSPoint screenPoint = [NSEvent mouseLocation]; + point = [self.view convertPoint:[self.window convertPointFromScreen:screenPoint] fromView:nil]; + CGRect frame = idd_letterbox(self.view.bounds.size.width, self.view.bounds.size.height, + [self frameWidth], [self frameHeight], NULL); + active = NSPointInRect(point, self.view.visibleRect) && CGRectContainsPoint(frame, point) && + [NSWindow windowNumberAtPoint:screenPoint belowWindowWithWindowNumber:0] == self.window.windowNumber; + } + if (_mousePositionRequest) { + if (!active || !visible) { + [self releaseMouseCapture]; + } else { + if (NSProcessInfo.processInfo.systemUptime >= _mousePositionDeadline) + [self fallbackMousePosition]; + return; + } + } + BOOL capture = active && hidden; + if (!capture) { + if (_relativeMouse) { + if (active && visible && g_mouseOwner == self) { + [self beginMousePosition]; + return; + } + [self releaseMouseCapture]; + } + return; + } + if (_relativeMouse) return; + [g_mouseOwner releaseMouseCapture]; + if (CGAssociateMouseAndMouseCursorPosition(false) != kCGErrorSuccess) return; + _hasMove = 0; + _relativeX = _relativeY = 0; + pthread_mutex_lock(&_inqLock); + [self discardMouseMovesLocked]; + _relativeMouse = YES; + _mouseCaptureConnection = _inputConnection; + pthread_mutex_unlock(&_inqLock); + g_mouseOwner = self; + [NSCursor hide]; +} + +- (BOOL)recordRelativeMove:(NSEvent *)event { + BOOL movement = event.type == NSEventTypeMouseMoved || event.type == NSEventTypeLeftMouseDragged || + event.type == NSEventTypeRightMouseDragged || event.type == NSEventTypeOtherMouseDragged; + if (event.timestamp <= _mousePositionResumeTime) return YES; + BOOL captured = _relativeMouse || _mousePositionRequest; + [self updateMouseCapture]; + if (_mousePositionRequest) return YES; + if (!_relativeMouse) return captured; + if (captured && movement) { + _relativeX += event.deltaX; + _relativeY += event.deltaY; + } + return YES; +} + +- (void)releaseHeldKeys { + pthread_mutex_lock(&_inqLock); + for (unsigned int kc = 0; kc < 128; kc++) { + if (!_heldKeys[kc]) continue; + InputPacket packet = _heldPackets[kc]; + packet.param3 |= INPUT_KEY_UP; + if (_inputReady) [self enqueueInputLocked:packet]; + _heldKeys[kc] = NO; + } + pthread_mutex_unlock(&_inqLock); +} + /* 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 +1080,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,11 +1099,13 @@ 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 macOS cursor, and copy a freshly reconstructed frame into the render buffer + redraw. */ - (void)renderTick { + [self updateMouseCapture]; [self flushMove]; [self.view updateGuestCursor]; [self renderMetal]; @@ -634,6 +1212,14 @@ - (uint32_t)frameHeight { return _fbH; } NSCursor to install, or nil if nothing changed. */ - (NSCursor *)currentCursorForScale:(double)scale { pthread_mutex_lock(&_curLock); + if (!_displayReady || !_cursorKnown) { + pthread_mutex_unlock(&_curLock); + NSCursor *arrow = [NSCursor arrowCursor]; + if (_nsCursor == arrow) return nil; + _nsCursor = arrow; + _curBlobApplied = 0; + return arrow; + } uint32_t seq = _curBlobSeq; if ((seq == _curBlobApplied && fabs(scale - _curScale) < 0.01) || !_curBlob) { pthread_mutex_unlock(&_curLock); @@ -649,6 +1235,8 @@ - (NSCursor *)appliedCursor { return _nsCursor; } #pragma mark - Input (main thread -> ch3 fd) - (void)sendInput:(uint32_t)type p1:(uint32_t)p1 p2:(uint32_t)p2 p3:(uint32_t)p3 { + if (_mousePositionRequest && ((type == INPUT_MOUSE_BUTTON && p2) || type == INPUT_MOUSE_WHEEL)) + [self fallbackMousePosition]; InputPacket pkt = { INPUT_MAGIC, type, p1, p2, p3 }; /* ENQUEUE ONLY — the AppKit main thread does ZERO ch3 socket I/O. The input worker thread (inputLoop) drains this ring and does the actual blocking send + owns reconnect. Any guest @@ -657,16 +1245,32 @@ - (void)sendInput:(uint32_t)type p1:(uint32_t)p1 p2:(uint32_t)p2 p3:(uint32_t)p3 (A non-blocking main-thread send would not suffice: the main thread stays coupled to ch3's live state and can pin in __sendto.) Bounded ring, drop-OLDEST when full (latest position wins). */ pthread_mutex_lock(&_inqLock); + if (_inputReady && !_stop) [self enqueueInputLocked:pkt]; + pthread_mutex_unlock(&_inqLock); +} + +- (void)enqueueInputLocked:(InputPacket)pkt { uint32_t next = (_inqTail + 1) % IDD_INQ_CAP; if (next == _inqHead) _inqHead = (_inqHead + 1) % IDD_INQ_CAP; /* full -> drop oldest */ _inq[_inqTail] = pkt; _inqTail = next; pthread_cond_signal(&_inqCond); - pthread_mutex_unlock(&_inqLock); } -- (void)recordMoveX:(uint32_t)x y:(uint32_t)y { _moveX = x; _moveY = y; _hasMove = 1; } +- (void)recordMoveX:(uint32_t)x y:(uint32_t)y { + if (_mousePositionRequest) return; + _moveX = x; _moveY = y; _hasMove = 1; +} - (void)flushMove { + if (_mousePositionRequest) return; + if (_relativeMouse) { + int32_t dx = (int32_t)fmax(INT32_MIN, fmin(INT32_MAX, _relativeX)); + int32_t dy = (int32_t)fmax(INT32_MIN, fmin(INT32_MAX, _relativeY)); + _relativeX -= dx; + _relativeY -= dy; + if (dx || dy) [self sendInput:INPUT_MOUSE_RELATIVE p1:(uint32_t)dx p2:(uint32_t)dy p3:0]; + return; + } if (_hasMove) { _hasMove = 0; [self sendInput:INPUT_MOUSE_MOVE p1:_moveX p2:_moveY p3:0]; } } @@ -677,6 +1281,7 @@ - (void)flushMove { first then dirty-rect frames + cursor updates). On EOF/error we close the fd and reconnect — a new accept => a fresh full frame, exactly like reopening the IDD window. Mirrors display_recv_thread. */ - (void)displayLoop { + __weak IddDisplayWindow *weakSelf = self; uint8_t *scratch = NULL; size_t scratchSz = 0; while (!_stop) { @@ -706,6 +1311,8 @@ - (void)displayLoop { /* Build an AsbCursor blob (header + image) so the render timer reuses buildGuestCursor unchanged. shape_updated=0 => keep last image, update visibility only. */ pthread_mutex_lock(&_curLock); + _cursorKnown = YES; + _guestCursorVisible = ch.visible != 0; if (shape) { uint8_t *blob = malloc(sizeof(AsbCursor) + ch.shape_data_size); if (blob) { @@ -718,9 +1325,13 @@ - (void)displayLoop { memcpy(blob + sizeof(AsbCursor), shape, ch.shape_data_size); free(_curBlob); _curBlob = blob; _curBlobSeq++; } - } else if (_curBlob) { + } else { + if (!_curBlob) { + _curBlob = calloc(1, sizeof(AsbCursor)); + _curBlobSeq++; + } AsbCursor *c = (AsbCursor *)_curBlob; - if (c->visible != ch.visible) { c->visible = ch.visible; _curBlobSeq++; } + if (c && c->visible != ch.visible) { c->visible = ch.visible; _curBlobSeq++; } } pthread_mutex_unlock(&_curLock); free(shape); @@ -789,8 +1400,16 @@ _fbLock is taken only for the memcpy into _fb (as in the dirty-rect path below). _fbSeq++; pthread_mutex_unlock(&_fbLock); } + pthread_mutex_lock(&_curLock); + _displayReady = YES; + pthread_mutex_unlock(&_curLock); } + pthread_mutex_lock(&_curLock); + _displayReady = NO; + _cursorKnown = NO; + pthread_mutex_unlock(&_curLock); + dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf releaseMouseCapture]; }); pthread_mutex_lock(&_inputLock); if (_displayFd == fd) _displayFd = -1; /* unpublish before close so teardown won't shutdown a reused fd */ pthread_mutex_unlock(&_inputLock); @@ -800,13 +1419,10 @@ _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 { + __weak IddDisplayWindow *weakSelf = self; while (!_stop) { int fd = [_transport connectChannel:ASB_CH_INPUT timeoutMs:2000]; if (fd < 0) { if (_stop) break; usleep(200000); continue; } @@ -818,11 +1434,23 @@ - (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); - + int keyboardVersion = 1, mouseVersion = 0; + if (!idd_input_versions(fd, &_stop, &keyboardVersion, &mouseVersion) || _stop) { + close(fd); + if (!_stop) usleep(100000); + continue; + } + pthread_mutex_lock(&_inqLock); + _inqHead = _inqTail = 0; + memset(_heldKeys, 0, sizeof(_heldKeys)); + _keyboardVersion = keyboardVersion; + _mouseVersion = mouseVersion; + uint64_t connection = ++_inputConnection; + _inputReady = YES; + pthread_mutex_unlock(&_inqLock); BOOL dead = NO; + uint8_t received[sizeof(InputPacket)]; + size_t receivedCount = 0; while (!_stop && !dead) { /* 1) Wait briefly for queued input, then drain it into a local batch. The cond wait wakes immediately when -sendInput: enqueues, or after 50 ms so we still periodically poll the @@ -841,6 +1469,7 @@ fd for EOF (a guest input-helper respawn while input is idle). */ batch[nbatch++] = _inq[_inqHead]; _inqHead = (_inqHead + 1) % IDD_INQ_CAP; } + uint64_t moveGeneration = _mouseMoveGeneration; pthread_mutex_unlock(&_inqLock); /* 2) Send the batch NON-BLOCKING — mirrors the proven Windows send_input @@ -852,33 +1481,79 @@ 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++) { + pthread_mutex_lock(&_inqLock); + BOOL move = batch[i].type == INPUT_MOUSE_MOVE || batch[i].type == INPUT_MOUSE_RELATIVE; + if ((move && (moveGeneration != _mouseMoveGeneration || _mousePositionRequest)) || + (batch[i].type == INPUT_MOUSE_MOVE && _relativeMouse) || + (batch[i].type == INPUT_MOUSE_RELATIVE && + (!_relativeMouse || _mouseVersion != INPUT_MOUSE_VERSION)) || + (batch[i].type == INPUT_MOUSE_POSITION_QUERY && + (!_mousePositionRequest || batch[i].param1 != _mousePositionRequest))) { + pthread_mutex_unlock(&_inqLock); + continue; + } ssize_t wn = send(fd, &batch[i], sizeof(InputPacket), MSG_DONTWAIT); + pthread_mutex_unlock(&_inqLock); if (wn == (ssize_t)sizeof(InputPacket)) continue; /* delivered */ if (wn < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) continue; /* full -> drop */ dead = YES; break; /* error/partial -> reconnect */ } if (dead || _stop) break; - /* 3) Detect a guest-side teardown even while idle: ch3 is host->guest, but the guest's - socketpair end is closed by the pump on disconnect/respawn (asb_ivshmem_pump_main done:), - and the input helper sends a one-time READY magic. Poll readable (0 timeout, non-blocking); - recv==0 is EOF -> reconnect; recv>0 is the incidental READY byte(s) -> discard and keep - serving. So input survives a guest restart even with nothing being typed. */ fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds); struct timeval tv = { .tv_sec = 0, .tv_usec = 0 }; int s = select(fd + 1, &rfds, NULL, NULL, &tv); - if (s < 0) break; /* select error -> reconnect */ + if (s < 0) { if (errno == EINTR) continue; dead = YES; break; } if (s > 0 && FD_ISSET(fd, &rfds)) { - char drain[64]; - ssize_t rd = recv(fd, drain, sizeof(drain), 0); - if (rd <= 0) break; /* 0 = guest closed ch3 (respawn/disconnect) -> reconnect */ + if (mouseVersion == 0) { + char drain[64]; + ssize_t rd = recv(fd, drain, sizeof(drain), 0); + if (rd == 0 || (rd < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) + dead = YES; + } else for (;;) { + ssize_t rd = recv(fd, received + receivedCount, sizeof(received) - receivedCount, 0); + if (rd <= 0) { + if (rd == 0 || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) + dead = YES; + break; + } + receivedCount += (size_t)rd; + if (receivedCount == sizeof(InputPacket)) { + InputPacket reply; + memcpy(&reply, received, sizeof(reply)); + receivedCount = 0; + if (reply.magic != INPUT_MAGIC) { dead = YES; break; } + if (reply.type == INPUT_MOUSE_POSITION_REPLY) { + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf finishMousePosition:reply connection:connection]; + }); + } + } + } } } - pthread_mutex_lock(&_inputLock); - if (_inputFd == fd) _inputFd = -1; - pthread_mutex_unlock(&_inputLock); + 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); + } + pthread_mutex_lock(&_inqLock); + _inputReady = NO; + _keyboardVersion = 1; + _mouseVersion = 0; + _inqHead = _inqTail = 0; + memset(_heldKeys, 0, sizeof(_heldKeys)); + pthread_mutex_unlock(&_inqLock); + dispatch_async(dispatch_get_main_queue(), ^{ + IddDisplayWindow *owner = weakSelf; + if (owner && owner->_mouseCaptureConnection == connection) [owner releaseMouseCapture]; + }); close(fd); if (!_stop) usleep(100000); } @@ -890,6 +1565,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 +1576,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 +1952,49 @@ 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]; + [self updateMouseCapture]; +} + +- (void)windowDidResignKey:(NSNotification *)notification { + (void)notification; + [self releaseKeyboardCapture]; + [self releaseMouseCapture]; +} + +- (void)applicationDidBecomeActive:(NSNotification *)notification { + (void)notification; + [self updateKeyboardCapture]; + [self updateMouseCapture]; +} + +- (void)applicationDidResignActive:(NSNotification *)notification { + (void)notification; + [self releaseKeyboardCapture]; + [self releaseMouseCapture]; +} + +- (void)menuDidBeginTracking:(NSNotification *)notification { + (void)notification; + _trackingMenus++; + [self releaseKeyboardCapture]; + [self releaseMouseCapture]; +} + +- (void)menuDidEndTracking:(NSNotification *)notification { + (void)notification; + if (_trackingMenus) _trackingMenus--; + [self updateKeyboardCapture]; + [self updateMouseCapture]; +} + +- (void)windowWillMiniaturize:(NSNotification *)notification { + (void)notification; + [self releaseMouseCapture]; +} + - (void)windowWillClose:(NSNotification *)notification { self.userClosed = YES; /* mark closed (X button or programmatic) before teardown */ [self teardown]; @@ -1284,25 +2004,26 @@ - (void)windowWillClose:(NSNotification *)notification { - (void)teardown { if (_stop) return; + [self releaseMouseCapture]; + [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). */ 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. */ 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 +2038,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; } } @@ -1561,7 +2285,12 @@ - (void)recordMove:(NSEvent *)e { IddDisplayWindow *o = self.owner; if (!o) return; - NSPoint p = [self convertPoint:e.locationInWindow fromView:nil]; + if ([o recordRelativeMove:e]) return; + [self recordAbsoluteMove:[self convertPoint:e.locationInWindow fromView:nil]]; +} +- (void)recordAbsoluteMove:(NSPoint)p { + IddDisplayWindow *o = self.owner; + if (!o) return; NSRect b = self.bounds; if (b.size.width < 1 || b.size.height < 1) return; uint32_t gw = [o frameWidth] ? [o frameWidth] : 1920; @@ -1598,34 +2327,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..20c50ca 100644 --- a/src/backend_mac/iso_patch_mac.m +++ b/src/backend_mac/iso_patch_mac.m @@ -2,6 +2,7 @@ #import "vm_dir.h" /* AppSandbox support root (sibling of VMs/) for the driver cache */ #import #import +#import #import "../../tools/provision/win_provision.h" /* shared answer-file generator (same source the Windows backend uses) */ #import "../../tools/provision/mac_account_hash.h" /* shared macOS ShadowHash + kcpassword encoders */ @@ -14,6 +15,50 @@ static AuthorizationRef g_auth = NULL; static dispatch_source_t g_authKeepAlive = NULL; +static NSString *windows_input_locale(void) { + TISInputSourceRef source = TISCopyCurrentKeyboardInputSource(); + if (!source) return nil; + NSString *sourceID = [(__bridge NSString *)TISGetInputSourceProperty(source, + kTISPropertyInputSourceID) copy]; + CFRelease(source); + NSDictionary *layouts = @{ + @"com.apple.keylayout.ABC": @"0409:00000409", + @"com.apple.keylayout.US": @"0409:00000409", + @"com.apple.keylayout.USInternational-PC": @"0409:00020409", + @"com.apple.keylayout.Dvorak": @"0409:00010409", + @"com.apple.keylayout.Dvorak-Left": @"0409:00030409", + @"com.apple.keylayout.Dvorak-Right": @"0409:00040409", + @"com.apple.keylayout.British": @"0809:00000809", + @"com.apple.keylayout.British-PC": @"0809:00000809", + @"com.apple.keylayout.German": @"0407:00000407", + @"com.apple.keylayout.German-DIN-2137": @"0407:00000407", + @"com.apple.keylayout.Austrian": @"0c07:00000407", + @"com.apple.keylayout.SwissGerman": @"0807:00000807", + @"com.apple.keylayout.French": @"040c:0000040c", + @"com.apple.keylayout.French-PC": @"040c:0000040c", + @"com.apple.keylayout.CanadianFrench-PC": @"0c0c:00001009", + @"com.apple.keylayout.SwissFrench": @"100c:0000100c", + @"com.apple.keylayout.Spanish-ISO": @"0c0a:0000040a", + @"com.apple.keylayout.Italian-Pro": @"0410:00000410", + @"com.apple.keylayout.Portuguese": @"0816:00000816", + @"com.apple.keylayout.Brazilian-ABNT2": @"0416:00010416", + @"com.apple.keylayout.Russian": @"0419:00000419", + @"com.apple.keylayout.RussianWin": @"0419:00000419", + @"com.apple.keylayout.PolishPro": @"0415:00000415", + @"com.apple.inputmethod.SCIM.ITABC": @"zh-CN", + @"com.apple.inputmethod.TCIM.Zhuyin": @"zh-TW", + @"com.apple.inputmethod.TCIM.Cangjie": @"0404:{531FDEBF-9B4C-4A43-A2AA-960E8FCDC732}{4BDF9F03-C7D3-11D4-B2AB-0080C882687E}", + @"com.apple.inputmethod.TCIM.Sucheng": @"0404:{531FDEBF-9B4C-4A43-A2AA-960E8FCDC732}{6024B45F-5C54-11D4-B921-0080C882687E}", + @"com.apple.inputmethod.Korean.2SetKorean": @"ko-KR" + }; + NSString *locale = sourceID ? layouts[sourceID] : nil; + if (!locale && [sourceID hasPrefix:@"com.apple.inputmethod.Kotoeri."]) + locale = @"ja-JP"; + if (sourceID && !locale) + NSLog(@"Windows keyboard: no input profile mapping for %@", sourceID); + return locale; +} + /* Keep-alive interval: shorter than the default 300s TTL of * kAuthorizationRightExecute so the right never expires while the app * runs. Without this, a long install (>5 min) causes AEWP to prompt a @@ -392,6 +437,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], @@ -409,13 +455,14 @@ + (void)installMacOSWithName:(NSString *)name * This is the exact mirror of generate_unattend_vhdx -> asb_provision_unattend in the Windows * backend (src/backend_win/disk_util.c / asb_core.c vhdx_create_thread). */ static BOOL write_unattend_xml(NSString *path, NSString *vmName, NSString *user, - NSString *pass, NSString *lang, BOOL testMode) { + NSString *pass, NSString *lang, NSString *inputLocale, BOOL testMode) { FILE *fu = fopen(path.fileSystemRepresentation, "wb"); if (!fu) return NO; int rc = asb_provision_unattend(fu, vmName.UTF8String, user.UTF8String, pass.length ? pass.UTF8String : "", "arm64", testMode ? 1 : 0, /*is_arm64=*/1, - lang.length ? lang.UTF8String : "en-US"); + lang.length ? lang.UTF8String : "en-US", + inputLocale.UTF8String); fclose(fu); return rc == 0; } @@ -461,8 +508,9 @@ + (void)buildWindowsDiskWithISO:(NSURL *)isoURL ? sshMsiPath.lastPathComponent : nil; BOOL hasOverride = (lang.length > 0); NSString *initialLang = hasOverride ? lang : @"en-US"; + NSString *inputLocale = windows_input_locale(); if (!write_unattend_xml([provDir stringByAppendingPathComponent:@"unattend.xml"], - vmName, adminUser, adminPass, initialLang, testMode) || + vmName, adminUser, adminPass, initialLang, inputLocale, testMode) || !write_prov_scripts(provDir, sshMsiName)) { [fm removeItemAtPath:provDir error:nil]; completion([NSError errorWithDomain:@"IsoPatchMac" code:7 @@ -493,7 +541,7 @@ + (void)buildWindowsDiskWithISO:(NSURL *)isoURL if (frac == ISO_PATCH_PROGRESS_LANG) { if (!hasOverride) { (void)write_unattend_xml([provDir stringByAppendingPathComponent:@"unattend.xml"], - vmName, adminUser, adminPass, step, testMode); + vmName, adminUser, adminPass, step, inputLocale, testMode); } if (progressBlock) progressBlock(ISO_PATCH_PROGRESS_LOG, @@ -550,8 +598,8 @@ + (nullable NSString *)ensureSignedWinPayloadZipCached { /* Signed ARM64 release: EV-signed agent EXEs + MS-attestation-signed drivers (VDD/VAD/AppSandboxSHM/ * devcon). The release version is pinned in the cached filename, so a newer release auto-invalidates * the cache. The URL must stay publicly fetchable by an unauthenticated client. */ - NSString *name = @"AppSandbox-0.1.4-win-arm64.zip"; - NSString *urlStr = @"https://github.com/jamesstringer90/appsandbox/releases/download/v0.1.4/AppSandbox-0.1.4-win-arm64.zip"; + NSString *name = @"AppSandbox-0.1.5-win-arm64.zip"; + NSString *urlStr = @"https://github.com/jamesstringer90/appsandbox/releases/download/v0.1.5/AppSandbox-0.1.5-win-arm64.zip"; NSString *cacheDir = [[VmDir vmsRootDirectory] URLByDeletingLastPathComponent].path; [[NSFileManager defaultManager] createDirectoryAtPath:cacheDir withIntermediateDirectories:YES attributes:nil error:nil]; 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_mac/vz_network.h b/src/backend_mac/vz_network.h index 2e60b32..56d85f8 100644 --- a/src/backend_mac/vz_network.h +++ b/src/backend_mac/vz_network.h @@ -9,7 +9,6 @@ NS_ASSUME_NONNULL_BEGIN @interface VzNetwork : NSObject -/* NAT-attached virtio network device. Works out of the box, no entitlements. */ + (VZVirtioNetworkDeviceConfiguration *)natConfiguration; /* Bridged network device for the given interface identifier (e.g. "en0") diff --git a/src/backend_win/asb_core.c b/src/backend_win/asb_core.c index dc7e0f5..e417aec 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,45 @@ 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; + 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 +575,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 +590,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 +679,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 +701,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 +720,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 +768,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 +797,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) ---- */ @@ -761,7 +828,6 @@ static void asb_hcs_state_changed(VmInstance *instance, DWORD event) asb_log(L"VM \"%s\" exited (event=0x%08X).", instance->name, event); asb_vm_cleanup_network(instance); - instance->nat_ip[0] = '\0'; hcs_close_vm(instance); /* Template finalization */ @@ -783,6 +849,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]; @@ -852,13 +926,12 @@ static BOOL allocate_nat_ip(VmInstance *vm) { BOOL used[256] = { 0 }; const char *base = hcn_nat_subnet_base(); /* e.g. "192.168.42" */ - int ba, bb, bc; + int ba, bb, bc, a, b, c, d; int i, octet; if (sscanf_s(base, "%d.%d.%d", &ba, &bb, &bc) != 3) return FALSE; for (i = 0; i < g_vm_count; i++) { - int a, b, c, d; if (&g_vms[i] == vm) continue; if (g_vms[i].nat_ip[0] == '\0') continue; /* Only consider entries that share our current /24. Stale entries @@ -869,6 +942,10 @@ static BOOL allocate_nat_ip(VmInstance *vm) used[d] = TRUE; } + if (sscanf_s(vm->nat_ip, "%d.%d.%d.%d", &a, &b, &c, &d) == 4 && + a == ba && b == bb && c == bc && d >= 2 && d <= 254 && !used[d]) + return TRUE; + for (octet = 2; octet <= 254; octet++) { if (!used[octet]) { sprintf_s(vm->nat_ip, sizeof(vm->nat_ip), "%s.%d", base, octet); @@ -896,7 +973,7 @@ static HRESULT try_endpoint_with_retry(const GUID *net_id, GUID *ep_id, int retry; hr = hcn_create_endpoint(net_id, ep_id, ep_guid_str, str_len, - (nat_ip && nat_ip[0]) ? nat_ip : NULL); + (is_nat && nat_ip && nat_ip[0]) ? nat_ip : NULL); if (SUCCEEDED(hr) || !is_nat || !nat_ip || !nat_ip[0]) return hr; asb_log(L"Endpoint failed for %S, trying next IP...", nat_ip); @@ -936,8 +1013,6 @@ static DWORD WINAPI start_vm_thread(LPVOID param) } else { asb_log(L"Warning: NAT IP pool exhausted."); } - } else { - vm->nat_ip[0] = '\0'; } if (args->network_mode != NET_NONE) { @@ -974,8 +1049,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); @@ -1033,6 +1110,7 @@ typedef struct { wchar_t error_msg[512]; VmInstance *vm_inst; wchar_t language[32]; + wchar_t input_locale[128]; BOOL vhdx_created; } VhdxCreateArgs; @@ -1068,7 +1146,8 @@ static DWORD WINAPI vhdx_create_thread(LPVOID param) } } else { if (!generate_unattend_vhdx(file_path, args->config.name, args->config.admin_user, - args->config.admin_pass, args->config.test_mode, L"en-US")) { + args->config.admin_pass, args->config.test_mode, L"en-US", + args->input_locale)) { args->result = E_FAIL; wcscpy_s(args->error_msg, 512, L"Failed to generate unattend.xml"); goto done; @@ -1184,7 +1263,8 @@ static DWORD WINAPI vhdx_create_thread(LPVOID param) swprintf_s(unattend_path, MAX_PATH, L"%s\\unattend.xml", stg); generate_unattend_vhdx(unattend_path, args->config.name, args->config.admin_user, args->config.admin_pass, - args->config.test_mode, args->language); + args->config.test_mode, args->language, + args->input_locale); } } else if (strncmp(line, "DONE:", 5) == 0) { args->result = S_OK; @@ -1486,11 +1566,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; } /* ==================================================================== @@ -1611,6 +1694,51 @@ static void langid_to_xkb(WORD langid, char *out, size_t out_sz) if ((WORD)(map[i].id & 0x3ff) == prim) { strcpy_s(out, out_sz, map[i].xkb); return; } } +static void host_keyboard_to_linux(DWORD klid, WORD language, + char *layout, size_t layout_sz, + char *variant, size_t variant_sz, + char *input_method, size_t input_method_sz) +{ + static const struct { DWORD klid; const char *layout, *variant; } variants[] = { + { 0x00010409, "us", "dvorak" }, { 0x00020409, "us", "intl" }, + { 0x00000452, "gb", "extd" }, { 0x0000100c, "ch", "fr" }, + { 0x00001009, "ca", "" }, { 0x00000c0c, "ca", "fr-legacy" }, + { 0x00011009, "ca", "multix" }, { 0x0001041f, "tr", "f" }, + { 0x00010419, "ru", "typewriter" }, { 0x00010405, "cz", "qwerty" }, + { 0x00010415, "pl", "qwertz" }, { 0x00010410, "it", "ibm" }, + }; + langid_to_xkb((WORD)klid, layout, layout_sz); + variant[0] = input_method[0] = '\0'; + for (int i = 0; i < ARRAYSIZE(variants); i++) { + if (variants[i].klid == klid) { + strcpy_s(layout, layout_sz, variants[i].layout); + strcpy_s(variant, variant_sz, variants[i].variant); + break; + } + } + switch (PRIMARYLANGID(language)) { + case LANG_CHINESE: + strcpy_s(layout, layout_sz, "us"); + strcpy_s(input_method, input_method_sz, + language == 0x0804 || language == 0x1004 ? "libpinyin" : "chewing"); + break; + case LANG_JAPANESE: { + DWORD thread = GetWindowThreadProcessId(GetForegroundWindow(), NULL); + HKL keyboard = GetKeyboardLayout(thread); + /* Japanese IME supports both JIS and US physical keyboards. */ + strcpy_s(layout, layout_sz, + MapVirtualKeyExW(0x1a, MAPVK_VSC_TO_VK_EX, keyboard) == VK_OEM_3 ? "jp" : "us"); + strcpy_s(input_method, input_method_sz, "mozc-jp"); + break; + } + case LANG_KOREAN: + strcpy_s(layout, layout_sz, "kr"); + strcpy_s(variant, variant_sz, "kr104"); + strcpy_s(input_method, input_method_sz, "hangul"); + break; + } +} + /* Map a Windows time-zone key name to an IANA zone. Never gated — all * zones ship in tzdata. Fallback "Etc/UTC". Subset of CLDR windowsZones * covering the common zones; unknown keys fall back to UTC. */ @@ -1680,6 +1808,8 @@ static void win_tz_to_iana(const wchar_t *keyname, char *out, size_t out_sz) * failure leaves the corresponding output at its safe default. */ static void detect_host_locale_settings(char *locale, size_t locale_sz, char *xkb, size_t xkb_sz, + char *variant, size_t variant_sz, + char *input_method, size_t input_method_sz, char *tz, size_t tz_sz) { /* Locale (gated). */ @@ -1693,12 +1823,13 @@ static void detect_host_locale_settings(char *locale, size_t locale_sz, } /* Keyboard (not gated). */ { - HKL list[16]; - int n = GetKeyboardLayoutList(16, list); - WORD langid = 0x0409; - if (n > 0) langid = (WORD)((UINT_PTR)list[0] & 0xFFFF); - langid_to_xkb(langid, xkb, xkb_sz); - asb_log(L"Host keyboard: langid=0x%04x -> %hs", langid, xkb); + DWORD klid; + WORD langid; + wchar_t input_locale[128]; + get_host_keyboard_settings(input_locale, ARRAYSIZE(input_locale), &klid, &langid); + host_keyboard_to_linux(klid, langid, xkb, xkb_sz, + variant, variant_sz, input_method, input_method_sz); + asb_log(L"Host keyboard: %s -> %hs %hs %hs", input_locale, xkb, variant, input_method); } /* Timezone (not gated). */ { @@ -1721,10 +1852,16 @@ static int generate_vhdx_manifest_ubuntu(const wchar_t *manifest_path, const char *admin_pw_hash, const char *host_locale, const char *host_xkb, + const char *host_variant, + const char *host_input_method, const char *host_tz, 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 +1880,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) { @@ -1779,6 +1918,14 @@ static int generate_vhdx_manifest_ubuntu(const wchar_t *manifest_path, n += stage_marker_file(f, staging, L"keyboard.marker", host_xkb, strlen(host_xkb), L"/etc/appsandbox-keyboard"); + if (host_variant && host_variant[0]) + n += stage_marker_file(f, staging, L"keyboard-variant.marker", + host_variant, strlen(host_variant), + L"/etc/appsandbox-keyboard-variant"); + if (host_input_method && host_input_method[0]) + n += stage_marker_file(f, staging, L"input-method.marker", + host_input_method, strlen(host_input_method), + L"/etc/appsandbox-input-method"); if (host_tz && host_tz[0]) n += stage_marker_file(f, staging, L"timezone.marker", host_tz, strlen(host_tz), @@ -1802,7 +1949,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; } @@ -2174,6 +2325,11 @@ typedef struct { wchar_t error_msg[512]; VmInstance *vm_inst; BOOL vhdx_created; + char host_locale[64]; + char host_xkb[32]; + char host_variant[32]; + char host_input_method[64]; + char host_tz[64]; } LinuxCreateArgs; static DWORD WINAPI linux_create_thread(LPVOID param) @@ -2212,13 +2368,11 @@ static DWORD WINAPI linux_create_thread(LPVOID param) swprintf_s(extras, MAX_PATH, L"%s\\extras", staging); CreateDirectoryW(extras, NULL); - /* Prefetch 1: repo source from GitHub. Writes agent-src/, + /* Prefetch 1: Linux sources from GitHub. Writes agent-src/, asb_drm-src/, dxgkrnl-src/, systemd/, modprobe.d-asb_drm.conf, 50-appsandbox-gpu, org.gnome.Shell-no-gpu.conf, appsandbox-gpu, wsl-mesa.tar.zst directly into /extras/. */ - asb_log(L"Prefetch 1/3: cloning repo source from GitHub..."); - /* Branch must match the branch this binary was built from, so the Linux - guest builds its agent/driver source from the SAME revision. */ + asb_log(L"Prefetch 1/3: downloading Linux sources from GitHub..."); swprintf_s(args_buf, 2048, L"--prefetch-repo --branch \"main\" --out-dir \"%s\"", extras); @@ -2263,32 +2417,26 @@ 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)); - /* Read the current user's Windows regional settings and translate to - * Linux. Language is gated to the 8 ISO-preinstalled languages - * (else en_US.UTF-8); keyboard + timezone always match the host. */ - char host_locale[64], host_xkb[32], host_tz[64]; - detect_host_locale_settings(host_locale, sizeof(host_locale), - host_xkb, sizeof(host_xkb), - host_tz, sizeof(host_tz)); - int n_staged = generate_vhdx_manifest_ubuntu(manifest, staging, res_dir, args->config.ssh_enabled, args->config.admin_user, admin_pw_hash, - host_locale, host_xkb, host_tz, + args->host_locale, args->host_xkb, + args->host_variant, args->host_input_method, + args->host_tz, args->config.name); SecureZeroMemory(admin_pw_hash, sizeof(admin_pw_hash)); if (n_staged < 0) { @@ -2601,6 +2749,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 +3001,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 +3073,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 +3134,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 +3159,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 +3194,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) ---- */ @@ -2824,6 +3252,8 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) wcscpy_s(args->vhdx_dir, MAX_PATH, vhdx_dir); wcscpy_s(args->net_adapter, 256, inst->net_adapter); + get_host_keyboard_settings(args->input_locale, ARRAYSIZE(args->input_locale), NULL, NULL); + asb_log(L"Building VHDX for \"%s\" (this may take several minutes)...", cfg.name); CloseHandle(CreateThread(NULL, 0, vhdx_create_thread, args, 0, NULL)); @@ -2891,15 +3321,15 @@ ASB_API HRESULT asb_vm_create(const AsbVmConfig *config) wcscpy_s(args->vhdx_dir, MAX_PATH, vhdx_dir); wcscpy_s(args->net_adapter, 256, inst->net_adapter); + detect_host_locale_settings(args->host_locale, sizeof(args->host_locale), + args->host_xkb, sizeof(args->host_xkb), + args->host_variant, sizeof(args->host_variant), + args->host_input_method, sizeof(args->host_input_method), + args->host_tz, sizeof(args->host_tz)); + 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 +3347,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 +3394,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 +3405,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 +3479,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 +3562,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)); @@ -3211,7 +3680,6 @@ ASB_API HRESULT asb_vm_stop(AsbVm vm) hcs_close_vm(inst); asb_vm_cleanup_network(inst); - inst->nat_ip[0] = '\0'; asb_log(L"VM \"%s\" terminated.", inst->name); save_vm_list(); @@ -3226,12 +3694,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 +3723,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 +3751,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 +4108,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..aa29c49 100644 --- a/src/backend_win/disk_util.c +++ b/src/backend_win/disk_util.c @@ -3,6 +3,7 @@ #include "../../tools/provision/win_provision.h" #include #include +#include #include #include #include @@ -114,6 +115,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 +291,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) { @@ -226,6 +347,109 @@ static const wchar_t *lang_to_input_locale(const wchar_t *lang) return L"0409:00000409"; /* fallback */ } +static BOOL system_keyboard_layout(DWORD klid) +{ + wchar_t key[96], file[MAX_PATH], path[MAX_PATH]; + DWORD size = sizeof(file), attributes; + UINT len; + + if (!klid || (klid & 0xF0000000)) return FALSE; + swprintf_s(key, ARRAYSIZE(key), + L"SYSTEM\\CurrentControlSet\\Control\\Keyboard Layouts\\%08X", klid); + if (RegGetValueW(HKEY_LOCAL_MACHINE, key, L"Layout File", RRF_RT_REG_SZ, + NULL, file, &size) != ERROR_SUCCESS || + _wcsnicmp(file, L"kbd", 3) != 0 || wcspbrk(file, L"\\/:") != NULL) + return FALSE; + len = GetSystemDirectoryW(path, ARRAYSIZE(path)); + if (!len || len + wcslen(file) + 2 > ARRAYSIZE(path)) return FALSE; + wcscat_s(path, ARRAYSIZE(path), L"\\"); + wcscat_s(path, ARRAYSIZE(path), file); + attributes = GetFileAttributesW(path); + return attributes != INVALID_FILE_ATTRIBUTES && !(attributes & FILE_ATTRIBUTE_DIRECTORY); +} + +static DWORD keyboard_layout_id(HKL layout) +{ + wchar_t name[KL_NAMELENGTH]; + DWORD klid = 0; + WORD device = HIWORD((ULONG_PTR)layout); + HKEY layouts; + + if (layout == GetKeyboardLayout(0) && GetKeyboardLayoutNameW(name)) { + klid = wcstoul(name, NULL, 16); + if (system_keyboard_layout(klid)) return klid; + klid = 0; + } + if ((device & 0xF000) != 0xF000) + return system_keyboard_layout(device) ? device : 0; + + /* Variant HKLs contain a registry Layout Id, not the full KLID. */ + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, + L"SYSTEM\\CurrentControlSet\\Control\\Keyboard Layouts", + 0, KEY_READ, &layouts) == ERROR_SUCCESS) { + DWORD index; + for (index = 0;; index++) { + wchar_t id[16]; + DWORD length = ARRAYSIZE(name), size = sizeof(id); + LONG result = RegEnumKeyExW(layouts, index, name, &length, + NULL, NULL, NULL, NULL); + if (result == ERROR_MORE_DATA) continue; + if (result != ERROR_SUCCESS) break; + if (RegGetValueW(layouts, name, L"Layout Id", RRF_RT_REG_SZ, + NULL, id, &size) == ERROR_SUCCESS && + wcstoul(id, NULL, 16) == (DWORD)(device & 0x0FFF)) { + DWORD candidate = wcstoul(name, NULL, 16); + if (system_keyboard_layout(candidate)) { + klid = candidate; + break; + } + } + } + RegCloseKey(layouts); + } + return klid; +} + +void get_host_keyboard_settings(wchar_t *input_locale, size_t size, + DWORD *klid, WORD *langid) +{ + HWND foreground = GetForegroundWindow(); + DWORD thread = foreground ? GetWindowThreadProcessId(foreground, NULL) : 0; + HKL layout = thread ? GetKeyboardLayout(thread) : NULL; + WORD language; + DWORD keyboard; + wchar_t locale[LOCALE_NAME_MAX_LENGTH], result[128]; + const wchar_t *ime = NULL; + + if (!layout) layout = GetKeyboardLayout(0); + language = layout ? LOWORD((ULONG_PTR)layout) : GetUserDefaultLangID(); + keyboard = layout ? keyboard_layout_id(layout) : 0; + switch (language) { + case 0x0411: ime = L"ja-JP"; break; + case 0x0412: ime = L"ko-KR"; break; + case 0x0804: case 0x1004: ime = L"zh-CN"; break; + case 0x0404: case 0x0C04: case 0x1404: ime = L"zh-TW"; break; + } + if (ime) { + wcscpy_s(result, ARRAYSIZE(result), ime); + if (!keyboard) + keyboard = PRIMARYLANGID(language) == LANG_CHINESE ? 0x0409 : language; + } else { + if (!keyboard) { + const wchar_t *fallback; + if (!LCIDToLocaleName(MAKELCID(language, SORT_DEFAULT), locale, + ARRAYSIZE(locale), 0)) + wcscpy_s(locale, ARRAYSIZE(locale), L"en-US"); + fallback = wcschr(lang_to_input_locale(locale), L':'); + keyboard = wcstoul(fallback + 1, NULL, 16); + } + swprintf_s(result, ARRAYSIZE(result), L"%04X:%08X", language, keyboard); + } + if (input_locale && size) wcsncpy_s(input_locale, size, result, _TRUNCATE); + if (klid) *klid = keyboard; + if (langid) *langid = language; +} + #if ASB_IS_ARM64 /* ARM Windows HCS exposes no vTPM, so the Windows 11 Setup hardware checks are bypassed via the HKLM\SYSTEM\Setup\LabConfig keys (x64 gets a real vTPM and @@ -262,7 +486,12 @@ 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]; + wchar_t input_locale[128]; + get_host_keyboard_settings(input_locale, ARRAYSIZE(input_locale), NULL, NULL); + 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) @@ -313,7 +542,7 @@ static BOOL generate_autounattend(const wchar_t *output_path, L" 33NTFSC\n" L" \n" L" \n", - lang, lang_to_input_locale(lang), lang, lang, lang); + lang, input_locale, lang, lang, lang); #if ASB_IS_ARM64 /* windowsPE Setup pass — bypass keys must run before the Win11 appraiser. */ @@ -471,9 +700,9 @@ static BOOL generate_autounattend(const wchar_t *output_path, L" \n" L" \n" L"\n", - lang_to_input_locale(lang), lang, lang, lang, - admin_user, b64_password, - admin_user, b64_password); + input_locale, lang, lang, lang, + user_xml, b64_password, + user_xml, b64_password); } fclose(f); @@ -825,7 +1054,13 @@ 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]; + wchar_t input_locale[128]; + get_host_keyboard_settings(input_locale, ARRAYSIZE(input_locale), NULL, NULL); + 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 +1082,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" @@ -940,12 +1176,14 @@ static BOOL generate_unattend_instance(const wchar_t *output_path, L" \n" L"\n", comp_name, - lang_to_input_locale(lang), lang, lang, lang, - admin_user, b64_password, - admin_user, b64_password); - - fclose(f); - return TRUE; + input_locale, lang, lang, lang, + user_xml, b64_password, + user_xml, b64_password); + + written = !ferror(f); + if (fclose(f) != 0) + written = FALSE; + return written; } /* Helper: copy agent exe to staging and write setup.cmd */ @@ -1337,8 +1575,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); @@ -1375,10 +1617,11 @@ BOOL generate_unattend_vhdx(const wchar_t *output_path, const wchar_t *admin_user, const wchar_t *admin_pass, BOOL test_mode, - const wchar_t *lang) + const wchar_t *lang, + const wchar_t *input_locale) { FILE *f; - char vm_u[64], user_u[256], pass_u[512], lang_u[32]; + char vm_u[64], user_u[256], pass_u[512], lang_u[32], input_u[128]; int rc; if (!output_path || !vm_name || !admin_user || !admin_pass) @@ -1390,7 +1633,9 @@ BOOL generate_unattend_vhdx(const wchar_t *output_path, if (!WideCharToMultiByte(CP_UTF8, 0, vm_name, -1, vm_u, sizeof vm_u, NULL, NULL) || !WideCharToMultiByte(CP_UTF8, 0, admin_user, -1, user_u, sizeof user_u, NULL, NULL) || !WideCharToMultiByte(CP_UTF8, 0, admin_pass, -1, pass_u, sizeof pass_u, NULL, NULL) || - !WideCharToMultiByte(CP_UTF8, 0, lang ? lang : L"en-US", -1, lang_u, sizeof lang_u, NULL, NULL)) { + !WideCharToMultiByte(CP_UTF8, 0, lang ? lang : L"en-US", -1, lang_u, sizeof lang_u, NULL, NULL) || + !WideCharToMultiByte(CP_UTF8, 0, input_locale ? input_locale : L"", -1, + input_u, sizeof input_u, NULL, NULL)) { SecureZeroMemory(pass_u, sizeof pass_u); return FALSE; } @@ -1402,7 +1647,7 @@ BOOL generate_unattend_vhdx(const wchar_t *output_path, rc = asb_provision_unattend(f, vm_u, user_u, pass_u, ASB_IS_ARM64 ? "arm64" : "amd64", - test_mode ? 1 : 0, ASB_IS_ARM64, lang_u); + test_mode ? 1 : 0, ASB_IS_ARM64, lang_u, input_u); fclose(f); SecureZeroMemory(pass_u, sizeof pass_u); @@ -1862,10 +2107,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..3a0707f 100644 --- a/src/backend_win/disk_util.h +++ b/src/backend_win/disk_util.h @@ -20,10 +20,18 @@ 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); +void get_host_keyboard_settings(wchar_t *input_locale, size_t size, + DWORD *klid, WORD *langid); + /* Create a resources ISO containing autounattend.xml, agent, and helper executables for unattended Windows install with GPU-PV. GPU driver file copy is handled by the agent service's embedded 9P client (p9copy). @@ -62,7 +70,8 @@ BOOL generate_unattend_vhdx(const wchar_t *output_path, const wchar_t *admin_user, const wchar_t *admin_pass, BOOL test_mode, - const wchar_t *lang); + const wchar_t *lang, + const wchar_t *input_locale); /* Generate unattend.xml for VHDX-first *template* boot. Boots into audit mode, runs sysprep /generalize /oobe /shutdown /mode:vm. 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..09b7b41 100644 --- a/src/backend_win/vm_display_idd.c +++ b/src/backend_win/vm_display_idd.c @@ -31,6 +31,7 @@ #include #include "vm_display_idd.h" +#include "../core/protocol.h" #include "vm_clipboard.h" #include "vm_agent.h" #include "hcs_vm.h" @@ -105,36 +106,13 @@ typedef struct AudioFrameHeader { #define MAX_DIRTY_RECTS 64 #define MAX_FRAME_DATA_SIZE (DEFAULT_WIDTH * DEFAULT_HEIGHT * 4) -/* ---- Input protocol (host → guest) ---- */ - -#define INPUT_MAGIC 0x4E495341 /* "ASIN" little-endian */ -#define INPUT_MOUSE_MOVE 0 -#define INPUT_MOUSE_BUTTON 1 -#define INPUT_MOUSE_WHEEL 2 -#define INPUT_KEY 3 - -/* Button IDs for INPUT_MOUSE_BUTTON */ -#define INPUT_BTN_LEFT 0 -#define INPUT_BTN_RIGHT 1 -#define INPUT_BTN_MIDDLE 2 - -#define INPUT_READY_MAGIC 0x59445249 /* "IRDY" little-endian */ - -#pragma pack(push, 1) -typedef struct InputPacket { - UINT32 magic; /* INPUT_MAGIC */ - UINT32 type; /* INPUT_MOUSE_MOVE / BUTTON / WHEEL / KEY */ - UINT32 param1; - UINT32 param2; - UINT32 param3; -} InputPacket; -#pragma pack(pop) - /* ---- Window messages ---- */ #define WM_VM_DISPLAY_CLOSED (WM_APP + 5) #define WM_IDD_FRAME_READY (WM_USER + 100) #define WM_IDD_FOCUS (WM_USER + 101) +#define WM_IDD_INPUT_READY (WM_USER + 102) +#define WM_IDD_CURSOR_CHANGED (WM_USER + 103) /* Timer for Present cadence when no frames arrive */ #define IDT_PRESENT 2001 @@ -235,6 +213,23 @@ struct VmDisplayIdd { /* Input forwarding */ volatile SOCKET input_socket; /* input socket for keyboard/mouse forwarding */ + SRWLOCK input_lock; + volatile LONG keyboard_version; + volatile LONG mouse_version; + volatile BOOL frame_connected; + BOOL relative_mouse; + BOOL input_sizing; + UINT mouse_buttons; + BOOL raw_absolute_valid; + HANDLE raw_absolute_device; + POINT raw_absolute_position; + BOOL mouse_sync_pending; + UINT32 mouse_sync_id; + ULONGLONG mouse_sync_deadline; + POINT mouse_sync_origin; + SOCKET mouse_sync_socket; + InputPacket mouse_reply; + int mouse_reply_size; BOOL mouse_in; /* TRUE while cursor is inside the render area */ BOOL tracking; /* TrackMouseEvent active */ @@ -243,12 +238,11 @@ struct VmDisplayIdd { volatile BOOL transmit_hotkeys; /* TRUE = capture host hotkeys + send to guest */ volatile BOOL input_focused; /* TRUE while our top-level window is active */ HHOOK kbd_hook; /* WH_KEYBOARD_LL handle, NULL when not installed */ - /* Held-key tracking (window-thread only, no lock). Indexed by virtual key. - held_down[vk]=1 means we forwarded a down with no matching up yet; the - saved scan/ext let us synthesize an accurate up when flushing. */ - BYTE held_down[256]; - BYTE held_scan[256]; - BYTE held_ext[256]; + /* Legacy keys use VK indices; physical keys use scan/E0 or 512 + function VK. */ + BYTE held_down[768]; + InputPacket held_keys[768]; + BYTE hook_routes[768]; + BOOL input_menu_active; /* Guest cursor */ HCURSOR guest_cursor; /* current cursor created from guest bitmap */ @@ -278,6 +272,11 @@ struct VmDisplayIdd { static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp); static DWORD WINAPI idd_window_thread_proc(LPVOID param); static DWORD WINAPI idd_recv_thread_proc(LPVOID param); +static void idd_update_relative_mouse(VmDisplayIdd *d); +static void idd_resume_absolute_mouse(VmDisplayIdd *d, const InputPacket *reply); +static void idd_poll_mouse_position(VmDisplayIdd *d); +static void window_to_vm_coords(HWND hwnd, int wx, int wy, UINT vm_w, UINT vm_h, + UINT *vx, UINT *vy); /* ---- Window class ---- */ @@ -291,6 +290,8 @@ static const wchar_t *IDD_LOG_CLASS = L"AppSandboxIddLog"; #define IDM_SHOW_LOG 0x1020 static BOOL g_idd_class_registered; static WNDPROC g_orig_listbox_proc; +static SRWLOCK g_mouse_capture_lock = SRWLOCK_INIT; +static HWND g_mouse_capture_hwnd; /* Listbox subclass — handles Ctrl+A / Ctrl+C */ static LRESULT CALLBACK idd_log_listbox_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) @@ -483,16 +484,14 @@ static void idd_log(VmDisplayIdd *d, const wchar_t *fmt, ...) /* ---- Send input packet to guest ---- */ -static UINT g_input_send_count = 0; - -static void send_input(VmDisplayIdd *d, UINT32 type, UINT32 p1, UINT32 p2, UINT32 p3) +static BOOL send_input_locked(VmDisplayIdd *d, UINT32 type, UINT32 p1, UINT32 p2, UINT32 p3) { InputPacket pkt; SOCKET s; int ret; s = d->input_socket; - if (s == INVALID_SOCKET) return; + if (s == INVALID_SOCKET) return FALSE; pkt.magic = INPUT_MAGIC; pkt.type = type; @@ -505,14 +504,13 @@ static void send_input(VmDisplayIdd *d, UINT32 type, UINT32 p1, UINT32 p2, UINT3 if (ret == SOCKET_ERROR) { int err = WSAGetLastError(); if (err == WSAEWOULDBLOCK) { - /* Send buffer full: drop this packet (as the comment intends) - without tearing down the socket. */ - return; + return FALSE; } idd_log(d, L"INPUT SEND ERR %d - flagging for reconnect.", err); /* Mark dead — recv thread owns the socket and will close + reconnect */ d->input_socket = INVALID_SOCKET; - return; + PostMessageW(d->hwnd, WM_IDD_INPUT_READY, 0, 0); + return FALSE; } if (ret != (int)sizeof(pkt)) { /* Partial send on a stream socket: the guest reads fixed-size 20-byte @@ -521,19 +519,112 @@ static void send_input(VmDisplayIdd *d, UINT32 type, UINT32 p1, UINT32 p2, UINT3 idd_log(d, L"INPUT SEND short (%d/%d) - flagging for reconnect.", ret, (int)sizeof(pkt)); d->input_socket = INVALID_SOCKET; - return; + PostMessageW(d->hwnd, WM_IDD_INPUT_READY, 0, 0); + return FALSE; } - g_input_send_count++; + return TRUE; +} - /* Log non-move events only (moves are too noisy) */ - if (type != INPUT_MOUSE_MOVE) { - static const wchar_t *type_names[] = { - L"MOUSE_MOVE", L"MOUSE_BTN", L"MOUSE_WHEEL", L"KEY" - }; - const wchar_t *name = type < 4 ? type_names[type] : L"?"; - idd_log(d, L"INPUT %s p1=%u p2=%u p3=%u (#%u)", name, p1, p2, p3, g_input_send_count); +static void send_input(VmDisplayIdd *d, UINT32 type, UINT32 p1, UINT32 p2, UINT32 p3) +{ + AcquireSRWLockExclusive(&d->input_lock); + send_input_locked(d, type, p1, p2, p3); + ReleaseSRWLockExclusive(&d->input_lock); +} + +static BOOL idd_clip_mouse(VmDisplayIdd *d) +{ + RECT rc; + if (!GetClientRect(d->render_hwnd, &rc) || IsRectEmpty(&rc)) return FALSE; + MapWindowPoints(d->render_hwnd, NULL, (POINT *)&rc, 2); + return ClipCursor(&rc); +} + +static void idd_cancel_mouse_sync(VmDisplayIdd *d) +{ + POINT point; + if (!d->mouse_sync_pending) return; + d->mouse_sync_pending = FALSE; + if (GetForegroundWindow() == d->hwnd && GetCursorPos(&point) && + WindowFromPoint(point) == d->render_hwnd) + SetCursor(!d->cursor_visible ? NULL : + d->guest_cursor ? d->guest_cursor : LoadCursorW(NULL, IDC_ARROW)); +} + +static void idd_update_relative_mouse(VmDisplayIdd *d) +{ + POINT pt = {0}; + BOOL active = !d->stop && d->frame_connected && + d->mouse_version == INPUT_MOUSE_VERSION && + d->input_socket != INVALID_SOCKET && d->input_focused && + !d->input_menu_active && !d->input_sizing && + GetForegroundWindow() == d->hwnd && !IsIconic(d->hwnd) && + GetCursorPos(&pt) && WindowFromPoint(pt) == d->render_hwnd; + BOOL capture = active && !d->cursor_visible; + BOOL sync_absolute = FALSE; + if (!active || capture) idd_cancel_mouse_sync(d); + AcquireSRWLockExclusive(&g_mouse_capture_lock); + if (capture == d->relative_mouse && + (!capture || g_mouse_capture_hwnd == d->hwnd)) { + ReleaseSRWLockExclusive(&g_mouse_capture_lock); + return; } + if (capture) { + RAWINPUTDEVICE mouse = { 0x01, 0x02, 0, d->hwnd }; + d->relative_mouse = FALSE; + if (RegisterRawInputDevices(&mouse, 1, sizeof(mouse))) { + if (idd_clip_mouse(d)) { + g_mouse_capture_hwnd = d->hwnd; + d->relative_mouse = TRUE; + d->mouse_in = TRUE; + d->raw_absolute_valid = FALSE; + SetCursor(NULL); + } else { + mouse.dwFlags = RIDEV_REMOVE; + mouse.hwndTarget = NULL; + RegisterRawInputDevices(&mouse, 1, sizeof(mouse)); + } + } + } else { + if (g_mouse_capture_hwnd == d->hwnd) { + sync_absolute = d->relative_mouse && active && d->cursor_visible; + RAWINPUTDEVICE mouse = { 0x01, 0x02, RIDEV_REMOVE, NULL }; + RegisterRawInputDevices(&mouse, 1, sizeof(mouse)); + ClipCursor(NULL); + g_mouse_capture_hwnd = NULL; + } + d->relative_mouse = FALSE; + d->raw_absolute_valid = FALSE; + } + ReleaseSRWLockExclusive(&g_mouse_capture_lock); + if (sync_absolute && d->frame_width && d->frame_height) { + if (d->mouse_version == INPUT_MOUSE_VERSION) { + if (++d->mouse_sync_id == 0) ++d->mouse_sync_id; + d->mouse_sync_origin = pt; + d->mouse_sync_deadline = GetTickCount64() + 250; + AcquireSRWLockExclusive(&d->input_lock); + d->mouse_sync_socket = d->input_socket; + d->mouse_sync_pending = send_input_locked(d, INPUT_MOUSE_POSITION_QUERY, + d->mouse_sync_id, 0, 0); + ReleaseSRWLockExclusive(&d->input_lock); + if (d->mouse_sync_pending) { + SetCursor(NULL); + return; + } + } + idd_resume_absolute_mouse(d, NULL); + } +} + +static void idd_flush_mouse_buttons(VmDisplayIdd *d) +{ + for (UINT button = INPUT_BTN_LEFT; button <= INPUT_BTN_MIDDLE; button++) { + if (d->mouse_buttons & (1u << button)) + send_input(d, INPUT_MOUSE_BUTTON, button, 0, 0); + } + d->mouse_buttons = 0; + if (GetCapture() == d->render_hwnd) ReleaseCapture(); } /* ================================================================== @@ -594,13 +685,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) { @@ -625,35 +711,60 @@ static BOOL idd_is_reserved_hotkey(DWORD vk, BOOL alt_down) change can release anything still down. Runs on the window thread only. */ static void idd_forward_key(VmDisplayIdd *d, DWORD vk, DWORD scan, BOOL ext, BOOL up) { - UINT32 flags = 0; - if (ext) flags |= 1; - if (up) flags |= 2; - send_input(d, INPUT_KEY, vk, scan, flags); - - if (vk < 256) { - if (up) { - d->held_down[vk] = 0; - } else { - d->held_down[vk] = 1; - d->held_scan[vk] = (BYTE)scan; - d->held_ext[vk] = (BYTE)(ext ? 1 : 0); - } + UINT32 type = INPUT_KEY, index = vk; + InputPacket pkt; + AcquireSRWLockExclusive(&d->input_lock); + if (d->keyboard_version == INPUT_KEYBOARD_VERSION) { + type = INPUT_KEY_PHYSICAL; + if (vk == VK_PAUSE || vk == VK_CANCEL) { scan = 0; ext = FALSE; } + /* Windows flags right Shift and Num Lock as extended without a physical E0 prefix. */ + if (scan == 0x36 || scan == 0x45) ext = FALSE; + if (vk == VK_PACKET || scan > 255) goto done; + if (scan == 0 && vk != VK_CANCEL && vk != VK_PAUSE && + vk != VK_SNAPSHOT && vk != VK_SLEEP && + !(vk >= VK_BROWSER_BACK && vk <= VK_LAUNCH_APP2)) goto done; + index = scan ? scan + (ext ? 256 : 0) : 512 + vk; + } + pkt.magic = INPUT_MAGIC; + pkt.type = type; + pkt.param1 = vk; + pkt.param2 = scan; + pkt.param3 = ext ? INPUT_KEY_EXTENDED : 0; + if (type == INPUT_KEY_PHYSICAL && + index < ARRAYSIZE(d->held_down) && d->held_down[index]) + pkt = d->held_keys[index]; + if (type == INPUT_KEY_PHYSICAL && up && (scan == 0xF1 || scan == 0xF2) && + !d->held_down[index]) { + /* Some Korean keyboard drivers report only the key release. */ + if (!send_input_locked(d, pkt.type, pkt.param1, pkt.param2, pkt.param3)) goto done; + d->held_down[index] = 1; + d->held_keys[index] = pkt; } + if (up) pkt.param3 |= INPUT_KEY_UP; + if (send_input_locked(d, pkt.type, pkt.param1, pkt.param2, pkt.param3) && + index < ARRAYSIZE(d->held_down)) { + d->held_down[index] = !up; + if (!up) d->held_keys[index] = pkt; + } +done: + ReleaseSRWLockExclusive(&d->input_lock); } /* Send key-up for every key we believe is still held in the guest, then clear tracking. Called when our window loses activation, when Transmit - mode is turned off, and on teardown — this is the core stuck-key fix. */ + mode is turned off, and on teardown. */ static void idd_flush_held_keys(VmDisplayIdd *d) { - int vk; - for (vk = 0; vk < 256; vk++) { - if (d->held_down[vk]) { - UINT32 flags = 2 | (d->held_ext[vk] ? 1 : 0); - send_input(d, INPUT_KEY, (UINT32)vk, d->held_scan[vk], flags); - d->held_down[vk] = 0; + AcquireSRWLockExclusive(&d->input_lock); + for (int i = 0; i < ARRAYSIZE(d->held_down); i++) { + if (d->held_down[i]) { + const InputPacket *pkt = &d->held_keys[i]; + send_input_locked(d, pkt->type, pkt->param1, pkt->param2, + pkt->param3 | INPUT_KEY_UP); + d->held_down[i] = 0; } } + ReleaseSRWLockExclusive(&d->input_lock); } /* Thread-local owner: a WH_KEYBOARD_LL callback runs on the thread that @@ -661,19 +772,57 @@ static void idd_flush_held_keys(VmDisplayIdd *d) without a global registry, keeping multiple displays independent. */ static __declspec(thread) VmDisplayIdd *t_hook_display; +enum { KEY_ROUTE_HOST = 1, KEY_ROUTE_GUEST, KEY_ROUTE_BOTH }; + +static BOOL idd_is_modifier(DWORD vk) +{ + return vk == VK_SHIFT || vk == VK_CONTROL || vk == VK_MENU || + (vk >= VK_LSHIFT && vk <= VK_RMENU); +} + 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) { + if (code == HC_ACTION && d && !d->stop) { 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 */ + BOOL up = (wp == WM_KEYUP || wp == WM_SYSKEYUP); + BOOL ext = (k->flags & LLKHF_EXTENDED) != 0; + BOOL focused = d->input_focused && GetForegroundWindow() == d->hwnd; + if (d->keyboard_version != INPUT_KEYBOARD_VERSION) { + if (focused && d->transmit_hotkeys) { + idd_forward_key(d, k->vkCode, k->scanCode, ext, up); + return 1; + } + } else { + DWORD scan = (k->vkCode == VK_PAUSE || k->vkCode == VK_CANCEL) + ? 0 : k->scanCode; + if (scan == 0x36 || scan == 0x45) ext = FALSE; + if (scan > 255 || k->vkCode > 255) return CallNextHookEx(NULL, code, wp, lp); + UINT index = scan ? scan + (ext ? 256 : 0) : 512 + k->vkCode; + BYTE route = d->hook_routes[index]; + BOOL pulse = k->vkCode == VK_PAUSE || k->vkCode == VK_CANCEL; + if (!focused || d->input_menu_active) { + d->hook_routes[index] = up || pulse ? 0 : KEY_ROUTE_HOST; + return CallNextHookEx(NULL, code, wp, lp); + } + if (!route) { + if (up && scan != 0xF1 && scan != 0xF2) + return CallNextHookEx(NULL, code, wp, lp); + if (d->transmit_hotkeys) { + route = KEY_ROUTE_GUEST; + } else if (idd_is_reserved_hotkey(k->vkCode, (k->flags & LLKHF_ALTDOWN) != 0) || + (GetAsyncKeyState(VK_LWIN) & 0x8000) || + (GetAsyncKeyState(VK_RWIN) & 0x8000)) { + route = KEY_ROUTE_HOST; + } else { + route = idd_is_modifier(k->vkCode) ? KEY_ROUTE_BOTH : KEY_ROUTE_GUEST; + } + } + /* Releases and repeats follow the key-down destination even if modifiers changed. */ + d->hook_routes[index] = up || pulse ? 0 : route; + if (route != KEY_ROUTE_HOST) + idd_forward_key(d, k->vkCode, k->scanCode, ext, up); + if (route == KEY_ROUTE_GUEST) return 1; } } return CallNextHookEx(NULL, code, wp, lp); @@ -682,6 +831,13 @@ static LRESULT CALLBACK idd_ll_keyboard_proc(int code, WPARAM wp, LPARAM lp) static void idd_install_kbd_hook(VmDisplayIdd *d) { if (d->kbd_hook) return; + ZeroMemory(d->hook_routes, sizeof(d->hook_routes)); + AcquireSRWLockShared(&d->input_lock); + if (d->keyboard_version == INPUT_KEYBOARD_VERSION) { + for (UINT i = 0; i < ARRAYSIZE(d->held_down); i++) + if (d->held_down[i]) d->hook_routes[i] = KEY_ROUTE_BOTH; + } + ReleaseSRWLockShared(&d->input_lock); t_hook_display = d; d->kbd_hook = SetWindowsHookExW(WH_KEYBOARD_LL, idd_ll_keyboard_proc, d->hInstance, 0); @@ -701,6 +857,14 @@ static void idd_remove_kbd_hook(VmDisplayIdd *d) t_hook_display = NULL; } +static void idd_update_kbd_hook(VmDisplayIdd *d) +{ + if (d->transmit_hotkeys || d->keyboard_version == INPUT_KEYBOARD_VERSION) + idd_install_kbd_hook(d); + else + idd_remove_kbd_hook(d); +} + /* Compute letterboxed/pillarboxed viewport within client rect */ static void compute_letterbox(UINT client_w, UINT client_h, UINT frame_w, UINT frame_h, @@ -751,6 +915,92 @@ static void window_to_vm_coords(HWND hwnd, int wx, int wy, if (*vy >= vm_h) *vy = vm_h - 1; } +static void idd_resume_absolute_mouse(VmDisplayIdd *d, const InputPacket *reply) +{ + POINT point; + UINT vx, vy; + if (d->stop || !d->frame_connected || !d->input_focused || + d->input_menu_active || d->input_sizing || d->relative_mouse || + !d->cursor_visible || !d->frame_width || !d->frame_height || + GetForegroundWindow() != d->hwnd || IsIconic(d->hwnd) || + !GetCursorPos(&point) || WindowFromPoint(point) != d->render_hwnd) { + idd_cancel_mouse_sync(d); + return; + } + + if (reply && reply->param1 < d->frame_width && reply->param2 < d->frame_height) { + RECT rc; + float x, y, width, height; + LONG dx = point.x - d->mouse_sync_origin.x; + LONG dy = point.y - d->mouse_sync_origin.y; + if (GetClientRect(d->render_hwnd, &rc) && !IsRectEmpty(&rc)) { + compute_letterbox(rc.right, rc.bottom, d->frame_width, d->frame_height, + &x, &y, &width, &height); + POINT target = { + (LONG)(x + (reply->param1 + 0.5f) * width / d->frame_width) + dx, + (LONG)(y + (reply->param2 + 0.5f) * height / d->frame_height) + dy + }; + if (target.x < (LONG)x) target.x = (LONG)x; + if (target.y < (LONG)y) target.y = (LONG)y; + if (target.x >= (LONG)(x + width)) target.x = (LONG)(x + width) - 1; + if (target.y >= (LONG)(y + height)) target.y = (LONG)(y + height) - 1; + if (ClientToScreen(d->render_hwnd, &target)) { + SetCursorPos(target.x, target.y); + GetCursorPos(&point); + } + } + } + d->mouse_sync_pending = FALSE; + ScreenToClient(d->render_hwnd, &point); + window_to_vm_coords(d->render_hwnd, point.x, point.y, + d->frame_width, d->frame_height, &vx, &vy); + send_input(d, INPUT_MOUSE_MOVE, vx, vy, 0); + SetCursor(d->guest_cursor ? d->guest_cursor : LoadCursorW(NULL, IDC_ARROW)); +} + +static void idd_poll_mouse_position(VmDisplayIdd *d) +{ + InputPacket reply; + BOOL received = FALSE, disconnected = FALSE; + if (!d->mouse_sync_pending) return; + idd_update_relative_mouse(d); + if (!d->mouse_sync_pending) return; + AcquireSRWLockExclusive(&d->input_lock); + if (d->input_socket != INVALID_SOCKET && d->input_socket == d->mouse_sync_socket) { + for (int i = 0; i < 16; i++) { + int n = recv(d->input_socket, (char *)&d->mouse_reply + d->mouse_reply_size, + (int)sizeof(InputPacket) - d->mouse_reply_size, 0); + if (n <= 0) { + disconnected = n == 0 || WSAGetLastError() != WSAEWOULDBLOCK; + if (disconnected) { + d->input_socket = INVALID_SOCKET; + PostMessageW(d->hwnd, WM_IDD_INPUT_READY, 0, 0); + } + break; + } + d->mouse_reply_size += n; + if (d->mouse_reply_size != sizeof(InputPacket)) continue; + d->mouse_reply_size = 0; + if (d->mouse_reply.magic == INPUT_MAGIC && + d->mouse_reply.type == INPUT_MOUSE_POSITION_REPLY && + d->mouse_reply.param3 == d->mouse_sync_id) { + reply = d->mouse_reply; + received = TRUE; + break; + } + } + } else { + disconnected = TRUE; + } + ReleaseSRWLockExclusive(&d->input_lock); + if (disconnected) + idd_cancel_mouse_sync(d); + else if (received) + idd_resume_absolute_mouse(d, &reply); + else if (GetTickCount64() >= d->mouse_sync_deadline) + idd_resume_absolute_mouse(d, NULL); +} + /* ---- Reliable recv: read exactly `len` bytes ---- */ static BOOL recv_exact(SOCKET s, void *buf, int len) @@ -821,6 +1071,83 @@ static SOCKET connect_to_hv_service(const GUID *vm_runtime_id, const GUID *servi return s; } +static SOCKET connect_input(VmDisplayIdd *d) +{ + static volatile LONG request_id; + GUID svc; + UINT32 ready = 0; + InputPacket queries[] = { + { INPUT_MAGIC, INPUT_KEYBOARD_QUERY, INPUT_KEYBOARD_VERSION, 0, 0 }, + { INPUT_MAGIC, INPUT_MOUSE_QUERY, INPUT_MOUSE_VERSION, 0, 0 } + }; + InputPacket reply = {0}; + int got = 0, version = 1, mouse_version = 0; + BOOL keyboard_reply = FALSE, mouse_reply = FALSE; + ULONGLONG deadline; + u_long nonblock = 1; + DWORD zero_timeout = 0; + hcs_service_guid(d->os_type, 3, &svc); + SOCKET s = connect_to_hv_service(&d->runtime_id, &svc, 1000); + if (s == INVALID_SOCKET) return s; + if (!recv_exact(s, &ready, sizeof(ready)) || ready != INPUT_READY_MAGIC) + goto failed; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char *)&zero_timeout, sizeof(zero_timeout)); + if (ioctlsocket(s, FIONBIO, &nonblock) != 0) goto failed; + queries[0].param2 = queries[1].param2 = (UINT32)InterlockedIncrement(&request_id); + if (send(s, (const char *)queries, sizeof(queries), 0) != sizeof(queries)) + goto failed; + /* Old helpers ignore the query. A late or incomplete reply cannot change + the mode after the socket has been made available to the window thread. */ + deadline = GetTickCount64() + 500; + while ((!keyboard_reply || !mouse_reply) && !d->stop) { + fd_set read_set; + struct timeval timeout; + LONGLONG remaining = (LONGLONG)(deadline - GetTickCount64()); + if (remaining <= 0) break; + timeout.tv_sec = 0; + timeout.tv_usec = (long)remaining * 1000; + FD_ZERO(&read_set); + FD_SET(s, &read_set); + int result = select(0, &read_set, NULL, NULL, &timeout); + if (result == SOCKET_ERROR) goto failed; + if (result == 0) break; + int n = recv(s, (char *)&reply + got, (int)sizeof(reply) - got, 0); + if (n == SOCKET_ERROR && WSAGetLastError() == WSAEWOULDBLOCK) continue; + if (n <= 0) goto failed; + got += n; + if (got == sizeof(reply)) { + if (reply.magic == INPUT_MAGIC && reply.param2 == queries[0].param2 && + reply.param3 == 0) { + if (reply.type == INPUT_KEYBOARD_REPLY) { + keyboard_reply = TRUE; + if (reply.param1 == INPUT_KEYBOARD_VERSION) + version = INPUT_KEYBOARD_VERSION; + } else if (reply.type == INPUT_MOUSE_REPLY) { + mouse_reply = TRUE; + if (reply.param1 == INPUT_MOUSE_VERSION) + mouse_version = INPUT_MOUSE_VERSION; + } + } + got = 0; + } + } + if (d->stop) goto failed; + AcquireSRWLockExclusive(&d->input_lock); + ZeroMemory(d->held_down, sizeof(d->held_down)); + d->keyboard_version = version; + d->mouse_version = mouse_version; + d->mouse_reply = reply; + d->mouse_reply_size = got; + d->input_socket = s; + ReleaseSRWLockExclusive(&d->input_lock); + PostMessageW(d->hwnd, WM_IDD_INPUT_READY, 0, 0); + idd_log(d, L"Input connected + ready (keyboard v%d).", version); + return s; +failed: + closesocket(s); + return INVALID_SOCKET; +} + /* ================================================================== * Audio playback — guest -> host * ================================================================== */ @@ -1348,7 +1675,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. */ + UINT mask_pitch = (mask_row_bytes + 1) & ~1u; void *color_bits = NULL; BYTE *mask_buf; UINT row, col; @@ -1462,9 +1791,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) { @@ -1547,27 +1876,8 @@ static DWORD WINAPI idd_recv_thread_proc(LPVOID param) FrameHeader hdr; /* Ensure input channel is connected (independent of frame channel) */ - if (input_s == INVALID_SOCKET) { - GUID svc; hcs_service_guid(d->os_type, 3, &svc); - input_s = connect_to_hv_service(&d->runtime_id, &svc, 1000); - if (input_s != INVALID_SOCKET) { - UINT32 ready_magic = 0; - if (recv_exact(input_s, &ready_magic, sizeof(ready_magic)) && - ready_magic == INPUT_READY_MAGIC) { - DWORD zero_timeout = 0; - u_long nb = 1; - setsockopt(input_s, SOL_SOCKET, SO_RCVTIMEO, (char *)&zero_timeout, sizeof(zero_timeout)); - ioctlsocket(input_s, FIONBIO, &nb); - d->input_socket = input_s; - g_input_send_count = 0; - idd_log(d, L"Input connected + ready (GUID :0003)."); - } else { - idd_log(d, L"Input handshake failed - closing."); - closesocket(input_s); - input_s = INVALID_SOCKET; - } - } - } + if (input_s == INVALID_SOCKET) + input_s = connect_input(d); /* Clipboard module (handles :0005 + :0006 internally) */ if (!d->clipboard) { @@ -1599,6 +1909,8 @@ static DWORD WINAPI idd_recv_thread_proc(LPVOID param) } idd_log(d, L"Frame channel connected."); + d->cursor_visible = TRUE; + PostMessageW(d->hwnd, WM_IDD_CURSOR_CHANGED, 0, 0); /* Receive loop — reads magic first to dispatch frame vs cursor */ while (!d->stop) { @@ -1620,7 +1932,8 @@ static DWORD WINAPI idd_recv_thread_proc(LPVOID param) sizeof(CursorHeader) - sizeof(UINT32))) break; - d->cursor_visible = chdr.visible; + BOOL cursor_changed = d->cursor_visible != (chdr.visible != 0); + d->cursor_visible = chdr.visible != 0; if (chdr.shape_updated && chdr.shape_data_size > 0) { BYTE *cursor_buf; @@ -1649,16 +1962,14 @@ static DWORD WINAPI idd_recv_thread_proc(LPVOID param) d->guest_cursor = new_cursor; d->cursor_shape_id = chdr.shape_id; if (old) DestroyCursor(old); - /* Force cursor update if mouse is in window */ - if (d->render_hwnd) - PostMessageW(d->render_hwnd, WM_SETCURSOR, - (WPARAM)d->render_hwnd, - MAKELPARAM(HTCLIENT, WM_MOUSEMOVE)); + cursor_changed = TRUE; } } HeapFree(GetProcessHeap(), 0, cursor_buf); } + if (cursor_changed) + PostMessageW(d->hwnd, WM_IDD_CURSOR_CHANGED, 0, 0); continue; /* back to message loop */ } @@ -1792,6 +2103,10 @@ static DWORD WINAPI idd_recv_thread_proc(LPVOID param) d->frame_dirty = TRUE; d->recv_count++; LeaveCriticalSection(&d->frame_cs); + if (!d->frame_connected) { + d->frame_connected = TRUE; + PostMessageW(d->hwnd, WM_IDD_CURSOR_CHANGED, 0, 0); + } /* Signal the window thread to repaint */ if (d->hwnd && IsWindow(d->hwnd)) @@ -1803,30 +2118,14 @@ static DWORD WINAPI idd_recv_thread_proc(LPVOID param) input_s = INVALID_SOCKET; idd_log(d, L"Input socket closed, will reconnect..."); } - if (input_s == INVALID_SOCKET) { - GUID svc; hcs_service_guid(d->os_type, 3, &svc); - SOCKET new_s = connect_to_hv_service(&d->runtime_id, &svc, 1000); - if (new_s != INVALID_SOCKET) { - UINT32 ready_magic = 0; - if (recv_exact(new_s, &ready_magic, sizeof(ready_magic)) && - ready_magic == INPUT_READY_MAGIC) { - DWORD zero_timeout = 0; - u_long nb = 1; - setsockopt(new_s, SOL_SOCKET, SO_RCVTIMEO, (char *)&zero_timeout, sizeof(zero_timeout)); - ioctlsocket(new_s, FIONBIO, &nb); - input_s = new_s; - d->input_socket = new_s; - g_input_send_count = 0; - idd_log(d, L"Input reconnected + ready (GUID :0003)."); - } else { - closesocket(new_s); - } - } - /* If connect/handshake fails, will retry next frame */ - } + if (input_s == INVALID_SOCKET) + input_s = connect_input(d); } /* Frame channel lost — close it but keep input alive */ + d->frame_connected = FALSE; + d->cursor_visible = TRUE; + PostMessageW(d->hwnd, WM_IDD_CURSOR_CHANGED, 0, 0); closesocket(s); idd_log(d, L"Frame channel disconnected, reconnecting..."); @@ -1846,7 +2145,13 @@ static DWORD WINAPI idd_recv_thread_proc(LPVOID param) } /* Final cleanup — close input socket on thread exit */ + AcquireSRWLockExclusive(&d->input_lock); d->input_socket = INVALID_SOCKET; + d->keyboard_version = 1; + d->mouse_version = 0; + ZeroMemory(d->held_down, sizeof(d->held_down)); + ReleaseSRWLockExclusive(&d->input_lock); + PostMessageW(d->hwnd, WM_IDD_INPUT_READY, 0, 0); if (input_s != INVALID_SOCKET) { closesocket(input_s); } @@ -1996,10 +2301,7 @@ static DWORD WINAPI idd_window_thread_proc(LPVOID param) /* Start a present timer for steady rendering */ SetTimer(d->hwnd, IDT_PRESENT, PRESENT_MS, NULL); - /* Install the hotkey hook on this (message-pumping) thread if the - persisted setting has Transmit mode enabled. */ - if (d->transmit_hotkeys) - idd_install_kbd_hook(d); + idd_update_kbd_hook(d); /* Message pump */ while (GetMessageW(&msg, NULL, 0, 0) > 0) { @@ -2027,6 +2329,11 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) d = (VmDisplayIdd *)GetWindowLongPtrW(hwnd, GWLP_USERDATA); + if (d && d->mouse_sync_pending && + (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN || + msg == WM_MBUTTONDOWN || msg == WM_MOUSEWHEEL)) + idd_resume_absolute_mouse(d, NULL); + switch (msg) { case WM_SYSCOMMAND: if (d && (wp & 0xFFF0) == IDM_AUDIO_MUTE) { @@ -2052,13 +2359,11 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) CheckMenuItem(sysmenu, IDM_XMIT_HOTKEYS, MF_BYCOMMAND | (d->transmit_hotkeys ? MF_CHECKED : MF_UNCHECKED)); } - if (d->transmit_hotkeys) { - idd_install_kbd_hook(d); - } else { - idd_remove_kbd_hook(d); + if (!d->transmit_hotkeys) { /* Release anything the guest may be holding from this mode. */ idd_flush_held_keys(d); } + idd_update_kbd_hook(d); idd_display_settings_save(d->vhdx_path, d->transmit_hotkeys); idd_log(d, d->transmit_hotkeys ? L"Transmit Keyboard Hotkeys: ON." @@ -2091,9 +2396,11 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) guest before tearing the window down. */ idd_remove_kbd_hook(d); idd_flush_held_keys(d); + idd_flush_mouse_buttons(d); /* Stop recv threads */ d->stop = TRUE; + idd_update_relative_mouse(d); /* Destroy clipboard module */ if (d->clipboard) { @@ -2147,6 +2454,10 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case WM_DESTROY: KillTimer(hwnd, IDT_PRESENT); if (d) idd_remove_kbd_hook(d); /* safety net if WM_CLOSE was bypassed */ + if (d) { + d->stop = TRUE; + idd_update_relative_mouse(d); + } if (d) d->hwnd = NULL; PostQuitMessage(0); return 0; @@ -2180,9 +2491,32 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) if (d->render_hwnd) MoveWindow(d->render_hwnd, 0, 0, rc.right, rc.bottom, TRUE); d3d_resize_swap_chain(d); + if (d->relative_mouse) { + AcquireSRWLockExclusive(&g_mouse_capture_lock); + if (g_mouse_capture_hwnd == hwnd) idd_clip_mouse(d); + ReleaseSRWLockExclusive(&g_mouse_capture_lock); + } + idd_update_relative_mouse(d); } return 0; + case WM_MOVE: + if (d && d->relative_mouse) { + AcquireSRWLockExclusive(&g_mouse_capture_lock); + if (g_mouse_capture_hwnd == hwnd) idd_clip_mouse(d); + ReleaseSRWLockExclusive(&g_mouse_capture_lock); + idd_update_relative_mouse(d); + } + break; + + case WM_ENTERSIZEMOVE: + case WM_EXITSIZEMOVE: + if (d) { + d->input_sizing = msg == WM_ENTERSIZEMOVE; + idd_update_relative_mouse(d); + } + break; + case WM_PAINT: { PAINTSTRUCT ps; @@ -2194,6 +2528,7 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case WM_TIMER: if (wp == IDT_PRESENT && d) { + if (d->mouse_sync_pending) idd_poll_mouse_position(d); if (d->frame_dirty) d3d_render_frame(d); } @@ -2227,6 +2562,41 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) SetForegroundWindow(hwnd); return 0; + case WM_IDD_INPUT_READY: + if (d && !d->stop) { + idd_cancel_mouse_sync(d); + idd_update_kbd_hook(d); + idd_update_relative_mouse(d); + } + return 0; + + case WM_IDD_CURSOR_CHANGED: + if (d && d->render_hwnd) { + idd_update_relative_mouse(d); + POINT pt; + if (GetCursorPos(&pt) && WindowFromPoint(pt) == d->render_hwnd) + SendMessageW(d->render_hwnd, WM_SETCURSOR, + (WPARAM)d->render_hwnd, + MAKELPARAM(HTCLIENT, WM_MOUSEMOVE)); + } + return 0; + + case WM_ENTERMENULOOP: + if (d) { + d->input_menu_active = TRUE; + if (d->keyboard_version == INPUT_KEYBOARD_VERSION) idd_flush_held_keys(d); + idd_flush_mouse_buttons(d); + idd_update_relative_mouse(d); + } + break; + + case WM_EXITMENULOOP: + if (d) { + d->input_menu_active = FALSE; + idd_update_relative_mouse(d); + } + break; + case WM_SETFOCUS: if (d && d->clipboard) vm_clipboard_set_sync_enabled(d->clipboard, TRUE); @@ -2245,8 +2615,11 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) case WM_ACTIVATE: if (d) { d->input_focused = (LOWORD(wp) != WA_INACTIVE); - if (!d->input_focused) + if (!d->input_focused) { idd_flush_held_keys(d); + idd_flush_mouse_buttons(d); + } + idd_update_relative_mouse(d); } break; @@ -2254,6 +2627,41 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) return 1; /* We handle all painting via D3D11 */ /* ---- Mouse tracking (events forwarded from render child) ---- */ + case WM_INPUT: + if (d && d->relative_mouse && d->input_focused && + d->frame_connected && !d->cursor_visible && + d->mouse_version == INPUT_MOUSE_VERSION && + GetForegroundWindow() == hwnd) { + RAWINPUT raw = {0}; + UINT size = sizeof(raw); + if (GetRawInputData((HRAWINPUT)lp, RID_INPUT, &raw, &size, + sizeof(RAWINPUTHEADER)) != (UINT)-1 && + raw.header.dwType == RIM_TYPEMOUSE) { + LONG dx = raw.data.mouse.lLastX, dy = raw.data.mouse.lLastY; + if (raw.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE) { + BOOL virtual_desktop = (raw.data.mouse.usFlags & MOUSE_VIRTUAL_DESKTOP) != 0; + POINT pt = { + MulDiv(dx, GetSystemMetrics(virtual_desktop ? SM_CXVIRTUALSCREEN : SM_CXSCREEN), 65535), + MulDiv(dy, GetSystemMetrics(virtual_desktop ? SM_CYVIRTUALSCREEN : SM_CYSCREEN), 65535) + }; + dx = dy = 0; + if (d->raw_absolute_valid && d->raw_absolute_device == raw.header.hDevice) { + dx = pt.x - d->raw_absolute_position.x; + dy = pt.y - d->raw_absolute_position.y; + } + d->raw_absolute_valid = TRUE; + d->raw_absolute_device = raw.header.hDevice; + d->raw_absolute_position = pt; + } else { + d->raw_absolute_valid = FALSE; + } + if (dx || dy) { + send_input(d, INPUT_MOUSE_RELATIVE, (UINT32)dx, (UINT32)dy, 0); + } + } + } + break; + case WM_MOUSEMOVE: if (d) { if (!d->tracking && d->render_hwnd) { @@ -2266,10 +2674,11 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) d->tracking = TRUE; } d->mouse_in = TRUE; + if (!d->relative_mouse && !d->cursor_visible) + idd_update_relative_mouse(d); - { + if (!d->relative_mouse && !d->mouse_sync_pending && d->frame_width && d->frame_height) { UINT vx, vy; - /* lp coords are relative to render child */ window_to_vm_coords(d->render_hwnd, (int)(short)LOWORD(lp), (int)(short)HIWORD(lp), d->frame_width, d->frame_height, &vx, &vy); @@ -2282,12 +2691,15 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) if (d) { d->mouse_in = FALSE; d->tracking = FALSE; + idd_update_relative_mouse(d); } return 0; case WM_SETCURSOR: if (LOWORD(lp) == HTCLIENT) { - if (d && d->guest_cursor) + if (d && (!d->cursor_visible || d->mouse_sync_pending)) + SetCursor(NULL); + else if (d && d->guest_cursor) SetCursor(d->guest_cursor); else SetCursor(LoadCursorW(NULL, IDC_ARROW)); @@ -2300,25 +2712,41 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) if (d && d->mouse_in) { if (d->render_hwnd) SetCapture(d->render_hwnd); send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_LEFT, 1, 0); + d->mouse_buttons |= 1u << INPUT_BTN_LEFT; } return 0; case WM_LBUTTONUP: ReleaseCapture(); - if (d && d->mouse_in) send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_LEFT, 0, 0); + if (d && (d->mouse_in || (d->mouse_buttons & (1u << INPUT_BTN_LEFT)))) { + send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_LEFT, 0, 0); + d->mouse_buttons &= ~(1u << INPUT_BTN_LEFT); + } return 0; case WM_RBUTTONDOWN: - if (d && d->mouse_in) send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_RIGHT, 1, 0); + if (d && d->mouse_in) { + send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_RIGHT, 1, 0); + d->mouse_buttons |= 1u << INPUT_BTN_RIGHT; + } return 0; case WM_RBUTTONUP: - if (d && d->mouse_in) send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_RIGHT, 0, 0); + if (d && (d->mouse_in || (d->mouse_buttons & (1u << INPUT_BTN_RIGHT)))) { + send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_RIGHT, 0, 0); + d->mouse_buttons &= ~(1u << INPUT_BTN_RIGHT); + } return 0; case WM_MBUTTONDOWN: - if (d && d->mouse_in) send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_MIDDLE, 1, 0); + if (d && d->mouse_in) { + send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_MIDDLE, 1, 0); + d->mouse_buttons |= 1u << INPUT_BTN_MIDDLE; + } return 0; case WM_MBUTTONUP: - if (d && d->mouse_in) send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_MIDDLE, 0, 0); + if (d && (d->mouse_in || (d->mouse_buttons & (1u << INPUT_BTN_MIDDLE)))) { + send_input(d, INPUT_MOUSE_BUTTON, INPUT_BTN_MIDDLE, 0, 0); + d->mouse_buttons &= ~(1u << INPUT_BTN_MIDDLE); + } return 0; case WM_MOUSEWHEEL: @@ -2326,7 +2754,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 @@ -2344,6 +2772,8 @@ static LRESULT CALLBACK idd_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) if (!d->transmit_hotkeys && idd_is_reserved_hotkey((DWORD)wp, (GetKeyState(VK_MENU) & 0x8000) != 0)) break; /* Default mode: let the host handle this hotkey. */ + if (d->kbd_hook && d->keyboard_version == INPUT_KEYBOARD_VERSION) + return 0; /* The hook already forwarded modifiers passed through to the host. */ if (d->input_focused) idd_forward_key(d, (DWORD)wp, scan, ext, up); return 0; @@ -2378,9 +2808,12 @@ VmDisplayIdd *vm_display_idd_create(VmInstance *vm, HINSTANCE hInstance, HWND ma d->main_hwnd = main_hwnd; d->open = TRUE; d->stop = FALSE; + InitializeSRWLock(&d->input_lock); + d->keyboard_version = 1; d->input_socket = INVALID_SOCKET; d->audio_socket = INVALID_SOCKET; d->clipboard = NULL; + d->cursor_visible = TRUE; /* Load the per-VM display setting, creating display_settings.json with the default (off) if this VM doesn't have one yet. The hook itself is diff --git a/src/core/protocol.h b/src/core/protocol.h new file mode 100644 index 0000000..8fea893 --- /dev/null +++ b/src/core/protocol.h @@ -0,0 +1,52 @@ +#ifndef ASB_PROTOCOL_H +#define ASB_PROTOCOL_H + +#include + +#define INPUT_MAGIC 0x4E495341u /* ASIN, little-endian */ +#define INPUT_READY_MAGIC 0x59445249u /* IRDY */ +#define INPUT_MOUSE_MOVE 0 +#define INPUT_MOUSE_BUTTON 1 +#define INPUT_MOUSE_WHEEL 2 +#define INPUT_KEY 3 +#define INPUT_KEYBOARD_QUERY 4 +#define INPUT_KEYBOARD_REPLY 5 +#define INPUT_KEY_PHYSICAL 6 +#define INPUT_KEYBOARD_VERSION 2 +#define INPUT_MOUSE_QUERY 7 +#define INPUT_MOUSE_REPLY 8 +#define INPUT_MOUSE_RELATIVE 9 +#define INPUT_MOUSE_POSITION_QUERY 10 +#define INPUT_MOUSE_POSITION_REPLY 11 +#define INPUT_MOUSE_VERSION 1 + +#define INPUT_BTN_LEFT 0 +#define INPUT_BTN_RIGHT 1 +#define INPUT_BTN_MIDDLE 2 +#define INPUT_KEY_EXTENDED 1 +#define INPUT_KEY_UP 2 + +/* All packets are 20 little-endian bytes, following the IRDY greeting. + * QUERY/REPLY: param1 = keyboard version, param2 = echoed request ID, param3 = 0. + * Without a matching reply, senders retain INPUT_KEY's legacy VK semantics. + * KEY_PHYSICAL: param2 = set-1 make byte, EXTENDED = E0, UP = release. + * With scan zero only, param1 identifies a Windows VK non-text function key + * (Pause/Break, Print Screen, Sleep, or browser/media/launch keys). + * MOUSE_QUERY/REPLY: param1 = mouse version, param2 = echoed request ID, param3 = 0. + * Without a matching mouse reply, senders retain absolute mouse movement. + * MOUSE_RELATIVE: param1/param2 = signed int32 X/Y deltas, param3 = 0. + * POSITION_QUERY: param1 = request ID, param2/param3 = 0. + * POSITION_REPLY: param1/param2 = physical guest-screen X/Y, param3 = request ID. + * Both coordinates are INT32_MIN if the cursor position cannot be read. + */ +#pragma pack(push, 1) +typedef struct { + uint32_t magic; + uint32_t type; + uint32_t param1; + uint32_t param2; + uint32_t param3; +} InputPacket; +#pragma pack(pop) + +#endif 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..aafcd95 100644 --- a/tools/agent/appsandbox-input.c +++ b/tools/agent/appsandbox-input.c @@ -11,33 +11,17 @@ */ #include "../transport/asb_transport.h" +#include "../../src/core/protocol.h" #include #include #include #pragma comment(lib, "user32.lib") -/* ---- Input protocol (must match the host sender) ---- */ - -#define INPUT_MAGIC 0x4E495341 /* "ASIN" little-endian */ -#define INPUT_MOUSE_MOVE 0 -#define INPUT_MOUSE_BUTTON 1 -#define INPUT_MOUSE_WHEEL 2 -#define INPUT_KEY 3 -#define INPUT_BTN_LEFT 0 -#define INPUT_BTN_RIGHT 1 -#define INPUT_BTN_MIDDLE 2 -#define INPUT_READY_MAGIC 0x59445249 /* "IRDY" little-endian */ - -#pragma pack(push, 1) typedef struct { - UINT32 magic; - UINT32 type; - UINT32 param1; - UINT32 param2; - UINT32 param3; -} InputPacket; -#pragma pack(pop) + BYTE scan[2][256]; + BYTE vk[2][256]; +} PhysicalKeyState; /* ---- Logging ---- */ @@ -60,6 +44,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) @@ -142,6 +140,86 @@ static void inject_input(const InputPacket *pkt) } } +static BOOL is_function_key(UINT32 vk) +{ + return vk == VK_CANCEL || vk == VK_PAUSE || vk == VK_SNAPSHOT || + vk == VK_SLEEP || (vk >= VK_BROWSER_BACK && vk <= VK_LAUNCH_APP2); +} + +static void inject_relative_mouse(const InputPacket *pkt) +{ + INPUT inp = {0}; + inp.type = INPUT_MOUSE; + inp.mi.dx = (LONG)(INT32)pkt->param1; + inp.mi.dy = (LONG)(INT32)pkt->param2; + inp.mi.dwFlags = MOUSEEVENTF_MOVE; + switch_to_input_desktop(); + SendInput(1, &inp, sizeof(inp)); +} + +static void inject_physical_key(const InputPacket *pkt, PhysicalKeyState *keys) +{ + INPUT inp; + BYTE *held; + UINT extended = (pkt->param3 & INPUT_KEY_EXTENDED) != 0; + + if (pkt->param2 > 0xFF || + (pkt->param3 & ~(INPUT_KEY_EXTENDED | INPUT_KEY_UP))) + return; + + ZeroMemory(&inp, sizeof(inp)); + inp.type = INPUT_KEYBOARD; + if (pkt->param2) { + inp.ki.wScan = (WORD)pkt->param2; + inp.ki.dwFlags = KEYEVENTF_SCANCODE; + held = &keys->scan[extended][pkt->param2]; + } else { + if (!is_function_key(pkt->param1)) + return; + inp.ki.wVk = (WORD)pkt->param1; + held = &keys->vk[extended][pkt->param1]; + } + if (extended) inp.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + if (pkt->param3 & INPUT_KEY_UP) inp.ki.dwFlags |= KEYEVENTF_KEYUP; + + switch_to_input_desktop(); + if (SendInput(1, &inp, sizeof(inp)) == 1) + *held = (pkt->param3 & INPUT_KEY_UP) == 0; + else + input_log("SendInput(PHYSICAL_KEY scan=0x%X flags=0x%X) failed: %lu", + pkt->param2, pkt->param3, GetLastError()); +} + +static void release_physical_keys(PhysicalKeyState *keys) +{ + UINT kind, extended, code; + + switch_to_input_desktop(); + for (kind = 0; kind < 2; kind++) { + for (extended = 0; extended < 2; extended++) { + for (code = 1; code < 256; code++) { + BYTE *held = kind ? &keys->vk[extended][code] : + &keys->scan[extended][code]; + INPUT inp; + if (!*held) continue; + ZeroMemory(&inp, sizeof(inp)); + inp.type = INPUT_KEYBOARD; + inp.ki.dwFlags = KEYEVENTF_KEYUP; + if (kind) + inp.ki.wVk = (WORD)code; + else { + inp.ki.wScan = (WORD)code; + inp.ki.dwFlags |= KEYEVENTF_SCANCODE; + } + if (extended) inp.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + if (SendInput(1, &inp, sizeof(inp)) == 0) + input_log("SendInput(key release) failed: %lu", GetLastError()); + *held = 0; + } + } + } +} + /* ---- Receive exactly len bytes (transport may deliver partial reads) ---- */ static int recv_full(AsbConn *c, void *buf, int len) @@ -156,11 +234,26 @@ static int recv_full(AsbConn *c, void *buf, int len) return got; } +static int send_full(AsbConn *c, const void *buf, int len) +{ + int sent = 0; + while (sent < len) { + int n = asb_send(c, (const char *)buf + sent, len - sent); + if (n <= 0) + return n; + sent += n; + } + return sent; +} + /* ---- Handle one host connection ---- */ static void handle_conn(AsbConn *c) { InputPacket pkt; + PhysicalKeyState keys = {0}; + UINT keyboard_version = 1; + UINT mouse_version = 0; UINT pkt_count = 0; UINT32 ready = INPUT_READY_MAGIC; @@ -176,7 +269,7 @@ static void handle_conn(AsbConn *c) if (n <= 0) { input_log("%s after %u packets.", n == 0 ? "Host disconnected" : "recv error", pkt_count); - return; + break; } if (pkt.magic != INPUT_MAGIC) { input_log("Bad magic 0x%08X, skipping.", pkt.magic); @@ -186,8 +279,49 @@ static void handle_conn(AsbConn *c) if (pkt_count == 1) input_log("First packet: type=%u p1=%u p2=%u p3=%u", pkt.type, pkt.param1, pkt.param2, pkt.param3); - inject_input(&pkt); + if (pkt.type == INPUT_MOUSE_QUERY) { + InputPacket reply = {INPUT_MAGIC, INPUT_MOUSE_REPLY, 0, pkt.param2, 0}; + if (pkt.param3 != 0) continue; + if (pkt.param1 >= INPUT_MOUSE_VERSION) + reply.param1 = INPUT_MOUSE_VERSION; + if (send_full(c, &reply, (int)sizeof(reply)) != (int)sizeof(reply)) + break; + mouse_version = reply.param1; + } else if (pkt.type == INPUT_MOUSE_RELATIVE) { + if (mouse_version == INPUT_MOUSE_VERSION && pkt.param3 == 0) + inject_relative_mouse(&pkt); + } else if (pkt.type == INPUT_MOUSE_POSITION_QUERY) { + POINT point; + InputPacket reply = {INPUT_MAGIC, INPUT_MOUSE_POSITION_REPLY, + (UINT32)INT32_MIN, (UINT32)INT32_MIN, pkt.param1}; + if (mouse_version != INPUT_MOUSE_VERSION || pkt.param2 || pkt.param3) continue; + switch_to_input_desktop(); + if (GetPhysicalCursorPos(&point)) { + reply.param1 = (UINT32)point.x; + reply.param2 = (UINT32)point.y; + } + if (send_full(c, &reply, (int)sizeof(reply)) != (int)sizeof(reply)) + break; + } else if (pkt.type == INPUT_KEYBOARD_QUERY) { + InputPacket reply = {INPUT_MAGIC, INPUT_KEYBOARD_REPLY, 1, pkt.param2, 0}; + if (pkt.param3 != 0) continue; + if (pkt.param1 >= INPUT_KEYBOARD_VERSION) + reply.param1 = INPUT_KEYBOARD_VERSION; + if (reply.param1 != keyboard_version) + release_physical_keys(&keys); + if (send_full(c, &reply, (int)sizeof(reply)) != (int)sizeof(reply)) { + input_log("Failed to send keyboard reply."); + break; + } + keyboard_version = reply.param1; + } else if (pkt.type == INPUT_KEY_PHYSICAL) { + if (keyboard_version >= INPUT_KEYBOARD_VERSION) + inject_physical_key(&pkt, &keys); + } else { + inject_input(&pkt); + } } + release_physical_keys(&keys); } /* ---- Main: listen on the input channel, accept connections ---- */ @@ -196,6 +330,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..ccac54b 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,9 @@ 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. */ +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..1442dfe 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"]; @@ -1594,7 +1648,7 @@ static int cmd_build_windows(int argc, char **argv) { FILE *fc = fopen(setupComplete.fileSystemRepresentation, "wb"); if (fu && fs && fc) { asb_provision_unattend(fu, vmName.UTF8String, user.UTF8String, pass.UTF8String, - "arm64", testMode, /*is_arm64=*/1, lang.UTF8String); + "arm64", testMode, /*is_arm64=*/1, lang.UTF8String, NULL); asb_provision_setup_cmd(fs); asb_provision_setupcomplete(fc, sshMsiName.length ? sshMsiName.UTF8String : NULL); scriptsOK = YES; 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/prefetch_build_deps.c b/tools/iso-patch/prefetch_build_deps.c index a5dd66e..a859dfc 100644 --- a/tools/iso-patch/prefetch_build_deps.c +++ b/tools/iso-patch/prefetch_build_deps.c @@ -742,7 +742,7 @@ int do_prefetch_build_deps(const wchar_t *codename, /* ---- 4. Mark seeds in_closure ---- */ const char *seeds[] = { "libasound2-dev", "libxcb1-dev", "libxcb-xfixes0-dev", - "libdrm-dev", "pkg-config", + "libdrm-dev", "libsystemd-dev", "pkg-config", "openssh-server" /* for ssh_enabled VMs; firstboot installs conditionally */ }; int closure_count = 0; diff --git a/tools/iso-patch/prefetch_repo.c b/tools/iso-patch/prefetch_repo.c index 232bb41..58aba1a 100644 --- a/tools/iso-patch/prefetch_repo.c +++ b/tools/iso-patch/prefetch_repo.c @@ -264,7 +264,7 @@ int do_prefetch_repo(const wchar_t *branch, const wchar_t *out_dir) */ u_mkdir_p(out_dir); - /* agent-src/: 5 .c files + Makefile */ + /* agent-src/ */ { wchar_t agent_src[MAX_PATH], dst[MAX_PATH]; swprintf_s(agent_src, MAX_PATH, @@ -283,6 +283,25 @@ int do_prefetch_repo(const wchar_t *branch, const wchar_t *out_dir) swprintf_s(dst, MAX_PATH, L"%s\\%s", out_agent, files[i]); u_cp_file(s, dst); } + wchar_t protocol_src[MAX_PATH]; + swprintf_s(protocol_src, MAX_PATH, L"%s\\src\\core\\protocol.h", extracted_root); + if (GetFileAttributesW(protocol_src) != INVALID_FILE_ATTRIBUTES) { + swprintf_s(dst, MAX_PATH, L"%s\\protocol.h", out_agent); + if (u_cp_file(protocol_src, dst) != 0) return -1; + } + wchar_t gnome_src[MAX_PATH]; + swprintf_s(gnome_src, MAX_PATH, + L"%s\\gnome\\appsandbox-pointer@appsandbox", agent_src); + if (GetFileAttributesW(gnome_src) != INVALID_FILE_ATTRIBUTES) { + const wchar_t *gnome_files[] = { L"metadata.json", L"extension.js" }; + for (int i = 0; i < 2; i++) { + wchar_t s[MAX_PATH]; + swprintf_s(s, MAX_PATH, L"%s\\%s", gnome_src, gnome_files[i]); + swprintf_s(dst, MAX_PATH, + L"%s\\gnome\\appsandbox-pointer@appsandbox\\%s", out_agent, gnome_files[i]); + if (u_cp_file(s, dst) != 0) return -1; + } + } log_msg(L"prefetch-repo: staged agent-src/"); } diff --git a/tools/iso-patch/ubuntu_vhdx.c b/tools/iso-patch/ubuntu_vhdx.c index 696650f..4cd2866 100644 --- a/tools/iso-patch/ubuntu_vhdx.c +++ b/tools/iso-patch/ubuntu_vhdx.c @@ -1164,15 +1164,21 @@ static void plant_firstboot_service(ext4_writer_t *ew) "fi\n" "KB=us\n" "if [ -s /etc/appsandbox-keyboard ]; then KB=$(tr -d '\\r\\n[:space:]' < /etc/appsandbox-keyboard); fi\n" + "KB_VARIANT=\n" + "if [ -s /etc/appsandbox-keyboard-variant ]; then KB_VARIANT=$(tr -d '\\r\\n[:space:]' < /etc/appsandbox-keyboard-variant); fi\n" + "IM=\n" + "if [ -s /etc/appsandbox-input-method ]; then IM=$(tr -d '\\r\\n[:space:]' < /etc/appsandbox-input-method); fi\n" "cat > /etc/default/keyboard </dev/null || true\n" + "KB_ID=$KB\n" + "if [ -n \"$KB_VARIANT\" ]; then KB_ID=\"$KB+$KB_VARIANT\"; fi\n" + "echo \"OK: keyboard=$KB_ID\"\n" + "rm -f /etc/appsandbox-locale /etc/appsandbox-keyboard /etc/appsandbox-keyboard-variant /etc/appsandbox-input-method 2>/dev/null || true\n" "\n" "# --- STEP 5: User account (from create-VM modal, falls back to test) ---\n" "# Host writes the chosen username + glibc $6$ hash into these\n" @@ -1220,6 +1226,25 @@ 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" + " SOURCES=\"[('xkb', '$KB_ID')]\"\n" + " MRU=$SOURCES\n" + " IM_PACKAGE=\n" + " case \"$IM\" in\n" + " libpinyin) IM_PACKAGE=ibus-libpinyin ;;\n" + " chewing) IM_PACKAGE=ibus-chewing ;;\n" + " mozc-jp) IM_PACKAGE=ibus-mozc ;;\n" + " hangul) IM_PACKAGE=ibus-hangul ;;\n" + " esac\n" + " if [ -n \"$IM_PACKAGE\" ] && [ \"$(dpkg-query -W -f='${Status}' \"$IM_PACKAGE\" 2>/dev/null)\" = 'install ok installed' ]; then\n" + " SOURCES=\"[('xkb', '$KB_ID'), ('ibus', '$IM')]\"\n" + " MRU=\"[('ibus', '$IM'), ('xkb', '$KB_ID')]\"\n" + " echo \"OK: input method=$IM\"\n" + " elif [ -n \"$IM\" ]; then\n" + " echo \"WARN: input method $IM unavailable; using $KB_ID\"\n" + " fi\n" + " su \"$ASB_USER\" -c \"dbus-run-session -- gsettings set org.gnome.desktop.input-sources sources \\\"$SOURCES\\\"\" \\\n" + " && su \"$ASB_USER\" -c \"dbus-run-session -- gsettings set org.gnome.desktop.input-sources mru-sources \\\"$MRU\\\"\" \\\n" + " && echo \"OK: GNOME keyboard=$KB_ID\" || echo \"WARN: GNOME keyboard setup failed\"\n" "fi\n" "rm -f /etc/xdg/autostart/gnome-initial-setup-first-login.desktop || true\n" "\n" @@ -1303,7 +1328,7 @@ static void plant_firstboot_service(ext4_writer_t *ew) "# pkg-config -> Makefile's pkg-config invocation\n" "if DEBIAN_FRONTEND=noninteractive apt-get install -y $APT_OPTS \\\n" " build-essential dkms \"linux-headers-$TGT_KVER\" \\\n" - " libasound2-dev libxcb1-dev libxcb-xfixes0-dev libdrm-dev \\\n" + " libasound2-dev libxcb1-dev libxcb-xfixes0-dev libdrm-dev libsystemd-dev \\\n" " pkg-config 2>&1 | tail -20; then\n" " echo \"OK: apt install all build tools\"\n" "else\n" @@ -1360,6 +1385,17 @@ static void plant_firstboot_service(ext4_writer_t *ew) " fi\n" " cd /\n" "fi\n" + "if [ -f /usr/share/gnome-shell/extensions/appsandbox-pointer@appsandbox/metadata.json ]; then\n" + " su \"$ASB_USER\" -c \"dbus-run-session -- python3 -\" <<'PY'\n" + "from gi.repository import Gio\n" + "settings = Gio.Settings.new('org.gnome.shell')\n" + "enabled = settings.get_strv('enabled-extensions')\n" + "uuid = 'appsandbox-pointer@appsandbox'\n" + "if uuid not in enabled:\n" + " settings.set_strv('enabled-extensions', enabled + [uuid])\n" + "Gio.Settings.sync()\n" + "PY\n" + "fi\n" "echo \"==== STEP 8 finished in $((SECONDS - BUILD_T0)) s ====\"\n" "ls -la /usr/local/bin/appsandbox-* 2>&1 || true\n" "\n" diff --git a/tools/linux/Makefile b/tools/linux/Makefile index cf5b4d8..f7a6c46 100644 --- a/tools/linux/Makefile +++ b/tools/linux/Makefile @@ -57,6 +57,8 @@ install-agents: agents echo " installed $(DISTDIR)/$$bin"; \ fi; \ done + install -d $(DISTDIR)/gnome/appsandbox-pointer@appsandbox + install -m 0644 agent/gnome/appsandbox-pointer@appsandbox/*.json agent/gnome/appsandbox-pointer@appsandbox/*.js $(DISTDIR)/gnome/appsandbox-pointer@appsandbox/ # ---- kernel modules --------------------------------------------------------- # diff --git a/tools/linux/agent/Makefile b/tools/linux/agent/Makefile index 6dd8833..f1447ee 100644 --- a/tools/linux/agent/Makefile +++ b/tools/linux/agent/Makefile @@ -11,6 +11,8 @@ DESTDIR ?= BINDIR := $(PREFIX)/bin SYSTEMDDIR := /etc/systemd/system USERUNITDIR := /etc/systemd/user +POINTER_EXT := appsandbox-pointer@appsandbox +EXTENSIONDIR := /usr/share/gnome-shell/extensions/$(POINTER_EXT) # Daemons get added here as each phase lands. BINS := appsandbox-agent appsandbox-display appsandbox-input appsandbox-audio appsandbox-clipboard @@ -35,8 +37,8 @@ appsandbox-display: appsandbox-display.c $(CC) $(CFLAGS) $(shell pkg-config --cflags libdrm) -o $@ $< \ $(LDFLAGS) $(shell pkg-config --libs libdrm) -appsandbox-input: appsandbox-input.c - $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) +appsandbox-input: appsandbox-input.c $(wildcard protocol.h ../../../src/core/protocol.h) + $(CC) $(CFLAGS) -I../../../src/core -o $@ $< $(LDFLAGS) -lsystemd # Audio daemon uses libasound to talk to the kernel snd-aloop driver. appsandbox-audio: appsandbox-audio.c @@ -48,7 +50,11 @@ appsandbox-audio: appsandbox-audio.c appsandbox-clipboard: appsandbox-clipboard.c $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) -lxcb -lxcb-xfixes -install: $(BINS) +install-pointer: + install -d $(DESTDIR)$(EXTENSIONDIR) + install -m 0644 gnome/$(POINTER_EXT)/metadata.json gnome/$(POINTER_EXT)/extension.js $(DESTDIR)$(EXTENSIONDIR)/ + +install: $(BINS) install-pointer install -d $(DESTDIR)$(BINDIR) install -m 0755 $(BINS) $(DESTDIR)$(BINDIR)/ install -d $(DESTDIR)$(SYSTEMDDIR) @@ -77,4 +83,4 @@ disable: clean: rm -f $(BINS) -.PHONY: all install enable disable clean +.PHONY: all install install-pointer enable disable clean diff --git a/tools/linux/agent/appsandbox-clipboard.c b/tools/linux/agent/appsandbox-clipboard.c index 7a7ae3c..cb8eb98 100644 --- a/tools/linux/agent/appsandbox-clipboard.c +++ b/tools/linux/agent/appsandbox-clipboard.c @@ -612,6 +612,11 @@ static uint32_t g_cf_html_id = 0; /* host sends this in the format id */ /* X atom resolution cache, populated at startup. */ static xcb_atom_t g_x_atom[N_FORMATS]; static xcb_atom_t g_x_atom_html = XCB_ATOM_NONE; +static struct { + uint32_t cf; + xcb_atom_t target; +} g_guest_formats[CLIP_MAX_FORMATS]; +static uint32_t g_guest_format_count; static enum conv_kind cf_to_conv(uint32_t cf) { @@ -682,6 +687,7 @@ static xcb_atom_t a_INCR; static xcb_atom_t a_MULTIPLE; static xcb_atom_t a_TIMESTAMP; static xcb_atom_t a_ATOM; +static xcb_atom_t a_CLIP_DATA; static xcb_atom_t intern_atom(const char *name) { @@ -734,6 +740,7 @@ static int xcb_setup(void) a_MULTIPLE = intern_atom("MULTIPLE"); a_TIMESTAMP = intern_atom("TIMESTAMP"); a_ATOM = XCB_ATOM_ATOM; + a_CLIP_DATA = intern_atom("_APPSANDBOX_CLIP_DATA"); /* Per-format atoms. */ for (size_t i = 0; i < N_FORMATS; i++) @@ -771,13 +778,22 @@ static uint32_t target_to_cf(xcb_atom_t target) return 0; } -/* Inverse: given a CF_*, return the preferred X target atom. The - * format table is ordered so the first match is the preferred one. */ -static xcb_atom_t cf_to_preferred_target(uint32_t cf) +static xcb_atom_t cf_to_offered_target(uint32_t cf, const xcb_atom_t *atoms, + uint32_t count) { if (cf == g_cf_html_id && g_x_atom_html) return g_x_atom_html; - for (size_t i = 0; i < N_FORMATS; i++) - if (kFormats[i].cf == cf) return g_x_atom[i]; + for (size_t i = 0; i < N_FORMATS; i++) { + if (kFormats[i].cf != cf) continue; + for (uint32_t j = 0; j < count; j++) + if (atoms[j] == g_x_atom[i]) return g_x_atom[i]; + } + return XCB_ATOM_NONE; +} + +static xcb_atom_t guest_target_for_cf(uint32_t cf) +{ + for (uint32_t i = 0; i < g_guest_format_count; i++) + if (g_guest_formats[i].cf == cf) return g_guest_formats[i].target; return XCB_ATOM_NONE; } @@ -1249,6 +1265,9 @@ struct pending_host_req { bool active; uint32_t cf; xcb_atom_t target; + xcb_atom_t property; + xcb_window_t owner; + xcb_timestamp_t time; bool incr; /* receiving via INCR protocol */ uint8_t *accum; /* growing buffer */ size_t accum_len; @@ -1259,6 +1278,9 @@ static struct pending_host_req g_pending_host; /* Accepted clients (one per channel at a time). */ static int g_writer_fd = -1; static int g_reader_fd = -1; +static xcb_window_t g_selection_owner; +static xcb_timestamp_t g_selection_time; +static bool g_targets_dirty; /* When we're owner of CLIPBOARD via a FORMAT_LIST from the host, hold * onto the last set of formats we advertised so SelectionRequest TARGETS @@ -1581,43 +1603,68 @@ static void handle_selection_request(xcb_selection_request_event_t *ev) /* ---- Guest→host path: handle CLIPBOARD owner change ---- */ -static void handle_xfixes_selection_notify(xcb_xfixes_selection_notify_event_t *ev) +static void query_guest_targets(void) { - if (ev->selection != a_CLIPBOARD) return; - - /* Owner change to ourselves means our own xcb_set_selection_owner — - * ignore to avoid loop. */ - if (ev->owner == g_window) return; - - /* If sync is gated off, swallow. */ - if (!g_sync_enabled || g_reader_fd < 0) return; - - /* If owner went to None, host clipboard becomes empty — clearing - * the host doesn't have a clean wire representation (the host's - * vm_clipboard.c expects FORMAT_LIST to be non-empty), so just no-op. */ - if (ev->owner == XCB_NONE) return; + if (!g_targets_dirty || g_pending_host.active || + !g_sync_enabled || g_reader_fd < 0 || + g_selection_owner == XCB_NONE || g_selection_owner == g_window) return; - /* Query TARGETS to discover available formats on the new owner. */ - if (g_pending_host.active) { - /* Already mid-fetch; drop. The next change will re-trigger. */ - return; - } - xcb_convert_selection(g_xcb, g_window, a_CLIPBOARD, a_TARGETS, - a_TARGETS, ev->timestamp); - xcb_flush(g_xcb); + g_targets_dirty = false; g_pending_host.active = true; g_pending_host.target = a_TARGETS; + g_pending_host.property = a_TARGETS; + g_pending_host.owner = g_selection_owner; + g_pending_host.time = g_selection_time; + xcb_delete_property(g_xcb, g_window, a_TARGETS); + xcb_convert_selection(g_xcb, g_window, a_CLIPBOARD, a_TARGETS, + a_TARGETS, g_pending_host.time); + xcb_flush(g_xcb); clip_log("CLIPBOARD owner changed, fetching TARGETS"); } +static bool pending_host_is_current(void) +{ + return g_pending_host.owner == g_selection_owner && + g_pending_host.time == g_selection_time && g_sync_enabled; +} + +static void finish_pending_host(bool failed) +{ + if (failed && g_pending_host.active && g_pending_host.cf && g_reader_fd >= 0) + send_clip_msg(g_reader_fd, CLIP_MSG_FORMAT_DATA_RESP, + g_pending_host.cf, NULL, 0); + free(g_pending_host.accum); + memset(&g_pending_host, 0, sizeof(g_pending_host)); + query_guest_targets(); +} + +static void handle_xfixes_selection_notify(xcb_xfixes_selection_notify_event_t *ev) +{ + if (ev->selection != a_CLIPBOARD) return; + + g_selection_owner = ev->owner; + g_selection_time = ev->selection_timestamp; + g_guest_format_count = 0; + g_targets_dirty = g_sync_enabled && g_reader_fd >= 0 && + ev->owner != XCB_NONE && ev->owner != g_window; + /* Finish the old transfer before reusing its reply property. */ + query_guest_targets(); +} + /* SelectionNotify arrives in response to xcb_convert_selection — could * be the TARGETS reply (and we then need to ship FORMAT_LIST), or the * data reply for a specific format. */ static void handle_selection_notify(xcb_selection_notify_event_t *ev) { + if (!g_pending_host.active || ev->requestor != g_window || + ev->selection != a_CLIPBOARD || ev->target != g_pending_host.target || + ev->time != g_pending_host.time || + (ev->property != XCB_ATOM_NONE && ev->property != g_pending_host.property)) + return; + if (ev->property == XCB_ATOM_NONE) { clip_log("SelectionNotify with no property — owner declined"); - g_pending_host.active = false; + finish_pending_host(true); return; } @@ -1627,7 +1674,7 @@ static void handle_selection_notify(xcb_selection_notify_event_t *ev) 0, UINT32_MAX); xcb_get_property_reply_t *pr = xcb_get_property_reply(g_xcb, pc, NULL); if (!pr) { - g_pending_host.active = false; + finish_pending_host(true); return; } @@ -1647,17 +1694,32 @@ static void handle_selection_notify(xcb_selection_notify_event_t *ev) uint32_t n = xcb_get_property_value_length(pr); void *val = xcb_get_property_value(pr); + if (!pending_host_is_current() || pr->type == XCB_ATOM_NONE) { + free(pr); + finish_pending_host(true); + return; + } + if (ev->target == a_TARGETS) { + if (pr->type != a_ATOM || pr->format != 32) { + free(pr); + finish_pending_host(true); + return; + } /* TARGETS arrived — build a FORMAT_LIST and ship. */ const xcb_atom_t *atoms = (const xcb_atom_t *)val; uint32_t n_atoms = n / 4; uint8_t buf[16384]; uint32_t count = 0; size_t off = 4; + g_guest_format_count = 0; for (uint32_t i = 0; i < n_atoms; i++) { uint32_t cf = target_to_cf(atoms[i]); - if (cf == 0) continue; + if (cf == 0 || guest_target_for_cf(cf) != XCB_ATOM_NONE) continue; if (off + 8 > sizeof(buf)) break; + g_guest_formats[count].cf = cf; + g_guest_formats[count].target = cf_to_offered_target(cf, atoms, n_atoms); + g_guest_format_count = count + 1; *(uint32_t *)(buf + off) = cf; off += 4; *(uint32_t *)(buf + off) = 0; off += 4; /* name_len, only used for registered names */ count++; @@ -1670,7 +1732,7 @@ static void handle_selection_notify(xcb_selection_notify_event_t *ev) clip_log("→ host FORMAT_LIST (%u formats)", count); } free(pr); - g_pending_host.active = false; + finish_pending_host(false); return; } @@ -1681,7 +1743,7 @@ static void handle_selection_notify(xcb_selection_notify_event_t *ev) uint32_t cf = g_pending_host.cf; if (cf == 0 || g_reader_fd < 0) { free(pr); - g_pending_host.active = false; + finish_pending_host(true); return; } @@ -1692,7 +1754,7 @@ static void handle_selection_notify(xcb_selection_notify_event_t *ev) * CF_HDROP branch). */ deliver_hdrop_to_host((const uint8_t *)val, n); free(pr); - g_pending_host.active = false; + finish_pending_host(false); return; } @@ -1709,7 +1771,7 @@ static void handle_selection_notify(xcb_selection_notify_event_t *ev) clip_log("→ host FORMAT_DATA_RESP cf=%u (empty, convert failed)", cf); } free(pr); - g_pending_host.active = false; + finish_pending_host(false); } /* PropertyNotify routing: @@ -1730,13 +1792,21 @@ static void handle_property_notify(xcb_property_notify_event_t *ev) if (ev->window != g_window) return; if (ev->state != XCB_PROPERTY_NEW_VALUE) return; - if (!g_pending_host.incr) return; + if (!g_pending_host.active || !g_pending_host.incr || + ev->atom != g_pending_host.property) return; xcb_get_property_cookie_t pc = xcb_get_property(g_xcb, 1, g_window, ev->atom, XCB_GET_PROPERTY_TYPE_ANY, 0, UINT32_MAX); xcb_get_property_reply_t *pr = xcb_get_property_reply(g_xcb, pc, NULL); - if (!pr) return; + if (!pr) { + finish_pending_host(true); + return; + } + if (pr->type == XCB_ATOM_NONE) { + free(pr); + return; + } uint32_t n = xcb_get_property_value_length(pr); void *val = xcb_get_property_value(pr); @@ -1746,10 +1816,14 @@ static void handle_property_notify(xcb_property_notify_event_t *ev) * collapse DIBV5 → DIB). */ uint32_t cf = g_pending_host.cf ? g_pending_host.cf : target_to_cf(g_pending_host.target); - if (cf == CF_HDROP && g_reader_fd >= 0 && g_pending_host.accum) { + bool replied = false; + if (pending_host_is_current() && cf == CF_HDROP && + g_reader_fd >= 0 && g_pending_host.accum) { deliver_hdrop_to_host(g_pending_host.accum, g_pending_host.accum_len); - } else if (cf && g_reader_fd >= 0 && g_pending_host.accum) { + replied = true; + } else if (pending_host_is_current() && cf && + g_reader_fd >= 0 && g_pending_host.accum) { size_t cf_len = 0; uint8_t *cf_bytes = convert_x_to_cf(cf, g_pending_host.accum, g_pending_host.accum_len, &cf_len); @@ -1759,14 +1833,14 @@ static void handle_property_notify(xcb_property_notify_event_t *ev) free(cf_bytes); clip_log("→ host (INCR) FORMAT_DATA_RESP cf=%u (%zu bytes)", cf, cf_len); + replied = true; } } - free(g_pending_host.accum); - g_pending_host.accum = NULL; - g_pending_host.accum_len = 0; - g_pending_host.accum_cap = 0; - g_pending_host.incr = false; - g_pending_host.active = false; + free(pr); + xcb_delete_property(g_xcb, g_window, ev->atom); + xcb_flush(g_xcb); + finish_pending_host(!replied); + return; } else { /* Accumulate. */ if (g_pending_host.accum_len + n > g_pending_host.accum_cap) { @@ -1805,6 +1879,10 @@ static void handle_property_notify(xcb_property_notify_event_t *ev) static void take_clipboard_ownership(xcb_timestamp_t time) { + g_guest_format_count = 0; + g_selection_owner = g_window; + g_selection_time = time; + g_targets_dirty = false; xcb_set_selection_owner(g_xcb, g_window, a_CLIPBOARD, time); xcb_flush(g_xcb); g_we_own_clipboard = true; @@ -1813,6 +1891,9 @@ static void take_clipboard_ownership(xcb_timestamp_t time) static void release_clipboard_ownership(void) { if (!g_we_own_clipboard) return; + g_guest_format_count = 0; + g_selection_owner = XCB_NONE; + g_targets_dirty = false; xcb_set_selection_owner(g_xcb, XCB_NONE, a_CLIPBOARD, XCB_CURRENT_TIME); xcb_flush(g_xcb); g_we_own_clipboard = false; @@ -1838,7 +1919,11 @@ static int handle_writer_message(int fd, const struct ClipHeader *h) } g_sync_enabled = flag ? true : false; clip_log("recv SYNC_ENABLE=%d", flag); - if (!g_sync_enabled) release_clipboard_ownership(); + if (!g_sync_enabled) { + g_guest_format_count = 0; + g_targets_dirty = false; + release_clipboard_ownership(); + } return 0; } @@ -2078,7 +2163,7 @@ static int handle_reader_message(int fd, const struct ClipHeader *h) send_clip_msg(fd, CLIP_MSG_FORMAT_DATA_RESP, h->format, NULL, 0); return 0; } - xcb_atom_t target = cf_to_preferred_target(h->format); + xcb_atom_t target = guest_target_for_cf(h->format); if (target == XCB_ATOM_NONE) { send_clip_msg(fd, CLIP_MSG_FORMAT_DATA_RESP, h->format, NULL, 0); return 0; @@ -2086,14 +2171,16 @@ static int handle_reader_message(int fd, const struct ClipHeader *h) g_pending_host.active = true; g_pending_host.cf = h->format; g_pending_host.target = target; + g_pending_host.property = a_CLIP_DATA; + g_pending_host.owner = g_selection_owner; + g_pending_host.time = g_selection_time; g_pending_host.incr = false; g_pending_host.accum = NULL; g_pending_host.accum_len = 0; g_pending_host.accum_cap = 0; - /* Use a unique property atom on our window. */ - xcb_atom_t prop = intern_atom("_APPSANDBOX_CLIP_DATA"); + xcb_delete_property(g_xcb, g_window, a_CLIP_DATA); xcb_convert_selection(g_xcb, g_window, a_CLIPBOARD, target, - prop, XCB_CURRENT_TIME); + a_CLIP_DATA, g_pending_host.time); xcb_flush(g_xcb); clip_log("recv FORMAT_DATA_REQ cf=%u → XConvertSelection target=%u", h->format, target); @@ -2230,6 +2317,10 @@ int main(void) int c = accept(listen_reader, NULL, NULL); if (c < 0) continue; if (g_reader_fd >= 0) close(g_reader_fd); + g_reader_fd = -1; + g_guest_format_count = 0; + finish_pending_host(false); + g_targets_dirty = false; g_reader_fd = c; uint32_t ready = CLIP_READY_MAGIC; send_exact(c, &ready, 4); @@ -2250,6 +2341,8 @@ int main(void) g_writer_fd = -1; release_clipboard_ownership(); g_sync_enabled = false; + g_guest_format_count = 0; + g_targets_dirty = false; /* Abandon any in-flight host roundtrip so SelectionRequests * don't stay rejected forever. */ if (g_pending_x.active) { @@ -2273,8 +2366,9 @@ int main(void) close(fd); g_reader_fd = -1; /* Drop any in-flight X→host fetch state. */ - free(g_pending_host.accum); - memset(&g_pending_host, 0, sizeof(g_pending_host)); + g_guest_format_count = 0; + finish_pending_host(false); + g_targets_dirty = false; } } } diff --git a/tools/linux/agent/appsandbox-input.c b/tools/linux/agent/appsandbox-input.c index 180de93..ec2c206 100644 --- a/tools/linux/agent/appsandbox-input.c +++ b/tools/linux/agent/appsandbox-input.c @@ -5,22 +5,11 @@ * incoming ASIN InputPackets into /dev/uinput events. The host stack * already speaks this protocol (see src/backend_win/vm_display_idd.c). * - * Single virtual device exposing absolute pointer, wheel, buttons, and + * Primary virtual device exposing absolute pointer, wheel, buttons, and * full keyboard. GNOME / Mutter (Wayland) and Xorg both pick it up via * libinput automatically because uinput presents a real evdev node. * - * Wire protocol (host → guest): - * #pragma pack(1) - * { uint32 magic=0x4E495341 ('ASIN'), uint32 type, - * uint32 p1, uint32 p2, uint32 p3 } - * - * type=0 MOUSE_MOVE : p1=x, p2=y in pixels (frame coords) - * type=1 MOUSE_BUTTON : p1=btn (0=L,1=R,2=M), p2=down(1)/up(0) - * type=2 MOUSE_WHEEL : p1=delta (signed int32, ~120/notch) - * type=3 KEY : p1=Windows VK, p2=scancode, - * p3 bit0=extended, bit1=keyup (clear=keydown) - * - * Build: gcc -O2 -Wall -o appsandbox-input appsandbox-input.c + * Wire protocol: src/core/protocol.h. */ #include @@ -33,24 +22,19 @@ #include #include #include +#include +#include #include #include #include +#include +#include #include #include +#include +#include "protocol.h" #define VSOCK_PORT 3 -#define INPUT_MAGIC 0x4E495341u /* 'ASIN' */ -#define INPUT_READY_MAGIC 0x59445249u /* 'IRDY' */ - -#define INPUT_MOUSE_MOVE 0 -#define INPUT_MOUSE_BUTTON 1 -#define INPUT_MOUSE_WHEEL 2 -#define INPUT_KEY 3 - -#define BTN_ID_LEFT 0 -#define BTN_ID_RIGHT 1 -#define BTN_ID_MIDDLE 2 /* Absolute pointer range. We don't know the guest screen size before * accept, and it can change mid-session. We pick a coordinate space @@ -58,16 +42,8 @@ * it to the active screen. 32767 is the de-facto absolute-tablet range. */ #define ABS_RANGE 32767 -#pragma pack(push, 1) -struct input_packet { - uint32_t magic; - uint32_t type; - uint32_t p1, p2, p3; -}; -#pragma pack(pop) - static volatile sig_atomic_t g_stop = 0; -static int g_frame_w = 1920; /* updated by hint command if we add one later */ +static int g_frame_w = 1920; static int g_frame_h = 1080; static void on_signal(int sig) { (void)sig; g_stop = 1; } @@ -153,6 +129,86 @@ static const uint16_t g_vk_to_key[256] = { [0xDE] = KEY_APOSTROPHE, }; +static const uint16_t g_set1_extended[128] = { + [0x10] = KEY_PREVIOUSSONG, [0x19] = KEY_NEXTSONG, + [0x1C] = KEY_KPENTER, [0x1D] = KEY_RIGHTCTRL, + [0x20] = KEY_MUTE, [0x21] = KEY_CALC, [0x22] = KEY_PLAYPAUSE, + [0x24] = KEY_STOPCD, [0x2E] = KEY_VOLUMEDOWN, + [0x30] = KEY_VOLUMEUP, [0x32] = KEY_HOMEPAGE, + [0x35] = KEY_KPSLASH, [0x36] = KEY_RIGHTSHIFT, + [0x37] = KEY_SYSRQ, [0x38] = KEY_RIGHTALT, + [0x45] = KEY_NUMLOCK, [0x46] = KEY_PAUSE, + [0x47] = KEY_HOME, [0x48] = KEY_UP, [0x49] = KEY_PAGEUP, + [0x4B] = KEY_LEFT, [0x4D] = KEY_RIGHT, + [0x4F] = KEY_END, [0x50] = KEY_DOWN, [0x51] = KEY_PAGEDOWN, + [0x52] = KEY_INSERT, [0x53] = KEY_DELETE, + [0x5B] = KEY_LEFTMETA, [0x5C] = KEY_RIGHTMETA, + [0x5D] = KEY_COMPOSE, [0x5E] = KEY_POWER, [0x5F] = KEY_SLEEP, + [0x63] = KEY_WAKEUP, [0x65] = KEY_SEARCH, + [0x66] = KEY_BOOKMARKS, [0x67] = KEY_REFRESH, + [0x68] = KEY_STOP, [0x69] = KEY_FORWARD, [0x6A] = KEY_BACK, + [0x6B] = KEY_COMPUTER, [0x6C] = KEY_MAIL, + [0x6D] = KEY_MEDIA, [0x6E] = KEY_PROG1, [0x6F] = KEY_PROG2, +}; + +static uint16_t physical_keycode(uint32_t scan, uint32_t flags) +{ + if (scan == 0xF1) return KEY_HANJA; + if (scan == 0xF2) return KEY_HANGEUL; + if (flags & INPUT_KEY_EXTENDED) + return scan < 128 ? g_set1_extended[scan] : 0; + /* Linux retains the set-1 values for the original PC keyboard keys. */ + if ((scan >= 0x01 && scan <= 0x53) || (scan >= 0x56 && scan <= 0x58)) + return (uint16_t)scan; + if (scan >= 0x64 && scan <= 0x6F) + return (uint16_t)(KEY_F13 + scan - 0x64); + switch (scan) { + case 0x54: return KEY_SYSRQ; + case 0x59: return KEY_KPEQUAL; + case 0x5C: return KEY_KPJPCOMMA; + case 0x70: return KEY_KATAKANAHIRAGANA; + case 0x71: case 0xF1: return KEY_HANJA; + case 0x72: case 0xF2: return KEY_HANGEUL; + case 0x73: return KEY_RO; + case 0x76: return KEY_F24; + case 0x77: return KEY_HIRAGANA; + case 0x78: return KEY_KATAKANA; + case 0x79: return KEY_HENKAN; + case 0x7B: return KEY_MUHENKAN; + case 0x7D: return KEY_YEN; + case 0x7E: return KEY_KPCOMMA; + default: return 0; + } +} + +static uint16_t function_keycode(uint32_t vk) +{ + switch (vk) { + case 0x03: case 0x13: return KEY_PAUSE; + case 0x2C: return KEY_SYSRQ; + case 0x5F: return KEY_SLEEP; + case 0xA6: return KEY_BACK; + case 0xA7: return KEY_FORWARD; + case 0xA8: return KEY_REFRESH; + case 0xA9: return KEY_STOP; + case 0xAA: return KEY_SEARCH; + case 0xAB: return KEY_BOOKMARKS; + case 0xAC: return KEY_HOMEPAGE; + case 0xAD: return KEY_MUTE; + case 0xAE: return KEY_VOLUMEDOWN; + case 0xAF: return KEY_VOLUMEUP; + case 0xB0: return KEY_NEXTSONG; + case 0xB1: return KEY_PREVIOUSSONG; + case 0xB2: return KEY_STOPCD; + case 0xB3: return KEY_PLAYPAUSE; + case 0xB4: return KEY_MAIL; + case 0xB5: return KEY_MEDIA; + case 0xB6: return KEY_PROG1; + case 0xB7: return KEY_PROG2; + default: return 0; + } +} + /* ---- uinput device setup ---- */ static int uinput_open(void) @@ -182,6 +238,12 @@ static int uinput_open(void) for (int i = 0; i < 256; i++) { if (g_vk_to_key[i]) ioctl(fd, UI_SET_KEYBIT, g_vk_to_key[i]); + uint16_t code = physical_keycode((uint32_t)i, 0); + if (code) ioctl(fd, UI_SET_KEYBIT, code); + code = physical_keycode((uint32_t)i, INPUT_KEY_EXTENDED); + if (code) ioctl(fd, UI_SET_KEYBIT, code); + code = function_keycode((uint32_t)i); + if (code) ioctl(fd, UI_SET_KEYBIT, code); } /* Modern uinput setup (UI_DEV_SETUP) gives us absinfo per-axis */ @@ -220,6 +282,33 @@ static void uinput_close(int fd) } } +static int uinput_open_relative(void) +{ + int fd = open("/dev/uinput", O_WRONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) return -1; + struct uinput_setup setup = {0}; + strncpy(setup.name, "AppSandbox Relative Mouse", UINPUT_MAX_NAME_SIZE - 1); + setup.id.bustype = BUS_VIRTUAL; + setup.id.vendor = 0xA53B; + setup.id.product = 0x0002; + setup.id.version = 1; + if (ioctl(fd, UI_SET_EVBIT, EV_REL) < 0 || + ioctl(fd, UI_SET_RELBIT, REL_X) < 0 || + ioctl(fd, UI_SET_RELBIT, REL_Y) < 0 || + ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0 || + ioctl(fd, UI_SET_KEYBIT, BTN_LEFT) < 0 || + ioctl(fd, UI_SET_KEYBIT, BTN_RIGHT) < 0 || + ioctl(fd, UI_SET_KEYBIT, BTN_MIDDLE) < 0 || + ioctl(fd, UI_DEV_SETUP, &setup) < 0 || + ioctl(fd, UI_DEV_CREATE) < 0) { + close(fd); + return -1; + } + struct timespec ts = { .tv_sec = 0, .tv_nsec = 200 * 1000000L }; + nanosleep(&ts, NULL); + return fd; +} + static void emit(int fd, uint16_t type, uint16_t code, int32_t value) { struct input_event ev = {0}; @@ -232,7 +321,7 @@ static void emit_syn(int fd) { emit(fd, EV_SYN, SYN_REPORT, 0); } /* ---- Event translation ---- */ -static void do_mouse_move(int ui_fd, uint32_t x, uint32_t y) +static void do_mouse_move(int ui_fd, uint32_t x, uint32_t y, int after_relative) { /* Host gives us pixel coordinates in the current frame. Map to * 0..ABS_RANGE so the compositor scales correctly regardless of @@ -241,6 +330,11 @@ static void do_mouse_move(int ui_fd, uint32_t x, uint32_t y) int32_t ay = (int32_t)((uint64_t)y * ABS_RANGE / (g_frame_h ? g_frame_h : 1)); if (ax < 0) ax = 0; if (ax > ABS_RANGE) ax = ABS_RANGE; if (ay < 0) ay = 0; if (ay > ABS_RANGE) ay = ABS_RANGE; + if (after_relative) { + /* EV_ABS filters unchanged values. Keep the reset in the same SYN frame. */ + emit(ui_fd, EV_ABS, ABS_X, ax ? ax - 1 : 1); + emit(ui_fd, EV_ABS, ABS_Y, ay ? ay - 1 : 1); + } emit(ui_fd, EV_ABS, ABS_X, ax); emit(ui_fd, EV_ABS, ABS_Y, ay); emit_syn(ui_fd); @@ -250,9 +344,9 @@ static void do_mouse_button(int ui_fd, uint32_t btn_id, uint32_t down) { uint16_t code = 0; switch (btn_id) { - case BTN_ID_LEFT: code = BTN_LEFT; break; - case BTN_ID_RIGHT: code = BTN_RIGHT; break; - case BTN_ID_MIDDLE: code = BTN_MIDDLE; break; + case INPUT_BTN_LEFT: code = BTN_LEFT; break; + case INPUT_BTN_RIGHT: code = BTN_RIGHT; break; + case INPUT_BTN_MIDDLE: code = BTN_MIDDLE; break; default: return; } emit(ui_fd, EV_KEY, code, down ? 1 : 0); @@ -279,6 +373,37 @@ static void do_key(int ui_fd, uint32_t vk, uint32_t scan, uint32_t flags) emit_syn(ui_fd); } +static void release_physical_keys(int ui_fd, uint8_t held[KEY_CNT]) +{ + int released = 0; + for (unsigned code = 1; code < KEY_CNT; code++) { + if (!held[code]) continue; + emit(ui_fd, EV_KEY, (uint16_t)code, 0); + held[code] = 0; + released = 1; + } + if (released) emit_syn(ui_fd); +} + +static void do_physical_key(int ui_fd, uint32_t vk, uint32_t scan, + uint32_t flags, uint8_t held[KEY_CNT]) +{ + if (flags & ~(INPUT_KEY_EXTENDED | INPUT_KEY_UP)) return; + uint16_t code = scan ? physical_keycode(scan, flags) : function_keycode(vk); + if (!code) return; + int up = (flags & INPUT_KEY_UP) != 0; + if (up && !held[code]) return; + emit(ui_fd, EV_KEY, code, up ? 0 : held[code] ? 2 : 1); + held[code] = !up; + emit_syn(ui_fd); + /* Pause and Korean mode keys have no hardware break event. */ + if (!up && (code == KEY_PAUSE || code == KEY_HANGEUL || code == KEY_HANJA)) { + emit(ui_fd, EV_KEY, code, 0); + held[code] = 0; + emit_syn(ui_fd); + } +} + /* ---- vsock listener ---- */ static int vsock_listen(unsigned port) @@ -316,30 +441,146 @@ static int recv_exact(int fd, void *buf, size_t len) return 0; } +static int send_exact(int fd, const void *buf, size_t len) +{ + const uint8_t *p = buf; + while (len) { + ssize_t n = send(fd, p, len, MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return -1; + p += n; + len -= (size_t)n; + } + return 0; +} + +static int get_pointer_position(uint32_t *x, uint32_t *y) +{ + uid_t uid; + if (sd_seat_get_active("seat0", NULL, &uid) < 0) return 0; + struct sockaddr_un addr = { .sun_family = AF_UNIX }; + snprintf(addr.sun_path, sizeof(addr.sun_path), + "/run/user/%u/appsandbox-pointer.sock", (unsigned)uid); + struct stat st; + if (lstat(addr.sun_path, &st) || !S_ISSOCK(st.st_mode) || st.st_uid != uid) return 0; + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); + if (fd < 0) return 0; + struct ucred peer; + socklen_t peer_len = sizeof(peer); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0 || + getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &peer, &peer_len) != 0 || peer.uid != uid) { + close(fd); + return 0; + } + + uint32_t position[4]; + size_t received = 0; + struct timespec start, now; + clock_gettime(CLOCK_MONOTONIC, &start); + while (received < sizeof(position)) { + clock_gettime(CLOCK_MONOTONIC, &now); + long remaining = 100 - (now.tv_sec - start.tv_sec) * 1000 - + (now.tv_nsec - start.tv_nsec) / 1000000; + if (remaining <= 0) break; + struct pollfd pfd = { .fd = fd, .events = POLLIN }; + int ready = poll(&pfd, 1, (int)remaining); + if (ready < 0 && errno == EINTR) continue; + if (ready <= 0) break; + ssize_t n = recv(fd, (char *)position + received, sizeof(position) - received, 0); + if (n < 0 && (errno == EINTR || errno == EAGAIN)) continue; + if (n <= 0) break; + received += (size_t)n; + } + close(fd); + if (received != sizeof(position)) return 0; + for (unsigned i = 0; i < 4; i++) position[i] = le32toh(position[i]); + if (!position[2] || !position[3] || position[2] > 16384 || position[3] > 16384 || + position[0] >= position[2] || position[1] >= position[3]) return 0; + *x = position[0]; + *y = position[1]; + g_frame_w = (int)position[2]; + g_frame_h = (int)position[3]; + return 1; +} + static void serve(int client_fd, int ui_fd) { /* Tell the host the guest is ready. */ uint32_t ready = INPUT_READY_MAGIC; - if (send(client_fd, &ready, sizeof(ready), MSG_NOSIGNAL) != sizeof(ready)) { + if (send_exact(client_fd, &ready, sizeof(ready)) < 0) { in_log("send IRDY: %s", strerror(errno)); return; } + uint32_t keyboard_version = 1; + uint32_t mouse_version = 0; + int relative_fd = -1; + int after_relative = 0; + uint8_t held[KEY_CNT] = {0}; while (!g_stop) { - struct input_packet pkt; + InputPacket pkt; if (recv_exact(client_fd, &pkt, sizeof(pkt)) < 0) break; if (pkt.magic != INPUT_MAGIC) { in_log("bad magic 0x%08x — desync, closing", pkt.magic); break; } switch (pkt.type) { - case INPUT_MOUSE_MOVE: do_mouse_move(ui_fd, pkt.p1, pkt.p2); break; - case INPUT_MOUSE_BUTTON: do_mouse_button(ui_fd, pkt.p1, pkt.p2); break; - case INPUT_MOUSE_WHEEL: do_mouse_wheel(ui_fd, (int32_t)pkt.p1); break; - case INPUT_KEY: do_key(ui_fd, pkt.p1, pkt.p2, pkt.p3); break; + case INPUT_MOUSE_MOVE: + do_mouse_move(ui_fd, pkt.param1, pkt.param2, after_relative); + after_relative = 0; + break; + case INPUT_MOUSE_BUTTON: do_mouse_button(ui_fd, pkt.param1, pkt.param2); break; + case INPUT_MOUSE_WHEEL: do_mouse_wheel(ui_fd, (int32_t)pkt.param1); break; + case INPUT_KEY: do_key(ui_fd, pkt.param1, pkt.param2, pkt.param3); break; + case INPUT_MOUSE_QUERY: { + if (pkt.param3) break; + uint32_t x, y; + get_pointer_position(&x, &y); + if (pkt.param1 >= INPUT_MOUSE_VERSION && relative_fd < 0) + relative_fd = uinput_open_relative(); + InputPacket reply = { INPUT_MAGIC, INPUT_MOUSE_REPLY, + pkt.param1 >= INPUT_MOUSE_VERSION && relative_fd >= 0 ? INPUT_MOUSE_VERSION : 0, + pkt.param2, 0 }; + if (send_exact(client_fd, &reply, sizeof(reply)) < 0) goto disconnected; + mouse_version = reply.param1; + break; + } + case INPUT_MOUSE_RELATIVE: + if (mouse_version == INPUT_MOUSE_VERSION && pkt.param3 == 0) { + emit(relative_fd, EV_REL, REL_X, (int32_t)pkt.param1); + emit(relative_fd, EV_REL, REL_Y, (int32_t)pkt.param2); + emit_syn(relative_fd); + after_relative = 1; + } + break; + case INPUT_MOUSE_POSITION_QUERY: { + if (mouse_version != INPUT_MOUSE_VERSION || pkt.param2 || pkt.param3) break; + InputPacket reply = { INPUT_MAGIC, INPUT_MOUSE_POSITION_REPLY, + (uint32_t)INT32_MIN, (uint32_t)INT32_MIN, pkt.param1 }; + get_pointer_position(&reply.param1, &reply.param2); + if (send_exact(client_fd, &reply, sizeof(reply)) < 0) goto disconnected; + break; + } + case INPUT_KEYBOARD_QUERY: { + if (pkt.param3) break; + InputPacket reply = { INPUT_MAGIC, INPUT_KEYBOARD_REPLY, + pkt.param1 >= INPUT_KEYBOARD_VERSION ? INPUT_KEYBOARD_VERSION : 1, + pkt.param2, 0 }; + if (send_exact(client_fd, &reply, sizeof(reply)) < 0) goto disconnected; + release_physical_keys(ui_fd, held); + keyboard_version = reply.param1; + break; + } + case INPUT_KEY_PHYSICAL: + if (keyboard_version >= INPUT_KEYBOARD_VERSION) + do_physical_key(ui_fd, pkt.param1, pkt.param2, pkt.param3, held); + break; default: break; } } +disconnected: + release_physical_keys(ui_fd, held); + uinput_close(relative_fd); } int main(void) diff --git a/tools/linux/agent/gnome/appsandbox-pointer@appsandbox/extension.js b/tools/linux/agent/gnome/appsandbox-pointer@appsandbox/extension.js new file mode 100644 index 0000000..e6e6d14 --- /dev/null +++ b/tools/linux/agent/gnome/appsandbox-pointer@appsandbox/extension.js @@ -0,0 +1,121 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Mtk from 'gi://Mtk'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +export default class AppSandboxPointer extends Extension { + enable() { + this._uid = new Gio.Credentials().get_unix_user(); + const path = GLib.build_filenamev([ + GLib.get_user_runtime_dir(), 'appsandbox-pointer.sock', + ]); + const file = Gio.File.new_for_path(path); + let info; + try { + info = file.query_info('unix::uid,unix::mode', + Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + } catch (error) { + if (!error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + throw error; + } + if (info) { + if (info.get_attribute_uint32('unix::uid') !== this._uid || + (info.get_attribute_uint32('unix::mode') & 0o170000) !== 0o140000) + throw new Error('Invalid AppSandbox pointer socket'); + file.delete(null); + } + + this._service = new Gio.SocketService({active: false}); + try { + this._service.add_address(Gio.UnixSocketAddress.new(path), + Gio.SocketType.STREAM, Gio.SocketProtocol.DEFAULT, null); + this._socketFile = file; + file.set_attribute_uint32('unix::mode', 0o600, + Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + this._incoming = this._service.connect('incoming', + (_service, connection) => this._accept(connection)); + this._service.start(); + } catch (error) { + this.disable(); + throw error; + } + } + + disable() { + if (this._service) { + if (this._incoming) + this._service.disconnect(this._incoming); + this._service.stop(); + this._service.close(); + this._service = null; + this._incoming = 0; + } + if (this._socketFile) { + try { + this._socketFile.delete(null); + } catch (_) { + } + this._socketFile = null; + } + } + + _getPosition() { + const unavailable = [-2147483648, -2147483648, 0, 0]; + try { + const views = global.stage.peek_stage_views(); + if (views.length !== 1) + return unavailable; + const view = views[0]; + if (view.get_transform() !== Mtk.MonitorTransform.NORMAL) + return unavailable; + const framebuffer = view.get_onscreen(); + if (!framebuffer) + return unavailable; + const width = framebuffer.get_width(); + const height = framebuffer.get_height(); + const scale = view.get_scale(); + const layout = view.layout; + const [point] = global.backend.get_cursor_tracker().get_pointer(); + if (!(width > 0 && height > 0 && Number.isFinite(scale) && scale > 0) || + !Number.isFinite(point.x) || !Number.isFinite(point.y)) + return unavailable; + const x = Math.trunc((point.x - layout.x) * scale); + const y = Math.trunc((point.y - layout.y) * scale); + return [Math.max(0, Math.min(width - 1, x)), + Math.max(0, Math.min(height - 1, y)), width, height]; + } catch (_) { + return unavailable; + } + } + + _accept(connection) { + try { + if (!this._service) + return true; + const socket = connection.get_socket(); + const uid = socket.get_credentials().get_unix_user(); + if (uid !== 0 && uid !== this._uid) + return true; + const position = this._getPosition(); + const reply = new Uint8Array(16); + const data = new DataView(reply.buffer); + for (let i = 0; i < position.length; i++) + data.setInt32(i * 4, position[i], true); + socket.set_blocking(false); + let offset = 0; + while (offset < reply.length) { + const written = socket.send(reply.subarray(offset), null); + if (written <= 0) + break; + offset += written; + } + } catch (_) { + } finally { + try { + connection.close(null); + } catch (_) { + } + } + return true; + } +} diff --git a/tools/linux/agent/gnome/appsandbox-pointer@appsandbox/metadata.json b/tools/linux/agent/gnome/appsandbox-pointer@appsandbox/metadata.json new file mode 100644 index 0000000..ff10344 --- /dev/null +++ b/tools/linux/agent/gnome/appsandbox-pointer@appsandbox/metadata.json @@ -0,0 +1,6 @@ +{ + "uuid": "appsandbox-pointer@appsandbox", + "name": "AppSandbox Pointer", + "description": "Synchronizes the viewer pointer with the guest desktop.", + "shell-version": ["50"] +} diff --git a/tools/provision/win_provision.c b/tools/provision/win_provision.c index 652b654..c8f122d 100644 --- a/tools/provision/win_provision.c +++ b/tools/provision/win_provision.c @@ -85,7 +85,7 @@ static void password_b64(const char *pass, char *out, size_t out_sz) { } /* Map a BCP-47 tag to the InputLocale "LCID:KLID" form (same table as lang_to_input_locale). */ -static const char *input_locale(const char *lang) { +static const char *default_input_locale(const char *lang) { static const struct { const char *tag, *klid; } map[] = { {"en-US","0409:00000409"},{"en-GB","0809:00000809"},{"de-DE","0407:00000407"}, {"fr-FR","040c:0000040c"},{"fr-CA","0c0c:00001009"},{"es-ES","0c0a:0000040a"}, @@ -106,9 +106,35 @@ 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) { + const char *arch, int test_mode, int is_arm64, const char *lang, + const char *input_locale) { 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; @@ -122,7 +148,11 @@ int asb_provision_unattend(FILE *f, const char *vm_name, const char *user, const comp[bi] = 0; } char b64[2048]; password_b64(pass ? pass : "", b64, sizeof b64); - const char *loc = input_locale(lang ? lang : "en-US"); + const char *loc = input_locale && input_locale[0] ? input_locale : + default_input_locale(lang ? lang : "en-US"); + char input_xml[512]; + if (!xml_escape_text(loc, input_xml, sizeof input_xml)) return -1; + loc = input_xml; if (!lang) lang = "en-US"; /* UTF-8 BOM, matching the Windows _wfopen_s("w,ccs=UTF-8"). */ @@ -231,7 +261,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/tools/provision/win_provision.h b/tools/provision/win_provision.h index ec0b6f4..66549bd 100644 --- a/tools/provision/win_provision.h +++ b/tools/provision/win_provision.h @@ -28,7 +28,8 @@ extern "C" { * lang : UI/locale BCP-47 tag (e.g. "en-US") * Returns 0 on success. */ 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); + const char *arch, int test_mode, int is_arm64, const char *lang, + const char *input_locale); /* setup.cmd -- first-logon: agent already staged at C:\Windows\AppSandbox\; register the service. */ int asb_provision_setup_cmd(FILE *f); diff --git a/web/app.js b/web/app.js index ece3a1d..5c6ce07 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. */ 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'); + if (!isWindows || hostBridge.isMac) selectTemplate('', templateDefaultLabel()); + 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,68 @@ 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(); + updateCreateButtons(); + 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(); + updateCreateButtons(); } /* ---- Adapters ---- */ @@ -293,6 +348,7 @@ function populateTemplates(templates) { /* No template selected — update default label in case count changed */ document.getElementById('template-dropdown-selected').textContent = templateDefaultLabel(); } + revalidateVmName(); } function selectTemplate(value, label) { @@ -342,24 +398,117 @@ 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() { +function createValidationError(isTemplate) { var osType = document.getElementById('os-type').value; - var hasImage = (document.getElementById('image-path').value.trim() !== ''); - var hasTpl = document.getElementById('template-select').value !== ''; - /* macOS guests auto-download their restore image (no path needed). Windows - and Linux guests build from a user-picked ISO — or, on a Windows host, a - saved template. Holds on both hosts: on a Mac the template UI is hidden so - hasTpl stays false and a Windows guest genuinely requires the ISO. */ - var createOk = (osType === 'macOS') ? true : (hasImage || hasTpl); - document.getElementById('btn-create').disabled = !createOk; - /* Templates are Windows-only; disabling create-as-template for Linux - (and macOS) is fine since hasImage is the only signal we check. */ - document.getElementById('btn-create-template').disabled = (osType !== 'Windows') || !hasImage; + if (isTemplate && (hostBridge.isMac || osType !== 'Windows')) + return 'Templates require a Windows guest on a Windows host.'; + var error = validateVmName(document.getElementById('vm-name').value.trim()) || + validateDiskDirectory(document.getElementById('disk-directory').value.trim()) || + validateUsername(document.getElementById('admin-user').value.trim(), isTemplate) || + validatePassword(document.getElementById('admin-pass').value); + if (error) return error; + if (document.getElementById('admin-pass').value !== document.getElementById('admin-confirm').value) + return 'Passwords do not match.'; + + var numericFields = [ + ['hdd-size', 'Disk size must be a whole number of at least 1 GB.'], + ['ram-size', 'RAM must be an even number of at least 512 MB.'], + ['cpu-cores', 'CPU cores must be a whole number of at least 1.'] + ]; + for (var i = 0; i < numericFields.length; i++) { + var input = document.getElementById(numericFields[i][0]); + if (input.value === '' || !input.validity.valid) return numericFields[i][1]; + } + + var path = selectedDiskDirectory(); + var diskKnown = diskSpaceInfo && diskSpaceInfo.path === path; + if (diskKnown && diskSpaceInfo.freeGb < 0) return 'Disk folder is unavailable.'; + if (diskSpacePending && !diskKnown) return 'Checking disk folder...'; + + var hasImage = document.getElementById('image-path').value.trim() !== ''; + var hasTemplate = !hostBridge.isMac && osType === 'Windows' && + document.getElementById('template-select').value !== ''; + if ((isTemplate || osType !== 'macOS') && !hasImage && (isTemplate || !hasTemplate)) + return 'Select an OS image or an available template.'; + return null; +} + +function updateCreateButtons() { + document.getElementById('btn-create').disabled = !!createValidationError(false); + document.getElementById('btn-create-template').disabled = !!createValidationError(true); } /* Wire up change events */ +document.getElementById('create-vm-overlay').addEventListener('input', updateCreateButtons); +document.getElementById('create-vm-overlay').addEventListener('change', updateCreateButtons); document.getElementById('image-path').addEventListener('input', function() { if (this.value.trim() !== '') { selectTemplate('', templateDefaultLabel()); @@ -369,15 +518,19 @@ document.getElementById('image-path').addEventListener('input', function() { /* RAM must be 2 MB-aligned: snap an odd entry down by 1 when the field is committed. */ document.getElementById('ram-size').addEventListener('change', function() { - var mb = parseInt(this.value, 10); + var mb = this.valueAsNumber; if (!isNaN(mb)) this.value = alignRamMb(mb); }); function revalidateVmName() { var name = document.getElementById('vm-name').value.trim(); document.getElementById('vm-name-warn').textContent = validateVmName(name) || ''; + updateCreateButtons(); } -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,10 +593,14 @@ function gatherConfig() { name: document.getElementById('vm-name').value.trim(), osType: osType, imagePath: imagePath, - templateName: document.getElementById('template-select').value, - hddGb: parseInt(document.getElementById('hdd-size').value) || 64, - ramMb: alignRamMb(parseInt(document.getElementById('ram-size').value) || 16384), - cpuCores: parseInt(document.getElementById('cpu-cores').value) || 8, + diskDirectory: document.getElementById('disk-directory').value.trim() === + (lastHostInfo && lastHostInfo.defaultDiskDirectory) ? '' : + document.getElementById('disk-directory').value.trim(), + templateName: (!hostBridge.isMac && osType === 'Windows') ? + document.getElementById('template-select').value : '', + hddGb: document.getElementById('hdd-size').valueAsNumber, + ramMb: alignRamMb(document.getElementById('ram-size').valueAsNumber), + cpuCores: document.getElementById('cpu-cores').valueAsNumber, gpuMode: parseInt(document.getElementById('gpu-mode').value), networkMode: parseInt(document.getElementById('net-mode').value), netAdapter: document.getElementById('net-adapter').value, @@ -501,76 +658,100 @@ 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; } function onCreateVm() { + var error = createValidationError(false); + if (error) { sendCmd('log', { message: error }); updateCreateButtons(); return; } var cfg = gatherConfig(); - var nameErr = validateVmName(cfg.name); - if (nameErr) { sendCmd('log', { message: nameErr }); return; } - var userErr = validateUsername(cfg.adminUser); - if (userErr) { sendCmd('log', { message: userErr }); return; } - var passErr = validatePassword(cfg.adminPass); - if (passErr) { sendCmd('log', { message: passErr }); return; } - if (cfg.adminPass !== cfg.adminConfirm) { - sendCmd('log', { message: 'Passwords do not match.' }); - return; - } sendCmd('createVm', cfg); clearCreateForm(); closeCreateModal(); } function onCreateTemplate() { + var error = createValidationError(true); + if (error) { sendCmd('log', { message: error }); updateCreateButtons(); return; } var cfg = gatherConfig(); - var nameErr = validateVmName(cfg.name); - if (nameErr) { sendCmd('log', { message: nameErr }); return; } - if (cfg.adminPass !== cfg.adminConfirm) { - sendCmd('log', { message: 'Passwords do not match.' }); - return; - } cfg.isTemplate = true; sendCmd('createVm', cfg); clearCreateForm(); @@ -580,9 +761,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 +794,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 +821,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 +1620,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 +1646,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