Files
James Stringer 042559092b Preserve VM MAC addresses across restarts
Save and reuse each VM's MAC address on Windows and macOS hosts, including existing VMs that do not yet have one.
2026-09-15 20:17:41 -04:00

1800 lines
82 KiB
Objective-C

/*
* iso-patch-mac -- Host-side CLI for preparing a macOS VM disk image.
*
* Mirrors tools/iso-patch/iso-patch.c on Windows. Responsibilities:
* - "stage": mount the APFS Data volume of a VZ disk image, copy files
* per a TSV manifest, fix ownership/permissions, then detach.
* - IPSW fetch + VZMacOSInstaller orchestration (analogue of
* iso-patch's --to-vhdx mode).
*
* Privilege model: invoked by the host via AuthorizationExecuteWithPrivileges,
* so the tool runs as root. It does not escalate itself.
*
* Stdout protocol (read by src/backend_mac/iso_patch_mac.m):
* STATUS:<text>
* PROGRESS:<percent>:<step>
* LOG:<line>
* DONE:<disk path>
* ERROR:<reason>
*
* Manifest TSV (one file per line, tab-separated, "#" comments ignored):
* <host-source-path>\t<dest-relative-to-Data-volume>\t<mode-octal>\t<owner>
* Example:
* /opt/agent/appsandbox-agent\tLibrary/AppSandbox/appsandbox-agent\t0755\troot:wheel
*/
#import <Foundation/Foundation.h>
#import <Virtualization/Virtualization.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <pwd.h>
#include <grp.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <CommonCrypto/CommonKeyDerivation.h>
#include "../transport/asb_shm_layout.h" /* authoritative ivshmem region table + asb_shm_publish */
#include "engine/win_disk.h" /* from-scratch Windows-disk builder (apply WIM + stage) */
#include "engine/wim.h" /* WIM XML reader -> install image's default UI language */
#include "../provision/win_provision.h" /* shared unattend/setup.cmd/SetupComplete.cmd generators */
#include "../provision/mac_account_hash.h" /* shared macOS ShadowHash + kcpassword encoders */
/* ---- stdout protocol helpers ---- */
static void emit(NSString *prefix, NSString *msg) {
fprintf(stdout, "%s:%s\n", prefix.UTF8String, msg.UTF8String);
fflush(stdout);
}
static void emit_status(NSString *msg) { emit(@"STATUS", msg); }
static void emit_progress(int pct, NSString *step) {
fprintf(stdout, "PROGRESS:%d:%s\n", pct, step.UTF8String);
fflush(stdout);
}
static void emit_log(NSString *msg) { emit(@"LOG", msg); }
static void emit_lang(NSString *tag) { emit(@"LANG", tag); } /* detected ISO language (mirrors Windows iso-patch.c) */
static void emit_done(NSString *path) { emit(@"DONE", path); }
static void emit_error(NSString *msg) { emit(@"ERROR", msg); }
/* ---- Subprocess runner ---- */
static int run_tool(NSString *launchPath, NSArray<NSString *> *args,
NSString **stdoutStr, NSString **stderrStr) {
NSTask *task = [[NSTask alloc] init];
task.launchPath = launchPath;
task.arguments = args ?: @[];
NSPipe *outPipe = [NSPipe pipe];
NSPipe *errPipe = [NSPipe pipe];
task.standardOutput = outPipe;
task.standardError = errPipe;
NSError *err = nil;
if (![task launchAndReturnError:&err]) {
if (stderrStr) *stderrStr = err.localizedDescription ?: @"launch failed";
return -1;
}
NSData *outD = [outPipe.fileHandleForReading readDataToEndOfFile];
NSData *errD = [errPipe.fileHandleForReading readDataToEndOfFile];
[task waitUntilExit];
if (stdoutStr)
*stdoutStr = [[NSString alloc] initWithData:outD encoding:NSUTF8StringEncoding];
if (stderrStr)
*stderrStr = [[NSString alloc] initWithData:errD encoding:NSUTF8StringEncoding];
return task.terminationStatus;
}
/* ---- Mount lifecycle ---- */
typedef struct {
NSString *diskDev; /* e.g. /dev/disk7 — returned by hdiutil */
NSString *dataDev; /* e.g. /dev/disk7s1 — the APFS Data volume */
NSString *mountPt; /* e.g. /Volumes/...Data — where it got mounted */
} MountState;
static void unmount_and_detach(MountState *st) {
if (st->dataDev) {
(void)run_tool(@"/usr/sbin/diskutil",
@[@"unmount", @"force", st->dataDev], NULL, NULL);
}
if (st->diskDev) {
(void)run_tool(@"/usr/bin/hdiutil",
@[@"detach", @"-force", st->diskDev], NULL, NULL);
}
st->diskDev = nil;
st->dataDev = nil;
st->mountPt = nil;
}
/* Parse `hdiutil attach -plist` output to find the /dev/diskN whole-image
* parent device (the one carrying the GPT partition table). The hdiutil
* output lists many devices of the form /dev/diskN (parent) and /dev/diskNsM
* (partitions), plus virtual-container devices created by APFS probing.
* Only the GPT parent has content-hint == "GUID_partition_scheme". */
static NSString *parse_first_dev(NSString *plistXml) {
NSData *data = [plistXml dataUsingEncoding:NSUTF8StringEncoding];
NSError *err = nil;
id parsed = [NSPropertyListSerialization propertyListWithData:data
options:0
format:NULL
error:&err];
if (![parsed isKindOfClass:[NSDictionary class]]) return nil;
NSArray *sysEntities = parsed[@"system-entities"];
if (![sysEntities isKindOfClass:[NSArray class]]) return nil;
for (NSDictionary *e in sysEntities) {
NSString *hint = e[@"content-hint"];
NSString *dev = e[@"dev-entry"];
if (!dev) continue;
if ([hint isEqualToString:@"GUID_partition_scheme"] ||
[hint isEqualToString:@"Apple_partition_scheme"]) {
return dev;
}
}
/* Fallback: first entry (hdiutil always lists the root device first). */
NSDictionary *first = sysEntities.firstObject;
return first[@"dev-entry"];
}
static BOOL mount_disk(NSString *diskImagePath, MountState *out, NSString **errOut) {
out->diskDev = nil;
out->dataDev = nil;
out->mountPt = nil;
emit_status(@"Attaching disk image");
NSString *sout = nil, *serr = nil;
int rc = run_tool(@"/usr/bin/hdiutil",
@[@"attach", @"-nomount", @"-readwrite", @"-noverify",
@"-plist", diskImagePath],
&sout, &serr);
if (rc != 0) {
if (errOut) *errOut = [NSString stringWithFormat:@"hdiutil attach failed (%d): %@", rc, serr];
return NO;
}
NSString *parent = parse_first_dev(sout);
if (!parent) {
if (errOut) *errOut = @"hdiutil returned no /dev/diskN";
return NO;
}
out->diskDev = parent;
emit_log([NSString stringWithFormat:@"Attached as %@", parent]);
/* Find the APFS Data volume under this disk image. Apple Silicon
* installs produce a GPT with three APFS containers (ISC, Main,
* Recovery), so we can't derive the container from the parent name.
* Walk `diskutil apfs list -plist` instead, matching containers by
* PhysicalStores back to our parent partitions. */
emit_status(@"Locating Data volume");
NSString *plistOut = nil;
rc = run_tool(@"/usr/sbin/diskutil",
@[@"apfs", @"list", @"-plist"], &plistOut, &serr);
if (rc != 0 || plistOut.length == 0) {
unmount_and_detach(out);
if (errOut) *errOut = [NSString stringWithFormat:@"diskutil apfs list failed: %@", serr];
return NO;
}
NSData *pd = [plistOut dataUsingEncoding:NSUTF8StringEncoding];
NSError *perr = nil;
id parsed = [NSPropertyListSerialization propertyListWithData:pd
options:0
format:NULL
error:&perr];
if (![parsed isKindOfClass:[NSDictionary class]]) {
unmount_and_detach(out);
if (errOut) *errOut = @"diskutil plist parse failed";
return NO;
}
NSString *parentName = [parent lastPathComponent]; /* e.g. "disk4" */
NSString *prefix = [parentName stringByAppendingString:@"s"]; /* "disk4s" */
NSString *dataVol = nil;
for (NSDictionary *container in parsed[@"Containers"]) {
BOOL ours = NO;
for (NSDictionary *store in container[@"PhysicalStores"]) {
NSString *dev = store[@"DeviceIdentifier"];
if ([dev hasPrefix:prefix]) { ours = YES; break; }
}
if (!ours) continue;
for (NSDictionary *volume in container[@"Volumes"]) {
NSArray *roles = volume[@"Roles"];
if ([roles isKindOfClass:[NSArray class]] &&
[roles containsObject:@"Data"]) {
dataVol = volume[@"DeviceIdentifier"];
break;
}
}
if (dataVol) break;
}
if (!dataVol) {
unmount_and_detach(out);
if (errOut) *errOut = @"No APFS Data-role volume found in image";
return NO;
}
out->dataDev = [NSString stringWithFormat:@"/dev/%@", dataVol];
emit_log([NSString stringWithFormat:@"Data volume: %@", out->dataDev]);
emit_status(@"Mounting Data volume");
NSString *mountOut = nil;
rc = run_tool(@"/usr/sbin/diskutil",
@[@"mount", out->dataDev], &mountOut, &serr);
if (rc != 0) {
unmount_and_detach(out);
if (errOut) *errOut = [NSString stringWithFormat:@"diskutil mount failed: %@", serr];
return NO;
}
/* diskutil prints "Volume <name> on <dev> mounted" — query info for the path. */
NSString *infoOut = nil;
rc = run_tool(@"/usr/sbin/diskutil",
@[@"info", out->dataDev], &infoOut, &serr);
if (rc != 0) {
unmount_and_detach(out);
if (errOut) *errOut = @"diskutil info failed";
return NO;
}
NSString *mp = nil;
for (NSString *line in [infoOut componentsSeparatedByString:@"\n"]) {
NSRange r = [line rangeOfString:@"Mount Point:"];
if (r.location == NSNotFound) continue;
NSString *rest = [[line substringFromIndex:r.location + r.length]
stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
if (rest.length) mp = rest;
break;
}
if (!mp) {
unmount_and_detach(out);
if (errOut) *errOut = @"Data volume mount point not found";
return NO;
}
out->mountPt = mp;
emit_log([NSString stringWithFormat:@"Mounted at %@", mp]);
/* APFS volumes from a disk image are mounted with ignore-ownership
* by default — chown(path, 0, 0) will silently no-op. Enable
* ownership so our root:wheel chown actually persists to disk. */
rc = run_tool(@"/usr/sbin/diskutil",
@[@"enableOwnership", mp], NULL, &serr);
if (rc != 0) {
emit_log([NSString stringWithFormat:@"enableOwnership failed (non-fatal): %@", serr]);
}
return YES;
}
/* ---- Manifest ---- */
typedef struct {
/* Non-retaining: these point into autoreleased NSArrays from
* load_manifest that outlive the whole stage command (the only
* @autoreleasepool wraps cmd_stage in main). NSValue stores raw bytes and
* never retains them, so the struct members must not be __strong. */
__unsafe_unretained NSString *src;
__unsafe_unretained NSString *destRel;
mode_t mode;
uid_t uid;
gid_t gid;
} StageEntry;
static BOOL parse_owner(NSString *ownerStr, uid_t *uid, gid_t *gid, NSString **errOut) {
NSArray *parts = [ownerStr componentsSeparatedByString:@":"];
if (parts.count != 2) {
if (errOut) *errOut = [NSString stringWithFormat:@"invalid owner '%@'", ownerStr];
return NO;
}
const char *user = [parts[0] UTF8String];
const char *grp = [parts[1] UTF8String];
struct passwd *pw = getpwnam(user);
struct group *gr = getgrnam(grp);
if (!pw || !gr) {
if (errOut) *errOut = [NSString stringWithFormat:@"unknown user/group '%@'", ownerStr];
return NO;
}
*uid = pw->pw_uid;
*gid = gr->gr_gid;
return YES;
}
static NSArray<NSValue *> *load_manifest(NSString *path, NSString **errOut) {
NSError *rErr = nil;
NSString *text = [NSString stringWithContentsOfFile:path
encoding:NSUTF8StringEncoding
error:&rErr];
if (!text) {
if (errOut) *errOut = rErr.localizedDescription;
return nil;
}
/* Strip BOM if present. */
if ([text hasPrefix:@"\uFEFF"]) text = [text substringFromIndex:1];
NSMutableArray *rows = [NSMutableArray array];
NSArray *lines = [text componentsSeparatedByString:@"\n"];
int lineno = 0;
for (NSString *raw in lines) {
lineno++;
NSString *line = [raw stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
if (line.length == 0 || [line hasPrefix:@"#"]) continue;
NSArray *fields = [line componentsSeparatedByString:@"\t"];
if (fields.count != 4) {
if (errOut) *errOut = [NSString stringWithFormat:
@"manifest line %d: need 4 tab-separated fields", lineno];
return nil;
}
StageEntry e = {0};
e.src = fields[0];
e.destRel = fields[1];
e.mode = (mode_t)strtol([fields[2] UTF8String], NULL, 8);
NSString *perr = nil;
if (!parse_owner(fields[3], &e.uid, &e.gid, &perr)) {
if (errOut) *errOut = [NSString stringWithFormat:
@"manifest line %d: %@", lineno, perr];
return nil;
}
[rows addObject:[NSValue valueWithBytes:&e objCType:@encode(StageEntry)]];
}
return rows;
}
/* ---- Staging ---- */
static BOOL mkdirs_root(NSString *dir, NSString **errOut) {
NSFileManager *fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:dir]) return YES;
NSError *err = nil;
/* createDirectoryAtPath inherits creator uid/gid; we'll chown afterwards if needed. */
if (![fm createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:@{NSFilePosixPermissions: @(0755)}
error:&err]) {
if (errOut) *errOut = err.localizedDescription;
return NO;
}
return YES;
}
static BOOL stage_entry(StageEntry e, NSString *mountPt, NSString **errOut) {
NSString *dst = [mountPt stringByAppendingPathComponent:e.destRel];
NSString *dstDir = [dst stringByDeletingLastPathComponent];
if (!mkdirs_root(dstDir, errOut)) return NO;
NSFileManager *fm = [NSFileManager defaultManager];
[fm removeItemAtPath:dst error:nil];
NSError *cpErr = nil;
if (![fm copyItemAtPath:e.src toPath:dst error:&cpErr]) {
if (errOut) *errOut = [NSString stringWithFormat:@"copy %@ -> %@: %@",
e.src, e.destRel, cpErr.localizedDescription];
return NO;
}
if (chown(dst.fileSystemRepresentation, e.uid, e.gid) != 0) {
if (errOut) *errOut = [NSString stringWithFormat:@"chown %@: %s",
e.destRel, strerror(errno)];
return NO;
}
if (chmod(dst.fileSystemRepresentation, e.mode) != 0) {
if (errOut) *errOut = [NSString stringWithFormat:@"chmod %@: %s",
e.destRel, strerror(errno)];
return NO;
}
return YES;
}
/* ---- User injection (dslocal) ---- */
/* ShadowHash + kcpassword live in tools/provision/mac_account_hash.{h,m}, shared with the host
* daemon so it can compute them in-process and hand this applier only the non-plaintext result
* (--shadowhash-file / --kcpassword-file). The plaintext --user-password-file path remains for
* direct CLI use, where this applier computes them itself via the same shared functions. */
static BOOL write_plist(NSDictionary *plist, NSString *path,
uid_t uid, gid_t gid, mode_t mode,
NSString **errOut) {
NSError *e = nil;
NSData *d = [NSPropertyListSerialization dataWithPropertyList:plist
format:NSPropertyListXMLFormat_v1_0
options:0
error:&e];
if (!d) {
if (errOut) *errOut = [NSString stringWithFormat:@"plist serialize: %@",
e.localizedDescription];
return NO;
}
[[NSFileManager defaultManager] createDirectoryAtPath:
[path stringByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil error:nil];
if (![d writeToFile:path atomically:YES]) {
if (errOut) *errOut = [NSString stringWithFormat:@"write %@", path];
return NO;
}
chown(path.fileSystemRepresentation, uid, gid);
chmod(path.fileSystemRepresentation, mode);
return YES;
}
static BOOL add_user_to_admin_group(NSString *mountPt, NSString *shortname,
NSString *userUUID, NSString **errOut) {
NSString *adminPath = [mountPt stringByAppendingPathComponent:
@"private/var/db/dslocal/nodes/Default/groups/admin.plist"];
NSMutableDictionary *admin = [[NSDictionary dictionaryWithContentsOfFile:adminPath]
mutableCopy];
if (!admin) {
/* Some macOS installs put it under /var/db/... Try both. */
adminPath = [mountPt stringByAppendingPathComponent:
@"var/db/dslocal/nodes/Default/groups/admin.plist"];
admin = [[NSDictionary dictionaryWithContentsOfFile:adminPath] mutableCopy];
}
if (!admin) {
if (errOut) *errOut = @"admin group plist not found on disk";
return NO;
}
NSMutableArray *users = [(admin[@"users"] ?: @[]) mutableCopy];
NSMutableArray *members = [(admin[@"groupmembers"] ?: @[]) mutableCopy];
if (![users containsObject:shortname]) [users addObject:shortname];
if (![members containsObject:userUUID]) [members addObject:userUUID];
admin[@"users"] = users;
admin[@"groupmembers"] = members;
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<NSString *> *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,
NSString **errOut) {
emit_status([NSString stringWithFormat:@"Creating user '%@'", shortname]);
if (!shd) {
if (errOut) *errOut = @"missing ShadowHashData";
return NO;
}
if (!new_user_paths_available(mountPt, shortname, errOut)) return NO;
NSString *uuidStr = [[NSUUID UUID] UUIDString];
NSDictionary *userPlist = @{
@"authentication_authority":
@[@";ShadowHash;HASHLIST:<SALTED-SHA512-PBKDF2>"],
@"generateduid": @[uuidStr],
@"gid": @[@"20"],
@"home": @[[NSString stringWithFormat:@"/Users/%@", shortname]],
@"name": @[shortname],
@"passwd": @[@"********"],
@"realname": @[realname],
@"shell": @[@"/bin/zsh"],
@"uid": @[[NSString stringWithFormat:@"%d", uid]],
@"ShadowHashData": @[shd],
};
NSString *userPlistPath = [mountPt stringByAppendingPathComponent:
[NSString stringWithFormat:
@"private/var/db/dslocal/nodes/Default/users/%@.plist", shortname]];
if (!write_plist(userPlist, userPlistPath, 0, 0, 0600, errOut)) {
/* Fall back to non-firmlinked path if private/ layout differs. */
userPlistPath = [mountPt stringByAppendingPathComponent:
[NSString stringWithFormat:
@"var/db/dslocal/nodes/Default/users/%@.plist", shortname]];
if (!write_plist(userPlist, userPlistPath, 0, 0, 0600, errOut)) return NO;
}
if (!add_user_to_admin_group(mountPt, shortname, uuidStr, errOut)) return NO;
/* Pre-create home dir skeleton so per-user SetupAssistant.plist persists. */
NSString *homePath = [mountPt stringByAppendingPathComponent:
[NSString stringWithFormat:@"Users/%@", shortname]];
NSString *prefsPath = [homePath stringByAppendingPathComponent:@"Library/Preferences"];
[[NSFileManager defaultManager] createDirectoryAtPath:prefsPath
withIntermediateDirectories:YES
attributes:nil error:nil];
/* Recursively chown home → uid:staff (gid 20). */
NSTask *t = [[NSTask alloc] init];
t.launchPath = @"/usr/sbin/chown";
t.arguments = @[@"-R",
[NSString stringWithFormat:@"%d:20", uid],
homePath];
[t launchAndReturnError:nil];
[t waitUntilExit];
chmod(homePath.fileSystemRepresentation, 0700);
return YES;
}
/* Create an empty file with given owner/mode; mkdir -p'ing the parent. */
static BOOL touch_marker(NSString *path, uid_t uid, gid_t gid, mode_t mode,
NSString **errOut) {
[[NSFileManager defaultManager] createDirectoryAtPath:
[path stringByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil error:nil];
int fd = open(path.fileSystemRepresentation, O_CREAT|O_WRONLY|O_TRUNC, mode);
if (fd < 0) {
if (errOut) *errOut = [NSString stringWithFormat:@"touch %@: %s",
path.lastPathComponent, strerror(errno)];
return NO;
}
close(fd);
chown(path.fileSystemRepresentation, uid, gid);
chmod(path.fileSystemRepresentation, mode);
return YES;
}
static BOOL skip_setup_assistant(NSString *mountPt, NSString *shortname, int uid,
NSString **errOut) {
emit_status(@"Skipping Setup Assistant");
/* ---- System-wide marker files ----
* Four flags together cover all the code paths inside the OS that
* decide whether to run the initial setup Buddy:
* .AppleSetupDone — the main system-level flag
* .SetupRegComplete — older receipt path, still checked
* User Template/.skipbuddy — suppresses Buddy for future logins too
*/
NSString *doneFile = [mountPt stringByAppendingPathComponent:
@"private/var/db/.AppleSetupDone"];
if (!touch_marker(doneFile, 0, 0, 0644, errOut)) {
doneFile = [mountPt stringByAppendingPathComponent:@"var/db/.AppleSetupDone"];
if (!touch_marker(doneFile, 0, 0, 0644, errOut)) return NO;
}
(void)touch_marker([mountPt stringByAppendingPathComponent:
@"Library/Receipts/.SetupRegComplete"], 0, 0, 0644, NULL);
(void)touch_marker([mountPt stringByAppendingPathComponent:
@"Library/User Template/English.lproj/.skipbuddy"], 0, 0, 0644, NULL);
(void)touch_marker([mountPt stringByAppendingPathComponent:
@"Library/User Template/Non_localized.lproj/.skipbuddy"], 0, 0, 0644, NULL);
/* Per-user com.apple.SetupAssistant.plist — DidSee* flags + a few
* MiniBuddy gate keys. On non-MDM Tahoe, this suppresses the older
* panes (Privacy, Siri, Screen Time, Touch ID, True Tone, Appearance,
* Accessibility, etc.). It does NOT suppress Apple Account, Age Range,
* Analytics, FileVault, or the Software Update prompt — those panes
* only respect the com.apple.SetupAssistant.managed domain via real
* MDM enrollment, which is out of scope here. The user clicks through
* those 5 once on first VM creation. */
NSDictionary *sa = @{
@"DidSeeAccessibility": @YES,
@"DidSeeAccessibilitySetup": @YES,
@"DidSeeActivationLock": @YES,
@"DidSeeAppearanceSetup": @YES,
@"DidSeeAppStore": @YES,
@"DidSeeAvatarSetup": @YES,
@"DidSeeContinuity": @YES,
@"DidSeeEmergencySOS": @YES,
@"DidSeeIntelligence": @YES,
@"DidSeeLockdownMode": @YES,
@"DidSeeOSShowcase": @YES,
@"DidSeePrivacy": @YES,
@"DidSeePrivacyConsent": @YES,
@"DidSeeScreenTime": @YES,
@"DidSeeSiriSetup": @YES,
@"DidSeeTerms": @YES,
@"DidSeeTermsOfAddress": @YES,
@"DidSeeTOS": @YES,
@"DidSeeTouchIDSetup": @YES,
@"DidSeeTrueToneDisplay": @YES,
@"DidSeeUpdateCompleted": @YES,
/* Gate keys MiniBuddy consults alongside the DidSee flags. Setting
* these explicitly prevents unrelated "you haven't finished setup"
* prompts on subsequent boots. */
@"InitialAccountOnMac": @YES,
@"MiniBuddyLaunchReason": @0,
@"MiniBuddyLaunchedPostMigration": @YES,
@"MiniBuddyShouldLaunchToResumeSetup": @NO,
@"SkipExpressSettingsUpdating": @YES,
@"SkipFirstLoginOptimization": @YES,
@"SkipFirstLoginOptIn": @YES,
@"GestureMovieSeen": @"none",
/* Out-of-range sentinel: prevents re-prompts after OS updates. */
@"LastSeenBuddyBuildVersion": @"99.99",
@"LastSeenCloudProductVersion": @"99.99",
@"PreviousBuildVersion": @"99.99",
@"PreviousSystemVersion": @"99.99",
@"RunNonInteractive": @YES,
};
NSString *saUserPath = [mountPt stringByAppendingPathComponent:
[NSString stringWithFormat:
@"Users/%@/Library/Preferences/com.apple.SetupAssistant.plist", shortname]];
if (!write_plist(sa, saUserPath, uid, 20, 0600, errOut)) return NO;
/* System-wide copy of the same plist — some code paths check the
* machine-scope domain in addition to the per-user one. */
NSString *saSysPath = [mountPt stringByAppendingPathComponent:
@"Library/Preferences/com.apple.SetupAssistant.plist"];
if (!write_plist(sa, saSysPath, 0, 0, 0644, errOut)) return NO;
return YES;
}
/* Enable the built-in OpenSSH daemon by clearing its Disabled flag in
* launchd's override database. This works offline (disk mounted on the
* host) and survives reboots, which `systemsetup -setremotelogin on`
* does not from a LaunchDaemon context on modern macOS (requires Full
* Disk Access through TCC that root LaunchDaemons don't have). */
static BOOL enable_ssh(NSString *mountPt, NSString **errOut) {
emit_status(@"Enabling SSH (launchd override)");
NSString *path = [mountPt stringByAppendingPathComponent:
@"private/var/db/com.apple.xpc.launchd/disabled.plist"];
NSMutableDictionary *d =
[[NSDictionary dictionaryWithContentsOfFile:path] mutableCopy];
if (!d) {
/* Alternate layout without the /private firmlink indirection. */
NSString *alt = [mountPt stringByAppendingPathComponent:
@"var/db/com.apple.xpc.launchd/disabled.plist"];
d = [[NSDictionary dictionaryWithContentsOfFile:alt] mutableCopy];
if (d) path = alt;
}
if (!d) d = [NSMutableDictionary dictionary];
d[@"com.openssh.sshd"] = @NO; /* NO = not disabled */
return write_plist(d, path, 0, 0, 0644, errOut);
}
static BOOL enable_autologin(NSString *mountPt, NSString *shortname,
NSData *kc,
NSString **errOut) {
emit_status(@"Enabling auto-login");
/* /Library/Preferences/com.apple.loginwindow.plist: autoLoginUser. */
NSString *lwPath = [mountPt stringByAppendingPathComponent:
@"Library/Preferences/com.apple.loginwindow.plist"];
NSMutableDictionary *lw =
[[NSDictionary dictionaryWithContentsOfFile:lwPath] mutableCopy]
?: [NSMutableDictionary dictionary];
lw[@"autoLoginUser"] = shortname;
if (!write_plist(lw, lwPath, 0, 0, 0644, errOut)) return NO;
/* /etc/kcpassword: obfuscated password bytes (XOR cipher). */
if (!kc) { if (errOut) *errOut = @"missing kcpassword"; return NO; }
NSString *kcPath = [mountPt stringByAppendingPathComponent:@"private/etc/kcpassword"];
[[NSFileManager defaultManager] createDirectoryAtPath:
[kcPath stringByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil error:nil];
if (![kc writeToFile:kcPath atomically:YES]) {
kcPath = [mountPt stringByAppendingPathComponent:@"etc/kcpassword"];
if (![kc writeToFile:kcPath atomically:YES]) {
if (errOut) *errOut = @"kcpassword write failed";
return NO;
}
}
chown(kcPath.fileSystemRepresentation, 0, 0);
chmod(kcPath.fileSystemRepresentation, 0600);
return YES;
}
/* Drop a sidecar file with the user's chosen display name so firstboot.sh
* can apply it via `scutil --set` on first boot. Writing the SystemConfig
* preferences.plist offline does not work: configd owns that file and
* regenerates it at boot from its own state, falling back to the hardware
* model name ("Apple Virtual Machine") if it never saw a live API call.
* The canonical path is SCDynamicStoreSetComputerName /
* SCPreferencesSetLocalHostName, which scutil wraps. */
static BOOL set_computer_name(NSString *mountPt, NSString *displayName,
NSString **errOut) {
emit_status([NSString stringWithFormat:@"Queueing computer name (%@)",
displayName]);
NSString *dir = [mountPt stringByAppendingPathComponent:
@"Library/AppSandbox"];
[[NSFileManager defaultManager] createDirectoryAtPath:dir
withIntermediateDirectories:YES
attributes:nil error:nil];
NSString *path = [dir stringByAppendingPathComponent:@"computer-name"];
NSError *err = nil;
if (![displayName writeToFile:path atomically:YES
encoding:NSUTF8StringEncoding error:&err]) {
if (errOut) *errOut = err.localizedDescription;
return NO;
}
chown(path.fileSystemRepresentation, 0, 0);
chmod(path.fileSystemRepresentation, 0644);
return YES;
}
/* ---- Global state for signal-safe cleanup ---- */
static MountState g_mount = {0};
static volatile sig_atomic_t g_cleaning_up = 0;
static void cleanup_on_signal(int sig) {
(void)sig;
if (g_cleaning_up) _exit(130);
g_cleaning_up = 1;
unmount_and_detach(&g_mount);
_exit(130);
}
/* ---- "stage" subcommand ---- */
static int cmd_stage(int argc, char **argv) {
NSString *diskPath = nil;
NSString *manifest = nil;
NSString *userShort = nil;
NSString *userReal = nil;
int userUid = 501;
NSString *passFile = nil;
NSString *shadowhashFile = nil; /* daemon-supplied precomputed ShadowHashData (no plaintext) */
NSString *kcpasswordFile = nil; /* daemon-supplied precomputed /etc/kcpassword bytes */
NSString *computerName = nil;
BOOL skipSA = NO;
BOOL autoLogin = NO;
BOOL sshOn = NO;
for (int i = 0; i < argc; i++) {
if (strcmp(argv[i], "--disk") == 0 && i + 1 < argc)
diskPath = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--manifest") == 0 && i + 1 < argc)
manifest = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--user-shortname") == 0 && i + 1 < argc)
userShort = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--user-realname") == 0 && i + 1 < argc)
userReal = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--user-uid") == 0 && i + 1 < argc)
userUid = atoi(argv[++i]);
else if (strcmp(argv[i], "--user-password-file") == 0 && i + 1 < argc)
passFile = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--shadowhash-file") == 0 && i + 1 < argc)
shadowhashFile = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--kcpassword-file") == 0 && i + 1 < argc)
kcpasswordFile = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--computer-name") == 0 && i + 1 < argc)
computerName = [NSString stringWithUTF8String:argv[++i]];
else if (strcmp(argv[i], "--skip-setup-assistant") == 0)
skipSA = YES;
else if (strcmp(argv[i], "--auto-login") == 0)
autoLogin = YES;
else if (strcmp(argv[i], "--enable-ssh") == 0)
sshOn = YES;
}
if (!diskPath || !manifest) {
emit_error(@"stage: --disk and --manifest required");
return 2;
}
if (geteuid() != 0) {
emit_error(@"stage: must run as root (invoke via AEWP)");
return 2;
}
NSString *err = nil;
NSArray *entries = load_manifest(manifest, &err);
if (!entries) { emit_error(err); return 3; }
/* Resolve the account credential material. In the daemon flow the host computes the ShadowHash +
* kcpassword IN-PROCESS and passes them as files -- this applier never sees the plaintext (the
* mirror of the Windows path, where the password is encoded in the daemon, not the disk-applier).
* Direct CLI use passes --user-password-file instead; we then compute them here via the same
* shared encoders. The password buffer is wiped before exit. */
char passBuf[256] = {0};
size_t passLen = 0;
if (passFile) {
int pfd = open(passFile.fileSystemRepresentation, O_RDONLY);
if (pfd < 0) {
emit_error([NSString stringWithFormat:@"open password file: %s", strerror(errno)]);
return 3;
}
ssize_t n = read(pfd, passBuf, sizeof(passBuf) - 1);
close(pfd);
if (n > 0) passLen = (size_t)n;
}
NSData *shdData = nil, *kcData = nil;
if (shadowhashFile) {
shdData = [NSData dataWithContentsOfFile:shadowhashFile];
if (!shdData) { memset(passBuf, 0, sizeof(passBuf)); emit_error(@"stage: cannot read --shadowhash-file"); return 3; }
} else if (passLen > 0) {
shdData = asb_macos_shadow_hash_data(passBuf, passLen);
}
if (kcpasswordFile) {
kcData = [NSData dataWithContentsOfFile:kcpasswordFile];
if (!kcData) { memset(passBuf, 0, sizeof(passBuf)); emit_error(@"stage: cannot read --kcpassword-file"); return 3; }
} else if (passLen > 0) {
kcData = asb_macos_kcpassword(passBuf, passLen);
}
emit_progress(5, @"Loaded manifest");
if (!mount_disk(diskPath, &g_mount, &err)) {
memset(passBuf, 0, sizeof(passBuf));
emit_error(err);
return 4;
}
signal(SIGINT, cleanup_on_signal);
signal(SIGTERM, cleanup_on_signal);
signal(SIGPIPE, SIG_IGN);
emit_progress(30, @"Staging files");
int total = (int)entries.count;
int fi = 0;
for (NSValue *v in entries) {
StageEntry e = {0};
[v getValue:&e];
fi++;
emit_progress(30 + (40 * fi / MAX(total, 1)),
[NSString stringWithFormat:@"Copying %@", e.destRel]);
if (!stage_entry(e, g_mount.mountPt, &err)) {
unmount_and_detach(&g_mount);
memset(passBuf, 0, sizeof(passBuf));
emit_error(err);
return 5;
}
}
/* Optional: create user, skip Setup Assistant, enable autologin. */
if (userShort && shdData) {
emit_progress(75, @"Creating user account");
if (!inject_user(g_mount.mountPt, userShort, userReal ?: userShort,
userUid, shdData, &err)) {
unmount_and_detach(&g_mount);
memset(passBuf, 0, sizeof(passBuf));
emit_error(err);
return 6;
}
}
if (skipSA && userShort) {
emit_progress(85, @"Skipping Setup Assistant");
if (!skip_setup_assistant(g_mount.mountPt, userShort, userUid, &err)) {
unmount_and_detach(&g_mount);
memset(passBuf, 0, sizeof(passBuf));
emit_error(err);
return 7;
}
}
if (autoLogin && userShort && kcData) {
emit_progress(90, @"Enabling auto-login");
if (!enable_autologin(g_mount.mountPt, userShort, kcData, &err)) {
unmount_and_detach(&g_mount);
memset(passBuf, 0, sizeof(passBuf));
emit_error(err);
return 8;
}
}
/* Enable SSH only if the user opted in at create time. When enabled,
* we flip launchd's disabled.plist offline so sshd is on from first
* boot, and the host-side vsock SSH proxy exposes it on a loopback
* port. When disabled, sshd stays off. */
if (sshOn) {
emit_progress(93, @"Enabling SSH");
if (!enable_ssh(g_mount.mountPt, &err)) {
unmount_and_detach(&g_mount);
memset(passBuf, 0, sizeof(passBuf));
emit_error(err);
return 9;
}
}
if (computerName.length) {
emit_progress(94, @"Setting computer name");
if (!set_computer_name(g_mount.mountPt, computerName, &err)) {
unmount_and_detach(&g_mount);
memset(passBuf, 0, sizeof(passBuf));
emit_error(err);
return 10;
}
}
emit_progress(95, @"Unmounting");
unmount_and_detach(&g_mount);
/* Wipe plaintext password from RAM before we exit. */
memset(passBuf, 0, sizeof(passBuf));
emit_progress(100, @"Done");
emit_done(diskPath);
return 0;
}
/* ---- NSURLSession download delegate (fetch-ipsw) ---- */
@interface IPSWDownloadDelegate : NSObject <NSURLSessionDownloadDelegate>
@property (nonatomic, copy) NSString *outputPath;
@property (nonatomic, copy) NSString *sidecarPath;
@property (nonatomic, copy) NSURL *sourceURL;
@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSURLSessionDownloadTask *currentTask;
@property (nonatomic, copy) void (^completionHandler)(NSError *);
@property (nonatomic, assign) BOOL reportedSize;
@property (nonatomic, assign) int lastPct;
@end
@implementation IPSWDownloadDelegate
/* Sidecar: 8-byte LE URL length, URL bytes, resume-data blob. */
- (void)persistResumeData:(NSData *)data {
if (!data || !self.sidecarPath || !self.sourceURL) return;
NSData *urlData = [self.sourceURL.absoluteString dataUsingEncoding:NSUTF8StringEncoding];
uint64_t n = (uint64_t)urlData.length;
NSMutableData *out = [NSMutableData dataWithCapacity:8 + urlData.length + data.length];
[out appendBytes:&n length:sizeof(n)];
[out appendData:urlData];
[out appendData:data];
[out writeToFile:self.sidecarPath atomically:YES];
}
- (void)clearSidecar {
if (self.sidecarPath) {
[[NSFileManager defaultManager] removeItemAtPath:self.sidecarPath error:nil];
}
}
+ (NSData *)resumeDataFromSidecarAt:(NSString *)path matchingURL:(NSURL *)url {
NSData *raw = [NSData dataWithContentsOfFile:path];
if (raw.length < 8) return nil;
uint64_t n = 0;
[raw getBytes:&n length:sizeof(n)];
/* raw.length >= 8 is guaranteed above, so (raw.length - 8) can't underflow;
* compare with subtraction rather than 8 + n. */
if (n == 0 || n > raw.length - 8) return nil;
NSData *urlData = [raw subdataWithRange:NSMakeRange(8, (NSUInteger)n)];
NSString *savedURL = [[NSString alloc] initWithData:urlData encoding:NSUTF8StringEncoding];
if (![savedURL isEqualToString:url.absoluteString]) return nil;
return [raw subdataWithRange:NSMakeRange(8 + (NSUInteger)n,
raw.length - 8 - (NSUInteger)n)];
}
- (void)URLSession:(NSURLSession *)session
downloadTask:(NSURLSessionDownloadTask *)task
didWriteData:(int64_t)bytes
totalBytesWritten:(int64_t)totalWritten
totalBytesExpectedToWrite:(int64_t)totalExpected {
(void)session; (void)task; (void)bytes;
if (totalExpected <= 0) return;
if (!self.reportedSize) {
self.reportedSize = YES;
emit_log([NSString stringWithFormat:@"Restore image size: %.1f GB",
(double)totalExpected / 1073741824.0]);
}
int pct = (int)(100.0 * (double)totalWritten / (double)totalExpected);
if (pct == self.lastPct) return;
self.lastPct = pct;
emit_progress(pct, [NSString stringWithFormat:@"Downloading (%d%%)", pct]);
}
- (void)URLSession:(NSURLSession *)session
downloadTask:(NSURLSessionDownloadTask *)task
didResumeAtOffset:(int64_t)fileOffset
expectedTotalBytes:(int64_t)expectedTotalBytes {
(void)session; (void)task;
int pct = (expectedTotalBytes > 0)
? (int)(100.0 * (double)fileOffset / (double)expectedTotalBytes)
: self.lastPct;
self.lastPct = pct;
emit_progress(pct, [NSString stringWithFormat:@"Resuming download (%d%%)", pct]);
}
- (void)URLSession:(NSURLSession *)session
taskIsWaitingForConnectivity:(NSURLSessionTask *)task {
(void)session; (void)task;
emit_progress(self.lastPct,
[NSString stringWithFormat:@"Waiting for network (%d%%)", self.lastPct]);
}
- (void)URLSession:(NSURLSession *)session
downloadTask:(NSURLSessionDownloadTask *)task
didFinishDownloadingToURL:(NSURL *)location {
(void)session; (void)task;
NSFileManager *fm = [NSFileManager defaultManager];
[fm removeItemAtPath:self.outputPath error:nil];
NSError *err = nil;
if (![fm moveItemAtPath:location.path toPath:self.outputPath error:&err]) {
if (self.completionHandler) { self.completionHandler(err); self.completionHandler = nil; }
return;
}
[self clearSidecar];
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(NSError *)error {
(void)session; (void)task;
if (!error) {
if (self.completionHandler) { self.completionHandler(nil); self.completionHandler = nil; }
return;
}
if (error.code == NSURLErrorCancelled) {
if (self.completionHandler) { self.completionHandler(error); self.completionHandler = nil; }
return;
}
NSData *resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData];
if (resumeData) [self persistResumeData:resumeData];
emit_log([NSString stringWithFormat:@"Download interrupted (%@); reconnecting",
error.localizedDescription]);
emit_progress(self.lastPct,
[NSString stringWithFormat:@"Reconnecting (%d%%)", self.lastPct]);
NSURLSessionDownloadTask *next = resumeData
? [self.session downloadTaskWithResumeData:resumeData]
: [self.session downloadTaskWithURL:self.sourceURL];
self.currentTask = next;
[next resume];
}
@end
/* ---- Install progress observer ---- */
@interface InstallProgressObserver : NSObject
@property (nonatomic, strong) NSProgress *observed;
@property (nonatomic, assign) int lastPct;
@end
@implementation InstallProgressObserver
- (instancetype)init { self = [super init]; if (self) _lastPct = -1; return self; }
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
(void)object; (void)change; (void)context;
if (![keyPath isEqualToString:@"fractionCompleted"]) return;
int pct = (int)(self.observed.fractionCompleted * 100.0);
if (pct == self.lastPct) return;
self.lastPct = pct;
emit_progress(pct, [NSString stringWithFormat:@"Installing macOS (%d%%)", pct]);
}
@end
/* ---- "fetch-ipsw" subcommand ---- */
static int cmd_fetch_ipsw(int argc, char **argv) {
NSString *outPath = nil;
for (int i = 0; i < argc; i++) {
if (strcmp(argv[i], "--output") == 0 && i + 1 < argc)
outPath = [NSString stringWithUTF8String:argv[++i]];
}
if (!outPath) { emit_error(@"fetch-ipsw: --output required"); return 2; }
NSString *sidecar = [outPath stringByAppendingPathExtension:@"resume"];
__block int exitCode = 0;
__block IPSWDownloadDelegate *activeDelegate = nil;
/* On SIGTERM, persist resume data so a later run can pick up here. */
signal(SIGTERM, SIG_IGN);
dispatch_source_t sigSrc = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL,
SIGTERM, 0,
dispatch_get_main_queue());
dispatch_source_set_event_handler(sigSrc, ^{
IPSWDownloadDelegate *d = activeDelegate;
if (!d.currentTask) {
CFRunLoopStop(CFRunLoopGetMain());
return;
}
[d.currentTask cancelByProducingResumeData:^(NSData * _Nullable data) {
if (data) [d persistResumeData:data];
CFRunLoopStop(CFRunLoopGetMain());
}];
});
dispatch_resume(sigSrc);
dispatch_async(dispatch_get_main_queue(), ^{
emit_status(@"Querying Apple for latest restore image");
[VZMacOSRestoreImage fetchLatestSupportedWithCompletionHandler:
^(VZMacOSRestoreImage * _Nullable img, NSError * _Nullable err) {
if (err || !img) {
emit_error(err.localizedDescription ?: @"no supported restore image");
exitCode = 3;
CFRunLoopStop(CFRunLoopGetMain());
return;
}
emit_log([NSString stringWithFormat:@"Source: %@", img.URL.absoluteString]);
/* Only reuse the sidecar if Apple hasn't rotated the IPSW URL. */
NSData *priorResume = [IPSWDownloadDelegate
resumeDataFromSidecarAt:sidecar matchingURL:img.URL];
if (priorResume) {
emit_log(@"Found prior resume data; attempting to resume");
} else if ([[NSFileManager defaultManager] fileExistsAtPath:sidecar]) {
[[NSFileManager defaultManager] removeItemAtPath:sidecar error:nil];
emit_log(@"Discarded stale resume data (source URL changed)");
}
IPSWDownloadDelegate *del = [[IPSWDownloadDelegate alloc] init];
del.outputPath = outPath;
del.sidecarPath = sidecar;
del.sourceURL = img.URL;
del.completionHandler = ^(NSError *dlErr) {
if (dlErr) {
emit_error(dlErr.localizedDescription);
exitCode = 4;
} else {
emit_done(outPath);
}
CFRunLoopStop(CFRunLoopGetMain());
};
NSURLSessionConfiguration *cfg = [NSURLSessionConfiguration defaultSessionConfiguration];
cfg.timeoutIntervalForRequest = 0;
cfg.timeoutIntervalForResource = 0;
cfg.waitsForConnectivity = YES;
NSURLSession *session = [NSURLSession sessionWithConfiguration:cfg
delegate:del
delegateQueue:nil];
del.session = session;
NSURLSessionDownloadTask *task = priorResume
? [session downloadTaskWithResumeData:priorResume]
: [session downloadTaskWithURL:img.URL];
del.currentTask = task;
activeDelegate = del;
[task resume];
}];
});
CFRunLoopRun();
return exitCode;
}
/* ---- "install" subcommand ---- */
/* Hold installer + observer across the async call so ARC doesn't release them. */
static VZMacOSInstaller *g_installer = nil;
static InstallProgressObserver *g_progressObs = nil;
static VZVirtualMachine *g_installVM = nil;
static int cmd_install(int argc, char **argv) {
NSString *name = nil, *vmDir = nil, *ipsw = nil, *diskPath = nil;
NSString *macAddress = 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]);
else if (strcmp(argv[i], "--disk-gb") == 0 && i + 1 < argc) diskGb = atoi(argv[++i]);
else if (strcmp(argv[i], "--mac-address") == 0 && i + 1 < argc) macAddress = @(argv[++i]);
}
if (!name || !vmDir || !ipsw || ramMb <= 0 || cpus <= 0 || diskGb <= 0) {
emit_error(@"install: --name --vm-dir --ipsw --ram-mb --cpus --disk-gb all required");
return 2;
}
VZMACAddress *mac = macAddress ? [[VZMACAddress alloc] initWithString:macAddress] : nil;
if (macAddress && !mac.isUnicastAddress) {
emit_error(@"install: invalid MAC address");
return 2;
}
NSFileManager *fm = [NSFileManager defaultManager];
NSError *mkErr = nil;
if (![fm createDirectoryAtPath:vmDir withIntermediateDirectories:YES
attributes:nil error:&mkErr]) {
emit_error([NSString stringWithFormat:@"mkdir %@: %@", vmDir, mkErr.localizedDescription]);
return 3;
}
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"];
emit_status(@"Creating disk image");
uint64_t sizeBytes = (uint64_t)diskGb * 1024ULL * 1024ULL * 1024ULL;
int dfd = open(diskPath.fileSystemRepresentation,
O_CREAT | O_WRONLY | O_TRUNC, 0644);
if (dfd < 0) {
emit_error([NSString stringWithFormat:@"open disk.img: %s", strerror(errno)]);
return 4;
}
if (ftruncate(dfd, (off_t)sizeBytes) != 0) {
int e = errno; close(dfd);
emit_error([NSString stringWithFormat:@"ftruncate disk.img: %s", strerror(e)]);
return 4;
}
close(dfd);
emit_progress(5, @"Disk image created");
__block int exitCode = 0;
dispatch_async(dispatch_get_main_queue(), ^{
emit_status(@"Loading restore image");
[VZMacOSRestoreImage loadFileURL:[NSURL fileURLWithPath:ipsw]
completionHandler:^(VZMacOSRestoreImage * _Nullable img,
NSError * _Nullable loadErr) {
/* loadFileURL's completion fires on an internal queue. All
* subsequent VZ APIs (VZVirtualMachine, VZMacOSInstaller,
* VZMacAuxiliaryStorage) require the main queue. */
dispatch_async(dispatch_get_main_queue(), ^{
if (!img) {
emit_error(loadErr.localizedDescription ?: @"failed to load IPSW");
exitCode = 5;
CFRunLoopStop(CFRunLoopGetMain());
return;
}
VZMacOSConfigurationRequirements *reqs = img.mostFeaturefulSupportedConfiguration;
if (!reqs) {
emit_error(@"restore image has no supported configuration on this host");
exitCode = 6;
CFRunLoopStop(CFRunLoopGetMain());
return;
}
int finalCpus = MAX(cpus, (int)reqs.minimumSupportedCPUCount);
uint64_t ramBytes = MAX((uint64_t)ramMb * 1024ULL * 1024ULL,
reqs.minimumSupportedMemorySize);
VZMacHardwareModel *hw = reqs.hardwareModel;
[hw.dataRepresentation writeToFile:hwPath atomically:YES];
VZMacMachineIdentifier *mid = [[VZMacMachineIdentifier alloc] init];
[mid.dataRepresentation writeToFile:midPath atomically:YES];
NSError *auxErr = nil;
VZMacAuxiliaryStorage *aux = [[VZMacAuxiliaryStorage alloc]
initCreatingStorageAtURL:[NSURL fileURLWithPath:auxPath]
hardwareModel:hw
options:VZMacAuxiliaryStorageInitializationOptionAllowOverwrite
error:&auxErr];
if (!aux) {
emit_error(auxErr.localizedDescription ?: @"aux storage creation failed");
exitCode = 7;
CFRunLoopStop(CFRunLoopGetMain());
return;
}
NSError *attErr = nil;
VZDiskImageStorageDeviceAttachment *att =
[[VZDiskImageStorageDeviceAttachment alloc]
initWithURL:[NSURL fileURLWithPath:diskPath]
readOnly:NO error:&attErr];
if (!att) {
emit_error(attErr.localizedDescription ?: @"disk attach failed");
exitCode = 8;
CFRunLoopStop(CFRunLoopGetMain());
return;
}
VZVirtualMachineConfiguration *config = [[VZVirtualMachineConfiguration alloc] init];
config.CPUCount = (NSUInteger)finalCpus;
config.memorySize = ramBytes;
VZMacPlatformConfiguration *platform = [[VZMacPlatformConfiguration alloc] init];
platform.hardwareModel = hw;
platform.machineIdentifier = mid;
platform.auxiliaryStorage = aux;
config.platform = platform;
config.bootLoader = [[VZMacOSBootLoader alloc] init];
config.storageDevices = @[[[VZVirtioBlockDeviceConfiguration alloc]
initWithAttachment:att]];
VZMacGraphicsDeviceConfiguration *gfx = [[VZMacGraphicsDeviceConfiguration alloc] init];
gfx.displays = @[[[VZMacGraphicsDisplayConfiguration alloc]
initWithWidthInPixels:2560
heightInPixels:1600
pixelsPerInch:144]];
config.graphicsDevices = @[gfx];
config.keyboards = @[[[VZUSBKeyboardConfiguration alloc] init]];
config.pointingDevices = @[[[VZMacTrackpadConfiguration alloc] init],
[[VZUSBScreenCoordinatePointingDeviceConfiguration alloc] init]];
VZNATNetworkDeviceAttachment *natAtt = [[VZNATNetworkDeviceAttachment alloc] init];
VZVirtioNetworkDeviceConfiguration *netCfg = [[VZVirtioNetworkDeviceConfiguration alloc] init];
if (mac) netCfg.MACAddress = mac;
netCfg.attachment = natAtt;
config.networkDevices = @[netCfg];
config.socketDevices = @[[[VZVirtioSocketDeviceConfiguration alloc] init]];
NSError *valErr = nil;
if (![config validateWithError:&valErr]) {
emit_error(valErr.localizedDescription ?: @"invalid VM config");
exitCode = 9;
CFRunLoopStop(CFRunLoopGetMain());
return;
}
g_installVM = [[VZVirtualMachine alloc] initWithConfiguration:config];
g_installer = [[VZMacOSInstaller alloc]
initWithVirtualMachine:g_installVM
restoreImageURL:[NSURL fileURLWithPath:ipsw]];
g_progressObs = [[InstallProgressObserver alloc] init];
g_progressObs.observed = g_installer.progress;
[g_installer.progress addObserver:g_progressObs
forKeyPath:@"fractionCompleted"
options:NSKeyValueObservingOptionNew
context:NULL];
emit_status(@"Installing macOS");
[g_installer installWithCompletionHandler:^(NSError * _Nullable installErr) {
dispatch_async(dispatch_get_main_queue(), ^{
@try {
[g_installer.progress removeObserver:g_progressObs
forKeyPath:@"fractionCompleted"];
} @catch (__unused NSException *ex) {}
if (installErr) {
emit_error(installErr.localizedDescription);
exitCode = 10;
} else {
emit_progress(100, @"Install complete");
emit_done(diskPath);
}
CFRunLoopStop(CFRunLoopGetMain());
});
}];
});
}];
});
CFRunLoopRun();
g_installer = nil;
g_progressObs = nil;
g_installVM = nil;
return exitCode;
}
/* ---- "publish-shm" subcommand ----
* Write the asb_transport directory (region table + slot caps) into the ivshmem backing file
* before QEMU boots, so every guest service's asb_transport_init() sees ASB_SHM_DIR_MAGIC at start
* and selects the ivshmem backend. The VM launcher owns the directory; the viewer/host attach as
* consumers. The product QemuVm launcher calls asb_shm_publish() in-process from the same header;
* this subcommand is the equivalent for the raw-QEMU dev harness (and builds via Xcode, not clang). */
static int cmd_publish_shm(int argc, char **argv) {
NSString *shmPath = nil;
for (int i = 0; i < argc; i++) {
if (strcmp(argv[i], "--shm") == 0 && i + 1 < argc)
shmPath = [NSString stringWithUTF8String:argv[++i]];
}
if (!shmPath) { emit_error(@"publish-shm: --shm <path> required"); return 2; }
int fd = open(shmPath.fileSystemRepresentation, O_RDWR);
if (fd < 0) {
emit_error([NSString stringWithFormat:@"open %@: %s", shmPath, strerror(errno)]);
return 3;
}
struct stat st;
if (fstat(fd, &st) != 0) { close(fd); emit_error(@"fstat failed"); return 3; }
if ((uint64_t)st.st_size < ASB_P9_REGION_OFF + ASB_P9_REGION_SIZE) {
close(fd);
emit_error([NSString stringWithFormat:@"backing file too small: %lld bytes (need >= %llu)",
(long long)st.st_size,
(unsigned long long)(ASB_P9_REGION_OFF + ASB_P9_REGION_SIZE)]);
return 3;
}
void *bar = mmap(NULL, (size_t)st.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (bar == MAP_FAILED) { close(fd); emit_error(@"mmap failed"); return 3; }
asb_shm_publish((uint8_t *)bar, (uint64_t)st.st_size);
msync(bar, 4096, MS_SYNC);
munmap(bar, (size_t)st.st_size);
close(fd);
emit_log([NSString stringWithFormat:@"published %d regions to %@", ASB_SHM_N_REGIONS, shmPath]);
emit_done(shmPath);
return 0;
}
/* ======================================================================== *
* "build-windows" subcommand
*
* From-scratch Windows 11 ARM64 VM disk creation on macOS. Mirrors the Windows
* VHDX-first deploy path (generate_unattend_vhdx + generate_vhdx_setup_cmd +
* generate_vhdx_setupcomplete + generate_vhdx_manifest) but writes our OWN NTFS
* via win_disk_build() instead of DISM:
* 1. mount the Microsoft ISO read-only -> sources/install.wim
* 2. generate unattend.xml + setup.cmd + SetupComplete.cmd into a temp dir
* 3. build a "<host-src>\t<guest-dest>" manifest: answer file at
* \Windows\Panther, agent EXEs + test-signed drivers + devcon at
* \Windows\AppSandbox[\drivers], SetupComplete.cmd at \Windows\Setup\Scripts
* 4. win_disk_build() applies the WIM image then stages our files at those
* real guest paths (mkdir -p over the apply-built dir map)
* 5. unmount the ISO + remove the temp dir
*
* The SetupComplete.cmd differs from the Windows one in one way: it also installs
* the AppSandboxSHM PCI driver (pnputil), since the Mac transport is ivshmem rather
* than AF_HYPERV. testMode (Secure Boot off + test signing on) is mandatory: the
* guest drivers are test-signed. ======================================================================== */
/* Append "<src>\t<dest>\n" to the manifest if src exists on the host. */
static int bw_manifest_add(FILE *mf, NSString *src, const char *dest) {
if (!src || ![[NSFileManager defaultManager] fileExistsAtPath:src]) return 0;
fprintf(mf, "%s\t%s\n", src.UTF8String, dest);
return 1;
}
/* ---- ISO mount lifecycle ---- */
typedef struct { NSString *diskDev; NSString *mountPt; NSString *shadowPath; } IsoMount;
/* Attach the ISO and locate the mount point that actually contains sources/install.wim (a Windows ISO
* exposes both ISO9660 and UDF volumes).
*
* CONCURRENCY: each build attaches with its OWN unique copy-on-write SHADOW file, which gives it an
* INDEPENDENT device + mount point. hdiutil de-duplicates a plain re-attach of the same image to one
* shared device, so without the shadow two concurrent builds would share a device and detaching one
* would tear the ISO out from under the other. The shadow stays ~empty (we only READ install.wim) and
* is deleted on unmount. */
static BOOL bw_mount_iso(NSString *isoPath, IsoMount *out, NSString **errOut) {
out->diskDev = nil; out->mountPt = nil; out->shadowPath = nil;
NSString *shadow = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"asb-iso-%08x.shadow", arc4random()]];
NSString *sout = nil, *serr = nil;
int rc = run_tool(@"/usr/bin/hdiutil",
@[@"attach", @"-nobrowse", @"-shadow", shadow, @"-plist", isoPath],
&sout, &serr);
if (rc != 0) { if (errOut) *errOut = [NSString stringWithFormat:@"hdiutil attach failed (%d): %@", rc, serr]; return NO; }
out->shadowPath = shadow;
NSData *d = [sout dataUsingEncoding:NSUTF8StringEncoding];
id parsed = [NSPropertyListSerialization propertyListWithData:d options:0 format:NULL error:NULL];
NSArray *ents = [parsed isKindOfClass:[NSDictionary class]] ? parsed[@"system-entities"] : nil;
NSFileManager *fm = [NSFileManager defaultManager];
for (NSDictionary *e in ents) {
NSString *dev = e[@"dev-entry"];
/* the whole-image parent has no "s<N>" suffix: record it for detach */
if (dev && !out->diskDev &&
[dev rangeOfString:@"s" options:0 range:NSMakeRange(@"/dev/disk".length, dev.length-@"/dev/disk".length)].location == NSNotFound)
out->diskDev = dev;
NSString *mp = e[@"mount-point"];
if (mp.length) {
NSString *wim = [mp stringByAppendingPathComponent:@"sources/install.wim"];
if ([fm fileExistsAtPath:wim]) out->mountPt = mp;
}
}
/* Fallback for detach: first dev-entry. */
if (!out->diskDev && ents.count) out->diskDev = [ents firstObject][@"dev-entry"];
if (!out->mountPt) {
/* No install.wim found at any mount point; detach + fail. */
if (out->diskDev) (void)run_tool(@"/usr/bin/hdiutil", @[@"detach", @"-force", out->diskDev], NULL, NULL);
[[NSFileManager defaultManager] removeItemAtPath:shadow error:nil];
if (errOut) *errOut = @"ISO mounted but sources/install.wim not found";
return NO;
}
return YES;
}
static void bw_unmount_iso(IsoMount *m) {
if (m->diskDev) (void)run_tool(@"/usr/bin/hdiutil", @[@"detach", @"-force", m->diskDev], NULL, NULL);
if (m->shadowPath) [[NSFileManager defaultManager] removeItemAtPath:m->shadowPath error:nil]; /* COW shadow */
m->diskDev = nil; m->mountPt = nil; m->shadowPath = nil;
}
/* win_disk_build progress -> PROGRESS/STATUS lines. */
static void bw_progress(int pct, const char *msg) {
emit_progress(pct, msg ? [NSString stringWithUTF8String:msg] : @"");
}
static int cmd_build_windows(int argc, char **argv) {
NSString *iso = nil, *out = nil, *sshMsi = nil, *signedZip = nil, *netkvmZip = nil;
NSString *vmName = @"WIN11-VM", *user = @"user", *pass = @"test123";
NSString *provDir = nil; /* host-generated unattend.xml/setup.cmd/SetupComplete.cmd (password encoded host-side) */
NSString *lang = @""; /* empty => auto-detect from the install.wim XML (else en-US) */
int diskGb = 64, imageIdx = 1, testMode = 1; /* testMode optional (mirrors Windows disk builder) */
for (int i = 0; i < argc; i++) {
if (strcmp(argv[i], "--iso") == 0 && i+1 < argc) iso = @(argv[++i]);
else if (strcmp(argv[i], "--out") == 0 && i+1 < argc) out = @(argv[++i]);
else if (strcmp(argv[i], "--signed-payload-zip") == 0 && i+1 < argc) signedZip = @(argv[++i]);
else if (strcmp(argv[i], "--netkvm-zip") == 0 && i+1 < argc) netkvmZip = @(argv[++i]);
else if (strcmp(argv[i], "--ssh-msi") == 0 && i+1 < argc) sshMsi = @(argv[++i]);
else if (strcmp(argv[i], "--vm-name") == 0 && i+1 < argc) vmName = @(argv[++i]);
else if (strcmp(argv[i], "--user") == 0 && i+1 < argc) user = @(argv[++i]);
else if (strcmp(argv[i], "--pass") == 0 && i+1 < argc) pass = @(argv[++i]);
else if (strcmp(argv[i], "--prov-dir") == 0 && i+1 < argc) provDir = @(argv[++i]);
else if (strcmp(argv[i], "--lang") == 0 && i+1 < argc) lang = @(argv[++i]);
else if (strcmp(argv[i], "--disk-gb") == 0 && i+1 < argc) diskGb = atoi(argv[++i]);
else if (strcmp(argv[i], "--image-index") == 0 && i+1 < argc) imageIdx = atoi(argv[++i]);
else if (strcmp(argv[i], "--test-mode") == 0 && i+1 < argc) testMode = atoi(argv[++i]);
}
if (!iso || !out || !signedZip) {
emit_error(@"build-windows: --iso, --out and --signed-payload-zip are required");
return 2;
}
NSFileManager *fm = [NSFileManager defaultManager];
/* 1) temp staging dir for the generated answer file + scripts */
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"asb-winbuild-%d", getpid()]];
[fm removeItemAtPath:tmp error:nil];
NSError *mkErr = nil;
if (![fm createDirectoryAtPath:tmp withIntermediateDirectories:YES attributes:nil error:&mkErr]) {
emit_error([NSString stringWithFormat:@"mkdir staging: %@", mkErr.localizedDescription]); return 3;
}
/* The guest payload comes ONLY from the downloaded, signed release zip -- there is no bundled
* fallback. Extract the whole zip into this per-build temp (its own tmp, so concurrent creates
* never collide). A full extract (not a path-filtered subset) is required because the Windows-built
* zip stores entries with backslash separators that forward-slash unzip include-patterns never
* match (unzip would exit 11 and extract nothing); a full extract makes Info-ZIP rewrite the
* backslashes into real resources/ + drivers/ subdirs. Such zips make unzip exit 1 (warning), so
* the exit code is not a reliable signal -- the probe file decides. binDir holds the signed EXEs;
* drvDir holds the signed drivers (VDD/VAD/SHM) + devcon. */
NSString *signedDir = [tmp stringByAppendingPathComponent:@"signed"];
(void)run_tool(@"/usr/bin/unzip", @[@"-o", @"-q", signedZip, @"-d", signedDir], NULL, NULL);
NSString *binDir = [signedDir stringByAppendingPathComponent:@"resources"];
NSString *drvDir = [signedDir stringByAppendingPathComponent:@"drivers"];
NSString *devconPath = [drvDir stringByAppendingPathComponent:@"devcon.exe"];
if (![fm fileExistsAtPath:[binDir stringByAppendingPathComponent:@"appsandbox-agent.exe"]]) {
[fm removeItemAtPath:tmp error:nil];
emit_error(@"build-windows: signed payload zip did not yield resources/appsandbox-agent.exe");
return 3;
}
emit_log(@"using downloaded signed guest payload");
/* NetKVM (virtio-net) ships in its own small vendored zip (BSD-3-Clause), pulled + cached
* separately because it changes independently of the signed release. Best-effort: a VM without it
* still comes online over ivshmem (it just has no guest network). The zip is flat -- extract into
* netkvmDir and stage from there below. */
NSString *netkvmDir = nil;
if (netkvmZip.length && [fm fileExistsAtPath:netkvmZip]) {
netkvmDir = [tmp stringByAppendingPathComponent:@"netkvm"];
(void)run_tool(@"/usr/bin/unzip", @[@"-o", @"-q", netkvmZip, @"-d", netkvmDir], NULL, NULL);
if (![fm fileExistsAtPath:[netkvmDir stringByAppendingPathComponent:@"netkvm.sys"]]) {
emit_log(@"netkvm zip extract failed; VM will build without a network driver");
netkvmDir = nil;
}
} else {
emit_log(@"no netkvm zip provided; VM will build without a network driver");
}
/* Mount the ISO + locate install.wim FIRST so the OS language can be read from the
* WIM before the answer file is generated; the mount stays live for the build below. */
emit_progress(2, @"Mounting ISO");
IsoMount iso_m = {0};
NSString *err = nil;
if (!bw_mount_iso(iso, &iso_m, &err)) {
[fm removeItemAtPath:tmp error:nil];
emit_error(err); return 4;
}
NSString *wim = [iso_m.mountPt stringByAppendingPathComponent:@"sources/install.wim"];
emit_log([NSString stringWithFormat:@"install.wim: %@", wim]);
/* OS language: unless an explicit --lang was given (lang non-empty), auto-detect the
* install image's default UI language from the WIM XML (<IMAGE INDEX=n><WINDOWS>
* <LANGUAGES><DEFAULT>), reading the same index we apply. Falls back to en-US. The tag
* flows unchanged into the shared asb_provision_unattend() below. */
if (lang.length == 0) {
wim_t *lw = wim_open(wim.fileSystemRepresentation);
if (lw) {
char lbuf[32];
if (wim_image_default_language(lw, (uint32_t)imageIdx, lbuf, sizeof lbuf) == 0 &&
((lbuf[0] >= 'A' && lbuf[0] <= 'Z') || (lbuf[0] >= 'a' && lbuf[0] <= 'z'))) { /* BCP-47 starts with a letter */
lang = @(lbuf);
emit_lang(@(lbuf)); /* daemon surfaces this as "Detected ISO language: <tag>" in the create log */
}
wim_close(lw);
}
if (lang.length == 0) { lang = @"en-US"; emit_log(@"ISO language not detected; using en-US"); }
}
/* Provisioning files. In the daemon flow they are generated IN-PROCESS by the host (the password
* is encoded there via the shared asb_provision_unattend and never reaches this child) and handed
* over via --prov-dir -- the exact mirror of the Windows backend, where vhdx_create_thread
* generates unattend.xml/setup.cmd/SetupComplete.cmd and iso-patch.exe only consumes the --stage
* manifest. Direct CLI use (no --prov-dir) falls back to generating them here from
* --pass/--user/--vm-name/--lang. */
NSString *provBase = provDir.length ? provDir : tmp;
NSString *unattend = [provBase stringByAppendingPathComponent:@"unattend.xml"];
NSString *setupCmd = [provBase stringByAppendingPathComponent:@"setup.cmd"];
NSString *setupComplete = [provBase stringByAppendingPathComponent:@"SetupComplete.cmd"];
/* SSH MSI basename: staged to \Windows\AppSandbox\<basename> in the manifest below; SetupComplete
* (generated host-side in --prov-dir mode, or here otherwise) references it to install OpenSSH. */
NSString *sshMsiName = (sshMsi.length && [fm fileExistsAtPath:sshMsi]) ? sshMsi.lastPathComponent : nil;
if (provDir.length) {
/* Host generated them; the language is still auto-detected below and reported via LANG: so the
* host re-generates unattend.xml in place before we stage it. Just verify they exist. */
for (NSString *p in @[unattend, setupCmd, setupComplete]) {
if (![fm fileExistsAtPath:p]) {
bw_unmount_iso(&iso_m);
[fm removeItemAtPath:tmp error:nil];
emit_error([NSString stringWithFormat:@"prov-dir missing %@", p.lastPathComponent]);
return 3;
}
}
} else {
emit_progress(3, @"Generating answer file + setup scripts");
/* Generate the provisioning files via the SHARED generator (same source the Windows backend
* uses -- no mac/pc fork). testMode is the caller's choice (mirrors the Windows disk builder,
* which passes config.test_mode): when on it bakes `bcdedit /set testsigning on` so the
* test-signed guest drivers load. arm64. */
BOOL scriptsOK = NO;
{
FILE *fu = fopen(unattend.fileSystemRepresentation, "wb");
FILE *fs = fopen(setupCmd.fileSystemRepresentation, "wb");
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, NULL);
asb_provision_setup_cmd(fs);
asb_provision_setupcomplete(fc, sshMsiName.length ? sshMsiName.UTF8String : NULL);
scriptsOK = YES;
}
if (fu) fclose(fu);
if (fs) fclose(fs);
if (fc) fclose(fc);
}
if (!scriptsOK) {
bw_unmount_iso(&iso_m);
[fm removeItemAtPath:tmp error:nil];
emit_error(@"failed to write staging scripts"); return 3;
}
}
/* 2) build the manifest: "<host-src>\t<guest-dest>" (mirrors generate_vhdx_manifest) */
NSString *manifest = [tmp stringByAppendingPathComponent:@"manifest.tsv"];
FILE *mf = fopen(manifest.fileSystemRepresentation, "w");
if (!mf) { bw_unmount_iso(&iso_m); [fm removeItemAtPath:tmp error:nil]; emit_error(@"cannot open manifest"); return 3; }
int n = 0;
n += bw_manifest_add(mf, unattend, "\\Windows\\Panther\\unattend.xml");
n += bw_manifest_add(mf, setupCmd, "\\Windows\\AppSandbox\\setup.cmd");
n += bw_manifest_add(mf, setupComplete, "\\Windows\\Setup\\Scripts\\SetupComplete.cmd");
/* agent + channel helper EXEs (existence-gated -- mirrors generate_vhdx_manifest's bin set;
* appsandbox-displays.exe is staged when present in the payload). */
const char *bins[] = { "appsandbox-agent.exe", "appsandbox-input.exe", "appsandbox-displays.exe",
"appsandbox-clipboard.exe", "appsandbox-clipboard-reader.exe",
"appsandbox-audio.exe" };
for (size_t i = 0; i < sizeof(bins)/sizeof(bins[0]); i++) {
NSString *src = [binDir stringByAppendingPathComponent:@(bins[i])];
n += bw_manifest_add(mf, src, [NSString stringWithFormat:@"\\Windows\\AppSandbox\\%s", bins[i]].UTF8String);
}
/* Our signed drivers (VDD + VAD + ivshmem SHM) + their certs, from the signed payload. Existence-gated. */
const char *drv[] = { "AppSandboxVDD.inf", "AppSandboxVDD.dll", "AppSandboxVDD.cat", "AppSandboxVDD.cer",
"AppSandboxVAD.inf", "AppSandboxVAD.sys", "AppSandboxVAD.cat", "AppSandboxVAD.cer",
"AppSandboxSHM.inf", "AppSandboxSHM.sys", "AppSandboxSHM.cat", "AppSandboxSHM.cer" };
for (size_t i = 0; i < sizeof(drv)/sizeof(drv[0]); i++) {
NSString *src = [drvDir stringByAppendingPathComponent:@(drv[i])];
n += bw_manifest_add(mf, src, [NSString stringWithFormat:@"\\Windows\\AppSandbox\\drivers\\%s", drv[i]].UTF8String);
}
/* NetKVM (virtio-net) -- vendored from virtio-win (BSD-3-Clause; license staged alongside), from its
* own zip (netkvmDir). Mac payload only; never in the Windows-host manifest. */
if (netkvmDir) {
const char *nk[] = { "netkvm.inf", "netkvm.sys", "netkvm.cat", "netkvmp.exe", "virtio-win_license.txt" };
for (size_t i = 0; i < sizeof(nk)/sizeof(nk[0]); i++) {
NSString *src = [netkvmDir stringByAppendingPathComponent:@(nk[i])];
n += bw_manifest_add(mf, src, [NSString stringWithFormat:@"\\Windows\\AppSandbox\\drivers\\%s", nk[i]].UTF8String);
}
}
/* devcon.exe (ARM64) -- installs the root-enumerated VDD/VAD devnodes */
if (devconPath) n += bw_manifest_add(mf, devconPath, "\\Windows\\AppSandbox\\drivers\\devcon.exe");
/* OpenSSH MSI (downloaded at create) -- SetupComplete.cmd installs it for SSH-over-ivshmem ch7 */
if (sshMsiName)
n += bw_manifest_add(mf, sshMsi, [NSString stringWithFormat:@"\\Windows\\AppSandbox\\%@", sshMsiName].UTF8String);
fclose(mf);
emit_log([NSString stringWithFormat:@"manifest: %d file(s) to stage", n]);
/* build the disk (apply WIM image -> NTFS -> stage our files -> ESP + BCD); the ISO is
* already mounted + install.wim located above (for language detection). Use LOG (not
* STATUS) so the apply monitor's percentage isn't reset to the indeterminate sentinel. */
emit_log(@"Building Windows disk");
int rc = win_disk_build(wim.fileSystemRepresentation,
out.fileSystemRepresentation,
(uint32_t)imageIdx,
(uint64_t)(diskGb > 0 ? diskGb : 64),
manifest.fileSystemRepresentation,
bw_progress);
/* 5) unmount + cleanup */
bw_unmount_iso(&iso_m);
[fm removeItemAtPath:tmp error:nil];
if (rc != 0) { emit_error([NSString stringWithFormat:@"win_disk_build failed (%d)", rc]); return 5; }
emit_progress(100, @"Disk complete");
emit_done(out);
return 0;
}
/* ---- Entry ---- */
static void print_usage(void) {
fprintf(stderr,
"Usage: iso-patch-mac <subcommand> [args]\n"
"\n"
"Subcommands:\n"
" fetch-ipsw --output <path>\n"
" Download the latest supported macOS restore image.\n"
"\n"
" install --name <vm> --vm-dir <dir> --ipsw <path>\n"
" --ram-mb <n> --cpus <n> --disk-gb <n>\n"
" Create disk image + aux storage + hardware model +\n"
" machine identifier, then run VZMacOSInstaller.\n"
"\n"
" stage --disk <path> --manifest <path>\n"
" Mount the VM disk image, apply TSV manifest, unmount.\n"
" Must run as root (invoked via AEWP). Account credential: --shadowhash-file +\n"
" --kcpassword-file (host-precomputed; no plaintext) or --user-password-file (computed here).\n"
"\n"
" publish-shm --shm <path>\n"
" Write the asb_transport directory into the ivshmem backing file\n"
" before QEMU boots (launcher owns it; guest services then select ivshmem).\n"
"\n"
" build-windows --iso <path> --out <disk.img> --signed-payload-zip <zip> [--netkvm-zip <zip>]\n"
" [--devcon <devcon.exe>] [--ssh-msi <OpenSSH .msi>] [--vm-name <n>]\n"
" [--user <u>] [--pass <p>] [--prov-dir <dir>] [--lang <bcp47>] [--disk-gb <n>] [--image-index <n>]\n"
" --prov-dir supplies host-generated unattend.xml/setup.cmd/SetupComplete.cmd (password\n"
" encoded host-side); without it they are generated here from --user/--pass/--vm-name.\n"
" From-scratch Windows VM disk: mount the ISO, apply install.wim with our\n"
" own NTFS writer, and stage the answer file + agent + test-signed drivers.\n"
"\n");
}
int main(int argc, char *argv[]) {
@autoreleasepool {
if (argc < 2) { print_usage(); return 64; }
const char *cmd = argv[1];
if (strcmp(cmd, "stage") == 0) {
return cmd_stage(argc - 2, argv + 2);
}
if (strcmp(cmd, "fetch-ipsw") == 0) {
return cmd_fetch_ipsw(argc - 2, argv + 2);
}
if (strcmp(cmd, "install") == 0) {
return cmd_install(argc - 2, argv + 2);
}
if (strcmp(cmd, "publish-shm") == 0) {
return cmd_publish_shm(argc - 2, argv + 2);
}
if (strcmp(cmd, "build-windows") == 0) {
return cmd_build_windows(argc - 2, argv + 2);
}
if (strcmp(cmd, "--help") == 0 || strcmp(cmd, "-h") == 0) {
print_usage();
return 0;
}
fprintf(stderr, "unknown subcommand: %s\n", cmd);
print_usage();
return 64;
}
}