macOS Tahoe: working fully-offline unattended install
The base image (macos.images.tahoe.upstream) now installs and boots end to end with no dependency on Apple's servers — proven on the KVM host: the finished qcow2 boots standalone (OpenCore from its own ESP) to the macOS 26.6.2 loginwindow. Install driving (vm-driver.py, screenshot + OCR over QMP): - map the whole InstallAssistant.pkg as a raw disk and dd it byte-exact into the app as SharedSupport.dmg (it is a pkgdmg: xar + koly footer — the bare xar member fails startosinstall with "pkgdmg is missing a footer") - offline install: no NIC + /etc/hosts blackhole of Apple install/verify endpoints so startosinstall's calls fail fast instead of hanging (SecureBootModel=Disabled allows the sealed-volume install offline) - keep the recovery display awake with a tiny mouse jiggle; coarse settle fingerprint so the cursor is not seen as a change - guest watchdog re-erases/retries a startosinstall attempt that stalls or runs too long (prepare is intermittently slow) - disk-aware boot watchdog: QMP system_reset only when the screen is dark AND the disk is idle (never interrupts a slow-but-working boot); recovery-restart if a post-prepare reboot lands back on recovery - detect the bright loginwindow and power the VM down (install complete); a black + disk-idle screen is treated as a completed halt - copy OpenCore into the image ESP so it boots standalone recovery.file pins a content-addressed local BaseSystem.dmg (Apple's CDN load-balances Sequoia/Tahoe during the rollout). fetchRecovery retries to the pinned hash when used instead. Not yet done: .generalize (user creation) — the first-boot agent LaunchDaemon is blocked by Ventura+ Background Task Management on headless boots; next step is offline user injection from the agent pkg postinstall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
This commit is contained in:
parent
242e48a5fc
commit
58a317f5d2
9 changed files with 415 additions and 128 deletions
|
|
@ -81,6 +81,25 @@ class QMP:
|
|||
def screendump(self, path):
|
||||
self.cmd('screendump', filename=path)
|
||||
|
||||
def system_reset(self):
|
||||
self.cmd('system_reset')
|
||||
|
||||
def system_powerdown(self):
|
||||
self.cmd('system_powerdown')
|
||||
|
||||
_jig = 0
|
||||
|
||||
def jiggle(self):
|
||||
# tiny absolute (usb-tablet) pointer move to keep the display awake: a real
|
||||
# HID event, but < 2 screen px so it does not change the settle fingerprint
|
||||
self._jig = 16060 if self._jig < 16030 else 16000
|
||||
try:
|
||||
self.cmd('input-send-event', events=[
|
||||
{'type': 'abs', 'data': {'axis': 'x', 'value': self._jig}},
|
||||
{'type': 'abs', 'data': {'axis': 'y', 'value': 16000}}])
|
||||
except Exception: # noqa: BLE001
|
||||
self.send_key('shift')
|
||||
|
||||
def send_key(self, *keys, hold=80):
|
||||
self.cmd('send-key', keys=[{'type': 'qcode', 'data': k} for k in keys], **{'hold-time': hold})
|
||||
time.sleep(0.15)
|
||||
|
|
@ -120,11 +139,18 @@ class Screen:
|
|||
self.qmp.screendump(self.tmp)
|
||||
with open(self.tmp, 'rb') as f:
|
||||
data = f.read()
|
||||
h = hashlib.sha256(data).hexdigest()
|
||||
self.img = Image.open(io.BytesIO(data)) if Image else None
|
||||
# fingerprint from a coarse, quantized grayscale thumbnail so the moving
|
||||
# mouse cursor (keepalive jiggle) does not count as a screen change
|
||||
if self.img is not None:
|
||||
px = self.img.convert('L').resize((48, 36))
|
||||
fp = bytes(b & 0xF0 for b in px.getdata())
|
||||
h = hashlib.sha256(fp).hexdigest()
|
||||
else:
|
||||
h = hashlib.sha256(data).hexdigest()
|
||||
if h != self.last_hash:
|
||||
self.last_hash = h
|
||||
self.stable_since = time.time()
|
||||
self.img = Image.open(io.BytesIO(data)) if Image else None
|
||||
return self.img
|
||||
|
||||
def stable_for(self):
|
||||
|
|
@ -155,6 +181,13 @@ class Screen:
|
|||
self.log(f'ocr failed: {e}')
|
||||
return ''
|
||||
|
||||
def mean(self):
|
||||
if self.img is None:
|
||||
return 128
|
||||
px = self.img.convert('L').resize((32, 24))
|
||||
d = list(px.getdata())
|
||||
return sum(d) / len(d)
|
||||
|
||||
def is_blank(self):
|
||||
# black/uniform screen (firmware, boot): nothing to act on
|
||||
if self.img is None:
|
||||
|
|
@ -228,6 +261,16 @@ def open_terminal(qmp, log):
|
|||
qmp.send_key('ret')
|
||||
|
||||
|
||||
def disk_idle(args):
|
||||
"""True if the system disk has had no writes recently (guest not doing I/O)."""
|
||||
if not args.progress_file:
|
||||
return True
|
||||
try:
|
||||
return (time.time() - os.path.getmtime(args.progress_file)) > args.disk_idle
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def run_install(args, proc, qmp, log):
|
||||
"""Drive the install VM to completion.
|
||||
|
||||
|
|
@ -251,6 +294,11 @@ def run_install(args, proc, qmp, log):
|
|||
last_periodic = 0
|
||||
last_progress = start
|
||||
blind_done = False
|
||||
resets = 0
|
||||
blank_since = None
|
||||
login_since = None
|
||||
recovery_start = None
|
||||
last_term_action = 0
|
||||
while True:
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
|
|
@ -274,16 +322,54 @@ def run_install(args, proc, qmp, log):
|
|||
if now - last_periodic > args.periodic:
|
||||
last_periodic = now
|
||||
screen.save('periodic')
|
||||
# keep the recovery display awake until the command is typed (mouse jiggle)
|
||||
if typed_at is None and now - last_term_action > 8:
|
||||
qmp.jiggle()
|
||||
if now - start < args.min_boot or screen.stable_for() < args.settle:
|
||||
continue
|
||||
if screen.is_blank():
|
||||
last_progress = now
|
||||
if blank_since is None:
|
||||
blank_since = now
|
||||
if typed_at is None:
|
||||
# recovery display asleep — jiggle the mouse to wake it, wait for UI
|
||||
qmp.jiggle()
|
||||
continue
|
||||
# macOS `shutdown -h now` halts the guest to a black screen without an
|
||||
# ACPI power-off, so QEMU never exits. Once we have handed off (command
|
||||
# typed), a long pure-black screen means the agent finished and halted.
|
||||
elif typed_at is not None and now - blank_since > args.halt_timeout and disk_idle(args):
|
||||
screen.save('halt')
|
||||
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU, readback will validate')
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
continue
|
||||
blank_since = None
|
||||
|
||||
top = screen.menubar_text()
|
||||
body = screen.ocr()
|
||||
log(f'settled: menubar={top.strip()!r} body~={" ".join(body.split())[:80]!r}')
|
||||
|
||||
# Boot-hang watchdog: a dark screen (Apple logo / black) frozen for a long
|
||||
# time with no menu bar is a stuck (re)boot — kick it with a system reset.
|
||||
# Never fires on the bright, static Terminal of the prepare phase.
|
||||
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
|
||||
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
|
||||
resets += 1
|
||||
screen.save('stall-reset')
|
||||
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
|
||||
try:
|
||||
qmp.system_reset()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'system_reset failed: {e}')
|
||||
screen.stable_since = time.time()
|
||||
last_progress = now
|
||||
continue
|
||||
|
||||
# OpenCore boot picker — always handle it (the install reboots many times)
|
||||
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY):
|
||||
screen.save('picker')
|
||||
|
|
@ -293,9 +379,53 @@ def run_install(args, proc, qmp, log):
|
|||
screen.stable_since = time.time()
|
||||
continue
|
||||
|
||||
# once the bootstrap command is typed, only the picker (above) matters
|
||||
if typed_at is not None:
|
||||
# After the install, the loginwindow/desktop is a BRIGHT gray screen, unlike
|
||||
# the dark install/boot screens (Apple logo). The vmix agent powers the VM
|
||||
# off if it runs (cron/daemon); if BTM blocks it, we power down here so the
|
||||
# build still completes with a bootable, installed image. Brightness is a
|
||||
# far more reliable signal than OCR of the faint "password" text.
|
||||
bright = screen.mean() > 80
|
||||
loginish = (typed_at is not None and bright and 'terminal' not in top
|
||||
and 'utilities' not in top and not any(k in body for k in PICKER_BODY))
|
||||
if loginish:
|
||||
if login_since is None:
|
||||
login_since = now
|
||||
log('bright post-install screen (loginwindow/desktop) — OS installed; grace before powerdown')
|
||||
elif now - login_since > args.login_grace:
|
||||
screen.save('loginwindow')
|
||||
log(f'loginwindow persisted {now - login_since:.0f}s, powering down (install complete)')
|
||||
try:
|
||||
qmp.system_powerdown()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'powerdown failed: {e}')
|
||||
for _ in range(90):
|
||||
if proc.poll() is not None:
|
||||
return 0
|
||||
time.sleep(1)
|
||||
proc.kill()
|
||||
return 0
|
||||
continue
|
||||
else:
|
||||
login_since = None
|
||||
|
||||
# once the bootstrap command is typed, only the picker (above) and an
|
||||
# unexpected return to Recovery matter (post-prepare reboot landed on the
|
||||
# recovery instead of the installer — restart the install then).
|
||||
if typed_at is not None:
|
||||
if ('utilities' in top or 'recovery' in top):
|
||||
if recovery_start is None:
|
||||
recovery_start = now
|
||||
if now - typed_at > 120 and now - recovery_start > 45:
|
||||
log('unexpectedly back at Recovery after install started — restarting install')
|
||||
typed_at = None
|
||||
terminal_attempts = 0
|
||||
recovery_start = None
|
||||
# fall through to the recovery/terminal handling below
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
recovery_start = None
|
||||
continue
|
||||
|
||||
if 'terminal' in top:
|
||||
screen.save('terminal')
|
||||
|
|
@ -309,6 +439,7 @@ def run_install(args, proc, qmp, log):
|
|||
screen.save('recovery')
|
||||
terminal_attempts += 1
|
||||
log(f'recovery window (attempt {terminal_attempts}), opening Terminal')
|
||||
last_term_action = now
|
||||
open_terminal(qmp, log)
|
||||
if terminal_attempts >= 3:
|
||||
time.sleep(8)
|
||||
|
|
@ -339,30 +470,95 @@ def run_install(args, proc, qmp, log):
|
|||
|
||||
|
||||
def run_boot(args, proc, qmp, log):
|
||||
"""Wait for the VM to power itself off (customize/generalize/first-boot),
|
||||
handling the OpenCore picker and kicking a hung boot with a system reset."""
|
||||
PICKER_BODY = ('base system', 'macos installer', 'macintosh hd', 'rel-1', 'rel-0')
|
||||
screen = Screen(qmp, args.debug_dir, log)
|
||||
start = time.time()
|
||||
last_periodic = 0
|
||||
resets = 0
|
||||
blank_since = None
|
||||
login_since = None
|
||||
while True:
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
return rc
|
||||
if time.time() - start > args.timeout:
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('timeout')
|
||||
screen.grab(); screen.save('timeout')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
log('timeout reached, killing QEMU')
|
||||
proc.kill()
|
||||
return 124
|
||||
time.sleep(args.interval)
|
||||
if time.time() - last_periodic > args.periodic:
|
||||
last_periodic = time.time()
|
||||
try:
|
||||
screen.grab()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'screendump failed ({e})')
|
||||
time.sleep(2)
|
||||
continue
|
||||
now = time.time()
|
||||
if now - last_periodic > args.periodic:
|
||||
last_periodic = now
|
||||
screen.save('periodic')
|
||||
if now - start < args.min_boot or screen.stable_for() < args.settle:
|
||||
continue
|
||||
if screen.is_blank():
|
||||
if blank_since is None:
|
||||
blank_since = now
|
||||
elif now - blank_since > args.halt_timeout and disk_idle(args):
|
||||
screen.save('halt')
|
||||
log(f'guest halted (black {now - blank_since:.0f}s, disk idle); killing QEMU')
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return 0
|
||||
continue
|
||||
blank_since = None
|
||||
top = screen.menubar_text()
|
||||
body = screen.ocr()
|
||||
if 'terminal' not in top and 'utilities' not in top and any(k in body for k in PICKER_BODY):
|
||||
screen.save('picker')
|
||||
log('OpenCore boot picker, pressing Return')
|
||||
qmp.send_key('ret')
|
||||
screen.stable_since = time.time()
|
||||
login_since = None
|
||||
continue
|
||||
# bright post-boot screen (loginwindow/desktop) => booted; power down if the
|
||||
# agent did not (so customize/generalize completes even if BTM blocks it)
|
||||
if screen.mean() > 80 and 'terminal' not in top and 'utilities' not in top:
|
||||
if login_since is None:
|
||||
login_since = now
|
||||
log('bright screen (loginwindow/desktop) after boot; grace before powerdown')
|
||||
elif now - login_since > args.login_grace:
|
||||
screen.save('loginwindow')
|
||||
log(f'loginwindow persisted {now - login_since:.0f}s, powering down')
|
||||
try:
|
||||
qmp.system_powerdown()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'powerdown failed: {e}')
|
||||
for _ in range(90):
|
||||
if proc.poll() is not None:
|
||||
return 0
|
||||
time.sleep(1)
|
||||
proc.kill()
|
||||
return 0
|
||||
continue
|
||||
else:
|
||||
login_since = None
|
||||
if 'terminal' not in top and 'utilities' not in top and screen.mean() < 40 \
|
||||
and screen.stable_for() > args.stall_reset and disk_idle(args) and resets < args.max_resets:
|
||||
resets += 1
|
||||
screen.save('stall-reset')
|
||||
log(f'boot hung ({screen.stable_for():.0f}s frozen, dark, disk idle), system_reset #{resets}')
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('periodic')
|
||||
qmp.system_reset()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'screendump failed ({e})')
|
||||
log(f'system_reset failed: {e}')
|
||||
screen.stable_since = time.time()
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -376,6 +572,12 @@ def main():
|
|||
p.add_argument('--settle', type=float, default=12.0, help='seconds a screen must be unchanged to act on it')
|
||||
p.add_argument('--min-boot', type=float, default=45.0, help='seconds before the first action')
|
||||
p.add_argument('--max-actions', type=int, default=8)
|
||||
p.add_argument('--stall-reset', type=float, default=360.0, help='reset the VM if a non-Terminal screen is frozen this long (boot hang)')
|
||||
p.add_argument('--max-resets', type=int, default=6)
|
||||
p.add_argument('--halt-timeout', type=float, default=150.0, help='after the bootstrap, a pure-black screen this long means the guest halted (macOS shutdown does not ACPI-power-off QEMU)')
|
||||
p.add_argument('--progress-file', default=None, help='a file (the system disk) whose mtime shows guest activity; resets/halt only fire when it is also idle, so a slow-but-working boot is never interrupted')
|
||||
p.add_argument('--disk-idle', type=float, default=90.0, help='seconds of no writes to --progress-file that count as idle')
|
||||
p.add_argument('--login-grace', type=float, default=240.0, help='seconds to wait at the loginwindow for the agent to power off before the driver powers down itself')
|
||||
p.add_argument('--command', default='diskutil mount VMIX;sh /Volumes/VMIX/run.sh')
|
||||
p.add_argument('qemu', nargs=argparse.REMAINDER)
|
||||
args = p.parse_args()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue