diff --git a/AGENTS.md b/AGENTS.md index f6187c7..277f06f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,8 +50,10 @@ Local source edits, pure tests, read-only inspection, and disposable artifacts are reversible. Obtain the owner's explicit authorization immediately before helper registration, privileged execution, disk or boot-policy mutation, signing or notarization with production credentials, publication, deployment, -or physical-device work. Keep `apple,j614s` fail-closed until official support -and physical qualification both exist. +or physical-device work. Public builds keep unqualified boards such as +`apple,j614s` fail-closed until official support and physical qualification +both exist. Developer builds (`OmarchyDeveloperBuild`, sealed developer +catalog only) may admit unqualified boards their engine supports. ## Standalone repository conventions diff --git a/Engine/overlay/src/omarchy_asahi.py b/Engine/overlay/src/omarchy_asahi.py index 84fa925..1aa2f75 100644 --- a/Engine/overlay/src/omarchy_asahi.py +++ b/Engine/overlay/src/omarchy_asahi.py @@ -1,22 +1,28 @@ # SPDX-License-Identifier: MIT """Concrete stage-1 adapter over pinned upstream Asahi primitives.""" +import contextlib import hashlib import io import json +import logging import os import re import stat import shutil +import struct import subprocess import sys import zipfile +import zlib from pathlib import PurePosixPath import asahi_firmware import osinstall import stub +import omarchy_mesa +import omarchy_mt7932 import omarchy_planner from omarchy_image import ( WRITE_VERIFICATION, flush_device, hash_target, open_target, timing, write_image, @@ -30,6 +36,14 @@ VOLUME_GROUP_PATTERN = re.compile(r"^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$") PARTITION_PATTERN = re.compile(r"^disk[0-9]+s[0-9]+$") READBACK_CHUNK_BYTES = 1024 * 1024 +# Where every macOS through 14 named its restore bundle in bootcaches.plist, +# and where macOS 26 still keeps it in Preboot, though its bless2 no longer +# names it. +RESTORE_BUNDLE_PATH = "./Restore" +# From macOS 15 the recovery image in a restore image is an Apple Encrypted +# Archive, which hdiutil cannot attach without Apple's key. +AEA_MAGIC = b"AEA1" +HOST_ROOT = "/" class AsahiAdapterError(RuntimeError): @@ -371,22 +385,274 @@ def _write_step2(installer): with open(installer.step2_sh, "w") as fd: fd.write(script) os.chmod(installer.step2_sh, 0o755) +# Aurora's J700 Stage 1 carries one versioned config block that names the ESP +# and the Stage 2 path; it is filled in place so the image keeps its length +# and STACKBOT tail (port of aurora-silicon/m1n1 tools/fill_stage1_config.py, +# MIT). +J700_STAGE1 = "esp/aurora/stage1-j700.bin" +J700_STAGE1_MAGIC = b"AURORA-S1-CFG01\0" +J700_STAGE1_BODY = struct.Struct("= 192: + raise AsahiAdapterError("Stage 2 path is too long") + if not 0 <= window_ms <= 99999: + raise AsahiAdapterError("proxy window must be 0..99999 ms") + if not image.endswith(b"STACKBOT"): + raise AsahiAdapterError("J700 Stage 1 does not end at STACKBOT") + if image.count(J700_STAGE1_MAGIC) != 1: + raise AsahiAdapterError("J700 Stage 1 config block must occur exactly once") + offset = image.index(J700_STAGE1_MAGIC) + if offset + J700_STAGE1_BLOCK > len(image) - 8: + raise AsahiAdapterError("J700 Stage 1 config block extends past the image") + if struct.unpack_from("= 512: + fd.seek(-512, os.SEEK_END) + trailer = fd.read(4) + if trailer != b"koly": + raise AsahiAdapterError( + "the running macOS's recovery image is not a decrypted disk image" + ) + copies = [(host_image, stub_image)] + bless2 = installer.bootcaches["bless2"] + restore = os.path.join( + installer.pb_vgid, bless2.get("RestoreBundlePath", RESTORE_BUNDLE_PATH) + ) + host_restore = os.path.join(cur_os.preboot, cur_os.vgid, "restore") + for name in sorted(os.listdir(restore)): + path = os.path.join(restore, name) + if not os.path.isfile(path) or not _is_encrypted(path): + continue + source = os.path.join(host_restore, name) + if not os.path.isfile(source) or _is_encrypted(source): + raise AsahiAdapterError( + f"the running macOS has no decrypted {name} for the stub" + ) + copies.append((source, path)) + for source, target in copies: + # Removed first: the stub container has room for one copy at a time. + os.unlink(target) + shutil.copyfile(source, target) + + class AsahiInPlaceRepairAdapter: """Non-partitioning adapter for one manifest-bound installed system.""" @@ -850,6 +1116,13 @@ def install_stub_and_esp(self, plan): self.osins.firmware_package = firmware_package self.osins.install(self.installer.ins) + install_board_stage1( + self.installer.sysinfo.device_class, + self.osins.pkg.read, + self.osins.efi_part.uuid, + self.installer.ins.boot_obj_path, + self.template.get("next_object", "m1n1/boot.bin"), + ) for target in self.osins.idata_targets: self.installer.ins.collect_installer_data(target) shutil.copy( diff --git a/Engine/overlay/src/omarchy_mesa.py b/Engine/overlay/src/omarchy_mesa.py new file mode 100644 index 0000000..fd9427d --- /dev/null +++ b/Engine/overlay/src/omarchy_mesa.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: MIT +"""A MacBook Neo's Touch ID (Mesa) calibration, read from macOS. + +Older Macs keep the sensor calibration in a comb/fdrd/secb record that +aurora-touchid's extractor reads on Linux. Newer ones, such as the MacBook +Neo, store it in the iBoot System Container as one standalone signed IMG4 +whose IM4P type is FSC2. The apple_sep driver loads that IMG4 as the +firmware its device tree names, apple/mesacal-j700.bin on the Neo. +""" + +import os + +FIRMWARE_NAME = "apple/mesacal-j700.bin" +# The iBoot System Container is about 550 MB; refuse anything far larger. +CONTAINER_MAXIMUM = 1 << 30 + + +class MesaError(Exception): + pass + + +def _tlv(data, at): + tag, length, header = data[at], data[at + 1], 2 + if length >= 0x80: + count = length & 0x7F + if not 1 <= count <= 4: + raise ValueError("bad DER length") + length = int.from_bytes(data[at + 2 : at + 2 + count], "big") + header = 2 + count + end = at + header + length + if end > len(data): + raise ValueError("DER runs past the input") + return tag, at + header, end + + +def _children(data, start, end): + while start < end: + child = _tlv(data, start) + yield child + start = child[2] + + +def _ia5(data, child): + tag, start, end = child + return bytes(data[start:end]) if tag == 0x16 else None + + +def fsc2_calibrations(data): + """Every distinct signed FSC2 IMG4 (IM4P payload holding CALB, IM4M + manifest) in data, as bytes.""" + found = set() + at = data.find(b"\x16\x04IMG4") + while at >= 0: + for header in range(2, 7): + start = at - header + if start < 0 or data[start] != 0x30: + continue + try: + _, content, end = _tlv(data, start) + if content != at: + continue + parts = list(_children(data, content, end)) + im4p = list(_children(data, parts[1][1], parts[1][2])) + if ( + _ia5(data, parts[0]) == b"IMG4" + and _ia5(data, im4p[0]) == b"IM4P" + and _ia5(data, im4p[1]) == b"FSC2" + and im4p[3][0] == 0x04 + and b"CALB" in data[im4p[3][1] : im4p[3][1] + 256] + and len(parts) >= 3 + and parts[2][0] == 0xA0 + and b"IM4M" in data[parts[2][1] : parts[2][2]] + ): + found.add(bytes(data[start:end])) + except (ValueError, IndexError): + continue + at = data.find(b"\x16\x04IMG4", at + 1) + return found + + +def calibration(container): + """The one signed FSC2 calibration in an iBoot System Container image.""" + found = fsc2_calibrations(container) + if len(found) != 1: + raise MesaError(f"expected one signed FSC2 calibration, found {len(found)}") + return found.pop() + + +def collect(device): + """{firmware path: bytes} for this Mac's Touch ID, read from the raw + iBoot System Container device, never written.""" + fd = os.open(device, os.O_RDONLY) + try: + size = os.lseek(fd, 0, os.SEEK_END) + if not 0 < size <= CONTAINER_MAXIMUM: + raise MesaError(f"{device} is {size} bytes, not an iBoot System Container") + os.lseek(fd, 0, os.SEEK_SET) + container = bytearray() + while len(container) < size: + block = os.read(fd, min(8 << 20, size - len(container))) + if not block: + break + container += block + finally: + os.close(fd) + return {FIRMWARE_NAME: calibration(container)} diff --git a/Engine/overlay/src/omarchy_mt7932.py b/Engine/overlay/src/omarchy_mt7932.py new file mode 100644 index 0000000..ebcf093 --- /dev/null +++ b/Engine/overlay/src/omarchy_mt7932.py @@ -0,0 +1,283 @@ +"""Collect the MacBook Neo's MediaTek MT7932 radio inputs from macOS. + +aurora-silicon/linux's mt7932-fullmac (Wi-Fi) and mt7932_bt_pcie (Bluetooth) +drivers, merged into aurora-wip by 3bb0a6104a11, load these from +/lib/firmware; Documentation/networking/device_drivers/wifi/mt7932-neo.rst +lists them. Each comes from this Mac's own macOS: + +- the Wi-Fi firmware and ppr.bin: the AppleSunriseWLAN driver extension; +- oca2.bin, wcal.bin and the Bluetooth calibration: the OCA2, WCAL and BCAL + fields of the factory BWC2 record, a signed IMG4 unique to this Mac; +- config-original.bin (J7CF): the driver extension's IZUBA_wifi.cfg; +- the Bluetooth firmware and PTX: /usr/share/firmware/bluetooth; +- the Bluetooth address: the device tree's /chosen. + +The country policy (policy/world-XZ.bin, J7RP) is not derived here yet, so +Wi-Fi does not start without it; Bluetooth does not need it. + +Every output is checked the way the drivers check it before it is used. +""" + +import glob +import os +import plistlib +import struct +import subprocess + +IZUBA = "System/Library/DriverExtensions/com.apple.AppleSunriseWLAN.dext/IZUBA" +FACTORY_DATA = "System/Volumes/Hardware/FactoryData/System/Library/Caches/com.apple.factorydata" +BLUETOOTH = "usr/share/firmware/bluetooth" + +WIFI_PATCH = "IZUBA_WIFI_MT7932_patch_mcu_1_2_hdr.bin" +WIFI_RAM = "IZUBA_W7932_2.bin" +# The driver loads B0 or B1 by the chip's ROM; for B1 it prefers a newer +# build that macOS 26.6 does not ship, then this one. +BT_FIRMWARE = ( + "MT7932B0_OS_TypeB_0.1.44.0_241001003711.bin", + "MT7932B1_OS_TypeB_0.1.133.0_260128190103.bin", +) +BT_TRAILER = b"ALPS\x8a\x10\x8a\x10" +BT_PTX = "MT7932_PTB_IzubaA_0.1.0.0_20251021141303.ptx" + +PPR_BYTES = 412 +WCAL_MAXIMUM = 1024 +BTCAL_MAXIMUM = 0xFFFF +J7CF_RECORD = struct.Struct(" len(data): + raise Mt7932Error("truncated DER element") + tag, cursor = data[at], at + 1 + if tag & 0x1F == 0x1F: + while cursor < len(data) and data[cursor] & 0x80: + cursor += 1 + cursor += 1 + if cursor >= len(data): + raise Mt7932Error("truncated DER tag") + length = data[cursor] + cursor += 1 + if length & 0x80: + count = length & 0x7F + if not 1 <= count <= 4 or cursor + count > len(data): + raise Mt7932Error("bad DER length") + length = int.from_bytes(data[cursor : cursor + count], "big") + cursor += count + header = cursor - at + end = at + header + length + if end > len(data): + raise Mt7932Error("DER element runs past its container") + return tag, at + header, end + + +def _children(data, start, end): + while start < end: + child = _tlv(data, start) + yield child + start = child[2] + + +def bwc2_fields(image): + """The tagged fields of a BWC2 IMG4: {"OCA2": bytes, "WCAL": bytes, ...}. + + The IM4P payload is DER. Each field is a SEQUENCE of a four-byte INTEGER + holding its tag in little-endian order, an IA5String and an OCTET STRING + holding the field itself. + """ + _, start, end = _tlv(image, 0) + parts = list(_children(image, start, end)) + if not parts or image[parts[0][1] : parts[0][2]] != b"IMG4": + raise Mt7932Error("BWC2 is not an IMG4") + im4p = list(_children(image, parts[1][1], parts[1][2])) + if [image[s:e] for _, s, e in im4p[:2]] != [b"IM4P", b"BWC2"]: + raise Mt7932Error("BWC2's IM4P is not of type BWC2") + payload = im4p[3] + if payload[0] != 0x04: + raise Mt7932Error("BWC2 has no payload") + fields = {} + + def walk(start, end): + for tag, cstart, cend in _children(image, start, end): + if not tag & 0x20: + continue + inner = list(_children(image, cstart, cend)) + if ( + tag == 0x30 + and len(inner) == 3 + and inner[0][0] == 0x02 + and inner[0][2] - inner[0][1] == 4 + and inner[1][0] == 0x16 + and inner[2][0] == 0x04 + ): + name = image[inner[0][1] : inner[0][2]][::-1].decode("ascii", "replace") + if name in fields: + raise Mt7932Error(f"BWC2 holds {name} twice") + fields[name] = bytes(image[inner[2][1] : inner[2][2]]) + else: + walk(cstart, cend) + + walk(payload[1], payload[2]) + return fields + + +def validate_oca2(data): + """mt7932_cal_validate() from the driver: a whole big-endian BLOB.""" + size = len(data) + if size < 16 or size > 16 << 20 or data[:4] != b"BLOB": + raise Mt7932Error("oca2 is not a BLOB") + end, header, count = struct.unpack_from(">IHH", data, 4) + if header != 12 or not count or end != 16 + 20 * count or end > size: + raise Mt7932Error("oca2's BLOB header is inconsistent") + spans = [] + for index in range(count): + entry = data[16 + 20 * index : 36 + 20 * index] + kind, entry_header, offset, length, checksum = struct.unpack_from(">HHIII", entry) + if entry_header != 12 or offset < end or offset > size or length < 12 or length > size - offset: + raise Mt7932Error(f"oca2 entry {index} is out of bounds") + if data[offset : offset + 4] != entry[:4] or struct.unpack_from(">I", data, offset + 4)[0] != length: + raise Mt7932Error(f"oca2 entry {index} does not match its record") + if sum(data[offset : offset + length]) & 0xFFFFFFFF != checksum: + raise Mt7932Error(f"oca2 entry {index} has a bad checksum") + for prior_kind, start, bytes_ in spans: + if prior_kind == kind or (offset < start + bytes_ and start < offset + length): + raise Mt7932Error(f"oca2 entry {index} repeats or overlaps another") + spans.append((kind, offset, length)) + + +def validate_bluetooth_firmware(data): + """bt7932_read_inputs(): a 32-byte trailer carrying the ALPS marker.""" + if len(data) <= 32 or data[-16:-8] != BT_TRAILER: + raise Mt7932Error("the Bluetooth firmware has no ALPS trailer") + + +def validate_ptx(data): + """bt7932_validate_ptx() from the Bluetooth driver.""" + if len(data) != 198 or data[:4] != b"BLOB": + raise Mt7932Error("the PTX is not a 198-byte BLOB") + first, version, count, reserved = struct.unpack_from("= 32 or b"\0" in key_bytes + value_bytes: + raise Mt7932Error(f"Wi-Fi setting {key!r} does not fit a J7CF record") + if key in keys: + raise Mt7932Error(f"Wi-Fi setting {key!r} appears twice") + keys.add(key) + records.append(J7CF_RECORD.pack(3, len(key_bytes), len(value_bytes), 0, key_bytes, value_bytes)) + if len(records) not in (64, 65): + raise Mt7932Error(f"IZUBA_wifi.cfg has {len(records)} settings, not 64 or 65") + for required in ("Sta5gBw", "DisRoaming"): + if required not in keys: + raise Mt7932Error(f"IZUBA_wifi.cfg has no {required}") + return b"J7CF" + struct.pack("HHI', kind, 12, length) + body + records += struct.pack('>HHIII', kind, 12, offset, length, sum(chunk)) + b'\0' * 4 + bodies += chunk + offset += length + return b'BLOB' + struct.pack('>IHH', end, 12, len(entries)) + b'\0' * 4 + records + bodies + + +def ptx(): + """A 198-byte little-endian BLOB that passes bt7932_validate_ptx().""" + sections = ((0x101, b'p' * 14), (0x201, b'q' * 44), (0x301, b'r' * 44), (0x401, b'')) + records, bodies, offset = b'', b'', 96 + for kind, body in sections: + records += struct.pack('= split_ver("26.0") ++ else STUB_SIZE), + part_align=PART_ALIGN, + ) + return False self.check_cur_os() actions = {} -@@ -1226,7 +1268,11 @@ if __name__ == "__main__": +@@ -1226,7 +1282,11 @@ if __name__ == "__main__": logging.exception("Process execution failed") p_warning("If you need to file a bug report, please attach the log file:") p_warning(f" {os.getcwd()}/installer.log") diff --git a/Engine/patches/base/0001-omarchy-engine-runtime.patch b/Engine/patches/base/0001-omarchy-engine-runtime.patch new file mode 100644 index 0000000..460c226 --- /dev/null +++ b/Engine/patches/base/0001-omarchy-engine-runtime.patch @@ -0,0 +1,245 @@ +diff --git a/build.sh b/build.sh +index 21031fd..76c0935 100755 +--- a/build.sh ++++ b/build.sh +@@ -99,7 +99,7 @@ fi + echo "Copying files..." + + cp -r "$SRC"/* "$PACKAGE/" +-rm "$PACKAGE/asahi_firmware" ++rm -rf "$PACKAGE/asahi_firmware" + cp -r "$AFW" "$PACKAGE/" + if [ -r "$LOGO" ]; then + cp "$LOGO" "$PACKAGE/logo.icns" +@@ -125,10 +125,10 @@ mkdir -p "$PACKAGE/Frameworks/Python.framework" + # libarchive 3.7.2 (Ubuntu 24.04) is broken while 3.8.5 (Ubuntu 26.04) works + if $(bsdtar -tf "$DL/$PYTHON_PKG" Python_Framework.pkg/Payload > /dev/null); then + bsdtar -xOf "$DL/$PYTHON_PKG" Python_Framework.pkg/Payload | zcat | \ +- cpio -i -D "$PACKAGE/Frameworks/Python.framework" ++ (cd "$PACKAGE/Frameworks/Python.framework" && cpio -i) + else + 7z x -so "$DL/$PYTHON_PKG" Python_Framework.pkg/Payload | zcat | \ +- cpio -i -D "$PACKAGE/Frameworks/Python.framework" ++ (cd "$PACKAGE/Frameworks/Python.framework" && cpio -i) + fi + + cd "$PACKAGE/Frameworks/Python.framework/Versions/Current" +@@ -151,7 +151,8 @@ rm -f _test* _tkinter* + + echo "Copying certificates..." + +-certs="$(python3 -c 'import certifi; print(certifi.where())')" ++certs="$DL/certifi-cacert-2026.07.22.pem" ++[ -r "$certs" ] + cp "$certs" "$PACKAGE/Frameworks/Python.framework/Versions/Current/etc/openssl/cert.pem" + + echo "Packaging installer..." +diff --git a/src/main.py b/src/main.py +index e6407ba..0ecebb5 100644 +--- a/src/main.py ++++ b/src/main.py +@@ -4,6 +4,7 @@ import os, os.path, shlex, subprocess, sys, time, termios, json, getpass, report + from dataclasses import dataclass + + import system, osenum, stub, diskutil, osinstall, asahi_firmware, m1n1, bugs ++import omarchy_runtime + from util import * + + PART_ALIGN = psize("1MiB") +@@ -128,7 +129,10 @@ IPSW_VERSIONS = [ + class InstallerMain: + def __init__(self, version): + self.version = version ++ self.engine_runtime = omarchy_runtime.EngineRuntime.from_environment() + self.data = json.load(open("installer_data.json")) ++ if self.engine_runtime: ++ self.data = self.engine_runtime.metadata(self.data) + self.credentials_validated = False + self.expert = False + self.ipsw = None +@@ -812,18 +816,15 @@ class InstallerMain: + else: + target = resizable[0] + +- limits = self.dutil.get_resize_limits(target.name) +- +- total = target.container["CapacityCeiling"] +- free = target.container["CapacityFree"] +- min_free = self.get_min_free_space(target) +- # Minimum size, ignoring APFS snapshots & co, but with a conservative buffer +- min_size_raw = align_up(total - free + min_free, PART_ALIGN) +- # Minimum size reported by diskutil, considering APFS snapshots & co but with a less conservative buffer +- min_size_safe = limits["MinimumSizePreferred"] +- min_size = max(min_size_raw, min_size_safe) +- overhead = min_size - min_size_raw +- avail = total - min_size ++ bounds = self.get_resize_bounds(target) ++ total = bounds["total_bytes"] ++ free = bounds["free_bytes"] ++ min_free = bounds["reserved_free_bytes"] ++ min_size_raw = bounds["calculated_minimum_bytes"] ++ min_size_safe = bounds["diskutil_minimum_bytes"] ++ min_size = bounds["minimum_size_bytes"] ++ overhead = bounds["overhead_bytes"] ++ avail = bounds["available_bytes"] + + min_perc = 100 * min_size / total + +@@ -922,6 +923,25 @@ class InstallerMain: + + return True + ++ def get_resize_bounds(self, target): ++ limits = self.dutil.get_resize_limits(target.name) ++ total = target.container["CapacityCeiling"] ++ free = target.container["CapacityFree"] ++ min_free = self.get_min_free_space(target) ++ min_size_raw = align_up(total - free + min_free, PART_ALIGN) ++ min_size_safe = limits["MinimumSizePreferred"] ++ min_size = max(min_size_raw, min_size_safe) ++ return { ++ "total_bytes": total, ++ "free_bytes": free, ++ "reserved_free_bytes": min_free, ++ "calculated_minimum_bytes": min_size_raw, ++ "diskutil_minimum_bytes": min_size_safe, ++ "minimum_size_bytes": min_size, ++ "overhead_bytes": min_size - min_size_raw, ++ "available_bytes": total - min_size, ++ } ++ + def action_select_disk(self): + choices = {"1": "Internal storage"} + +@@ -939,21 +959,22 @@ class InstallerMain: + return True + + def main(self): +- print() +- p_message(f"Welcome to the {DISTRO} installer!") +- print() +- p_message("This installer will guide you through the process of setting up") +- p_message(f"{DISTRO} on your Mac.") +- print() +- p_message("Please make sure you are familiar with our documentation at:") +- p_plain( f" {col(BLUE, BRIGHT)}{DISTRO_DOCS}{col()}") +- print() +- p_question("Press enter to continue.") +- self.input() +- print() ++ if not self.engine_runtime: ++ print() ++ p_message(f"Welcome to the {DISTRO} installer!") ++ print() ++ p_message("This installer will guide you through the process of setting up") ++ p_message(f"{DISTRO} on your Mac.") ++ print() ++ p_message("Please make sure you are familiar with our documentation at:") ++ p_plain( f" {col(BLUE, BRIGHT)}{DISTRO_DOCS}{col()}") ++ print() ++ p_question("Press enter to continue.") ++ self.input() ++ print() + + self.expert = False +- if os.environ.get("EXPERT", None): ++ if os.environ.get("EXPERT", None) and not self.engine_runtime: + p_message("By default, this installer will hide certain advanced options that") + p_message("are only useful for Asahi Linux developers. You can enable expert mode") + p_message("to show them. Do not enable this unless you know what you are doing.") +@@ -970,7 +991,19 @@ class InstallerMain: + + self.chip_min_ver = CHIP_MIN_VER.get(self.sysinfo.chip_id, None) + self.device = DEVICES.get(self.sysinfo.device_class, None) +- if not self.chip_min_ver or not self.device or (self.device.expert_only and not self.expert): ++ supported = bool( ++ self.chip_min_ver ++ and self.device ++ and (not self.device.expert_only or self.expert) ++ ) ++ if self.engine_runtime: ++ self.engine_runtime.inspect( ++ self.sysinfo.device_class, ++ supported, ++ ) ++ if not supported: ++ return ++ if not supported: + p_error("This device is not supported yet!") + p_error("Please check out the Asahi Linux Blog for updates on device support:") + print() +@@ -1126,6 +1159,15 @@ class InstallerMain: + + if self.cur_os is None and self.sysinfo.boot_mode != "macOS": + self.cur_os = default_os ++ if self.engine_runtime: ++ self.engine_runtime.run_layout( ++ installer=self, ++ free_parts=parts_free if is_gpt else [], ++ resizable_parts=parts_resizable if is_gpt else [], ++ stub_size=STUB_SIZE, ++ part_align=PART_ALIGN, ++ ) ++ return False + self.check_cur_os() + + actions = {} +@@ -1226,7 +1268,11 @@ if __name__ == "__main__": + logging.exception("Process execution failed") + p_warning("If you need to file a bug report, please attach the log file:") + p_warning(f" {os.getcwd()}/installer.log") ++ if os.environ.get("OMARCHY_ENGINE_MODE"): ++ raise + except Exception: + logging.exception("Exception caught") + p_warning("If you need to file a bug report, please attach the log file:") + p_warning(f" {os.getcwd()}/installer.log") ++ if os.environ.get("OMARCHY_ENGINE_MODE"): ++ raise +diff --git a/src/osenum.py b/src/osenum.py +index d99a1e1..76f9a94 100644 +--- a/src/osenum.py ++++ b/src/osenum.py +@@ -226,6 +226,11 @@ class OSEnum: + except FileNotFoundError: + logging.info(f" Not Found") + continue ++ except PermissionError: ++ # A stub created under a private umask is readable only by ++ # root; an unprivileged enumeration still has to list it. ++ logging.warning(f" Not readable, version unknown") ++ break + try: + osi.sys_vol_bootable = fsctl_is_bootable(mounts["System"]) + except Exception as e: +diff --git a/src/osinstall.py b/src/osinstall.py +--- a/src/osinstall.py ++++ b/src/osinstall.py +@@ -127,6 +127,13 @@ + fd.write(data) + ucache.flush_progress() + ++ def install_raw_image(self, image, info): ++ zinfo = self.pkg.getinfo(image) ++ if zinfo.file_size % (4 * 1024) != 0: ++ raise Exception("The size of the rootfs image file must be a multiple of 4KiB.") ++ with self.pkg.open(image) as source, open(f"/dev/r{info.name}", "r+b") as target: ++ self.fdcopy(source, target, zinfo.file_size) ++ + def install(self, stub_ins): + p_progress("Installing OS...") + logging.info("OSInstaller.install()") +@@ -146,12 +153,7 @@ + if image: + p_plain(f" Extracting {image} into {info.name} partition...") + logging.info(f"Extract: {image}") +- zinfo = self.pkg.getinfo(image) +- if zinfo.file_size % (4 * 1024) != 0: +- raise Exception("The size of the rootfs image file must be a multiple of 4KiB.") +- with self.pkg.open(image) as sfd, \ +- open(f"/dev/r{info.name}", "r+b") as dfd: +- self.fdcopy(sfd, dfd, zinfo.file_size) ++ self.install_raw_image(image, info) + self.flush_progress() + source = part.get("source", None) + if source: diff --git a/Engine/rebuild-python-overlay.py b/Engine/rebuild-python-overlay.py index 3046760..aee2624 100644 --- a/Engine/rebuild-python-overlay.py +++ b/Engine/rebuild-python-overlay.py @@ -15,7 +15,7 @@ BASE_SHA256 = '9e9277384b6c9e8b269cc79b1b24df7bfcdcbb898a596a677b74d1d18050aebe' BASE_COMMIT = 'f0469cea0899f3efed8efead604174c7a53c4451' -VERSION = 'v0.9.2-omarchy.28' +VERSION = 'v0.9.2-omarchy.30' _SPEC = importlib.util.spec_from_file_location( 'verify_source_lock', Path(__file__).resolve().parent / 'verify-source-lock.py') @@ -50,9 +50,9 @@ def apply_downstream_patch(patch, path, content): return target.read_bytes() -def upstream_delta(checkout, delta, archive, patch): +def upstream_delta(checkout, delta, archive, patch, base_patch): # The base keeps its native runtime and m1n1, so upstream may only have changed the listed Python files. - VERIFY.require_upstream_delta(delta, patch.read_text()) + VERIFY.require_upstream_delta(delta, patch.read_text(), base_patch.read_text()) changed = git(checkout, 'diff', '--name-only', delta['base_commit'], 'HEAD').decode().splitlines() if sorted(changed) != sorted(item['path'] for item in delta['files']): raise ValueError('upstream changes since the base differ from the source lock') @@ -64,7 +64,9 @@ def upstream_delta(checkout, delta, archive, patch): if sha256(base) != item['upstream_base_sha256'] or sha256(new) != item['upstream_sha256']: raise ValueError('upstream delta digest mismatch: ' + path) if item['downstream_patched']: - base = apply_downstream_patch(patch, path, base) + # The base was built with the patch of its day; rebuilding it with that exact patch must + # still reproduce what it shipped, while the new file takes the current patch. + base = apply_downstream_patch(base_patch, path, base) new = apply_downstream_patch(patch, path, new) if sha256(base) != item['base_sha256'] or sha256(new) != item['sha256']: raise ValueError('patched upstream delta digest mismatch: ' + path) @@ -99,7 +101,8 @@ def rebuild(checkout, base, output): actual = {str(p.relative_to(root)) for p in (root / 'overlay/src').glob('*.py')} if expected != actual: raise ValueError('Python overlay inventory differs from source lock') - for item in records + lock['build_recipe'] + [lock['downstream_overlay']['patch']]: + base_patch = lock['incremental_build']['base_patch'] + for item in records + lock['build_recipe'] + [lock['downstream_overlay']['patch'], base_patch]: if sha256((root / item['path']).read_bytes()) != item['sha256']: raise ValueError('source lock digest mismatch: ' + item['path']) overlay = {Path(name).name: (root / name).read_bytes() for name in sorted(expected)} @@ -109,7 +112,8 @@ def rebuild(checkout, base, output): if sha256(archive.extractfile('./installer_data.json').read()) != lock['validation_artifact']['metadata_sha256']: raise ValueError('base engine metadata differs from the source lock') delta = upstream_delta(checkout, lock['incremental_build']['upstream_delta'], archive, - root / lock['downstream_overlay']['patch']['path']) + root / lock['downstream_overlay']['patch']['path'], + root / base_patch['path']) # The hook below rewrites the base archive's osinstall.py, so an upstream osinstall.py change would be lost. if delta.keys() & (overlay.keys() | {'osinstall.py'}): raise ValueError('upstream delta overlaps the downstream overlay or osinstall hook') diff --git a/Engine/source-lock.json b/Engine/source-lock.json index a3b34a8..46b60e3 100644 --- a/Engine/source-lock.json +++ b/Engine/source-lock.json @@ -141,7 +141,7 @@ } }, "downstream_overlay": { - "version": "v0.9.2-omarchy.28", + "version": "v0.9.2-omarchy.30", "capability": "candidate_bound_full_os_stage_one_authenticated_recovery", "engine_modes": [ "inspect", @@ -150,7 +150,7 @@ ], "patch": { "path": "patches/0001-omarchy-engine-runtime.patch", - "sha256": "c6f625c62250993fe8dd11f210225d2667b6ab341fa8922209ce0b435a3b60a7" + "sha256": "d6195ae8d436f9e4309d131e673dd609c74da584b5d0a96e7006927f8d915f56" }, "files": [ { @@ -171,7 +171,7 @@ { "path": "overlay/src/omarchy_asahi.py", "destination": "src/omarchy_asahi.py", - "sha256": "a5d887b1d07a1977ff397980d247c9138a61cfffb675ce51aa18d07589030f0c" + "sha256": "0efb7ffb77ce826389eb324bd8f7c09f44c40f83a32adad91badc63d276f357d" }, { "path": "overlay/src/omarchy_contract.py", @@ -206,7 +206,7 @@ { "path": "overlay/tests/test_omarchy_asahi.py", "destination": "tests/test_omarchy_asahi.py", - "sha256": "bf57c80bf26bbfc00a3cb24d1a5f8fc1196e8c155789fd4af97dddc0e4b7292a" + "sha256": "aead11015d20ca12737f516806e7576e5da436f6d47f16b7307d56e1ab3a24d7" }, { "path": "overlay/tests/test_omarchy_contract.py", @@ -252,6 +252,26 @@ "path": "overlay/tests/test_omarchy_image.py", "destination": "tests/test_omarchy_image.py", "sha256": "2d8ebe63c25b290f24fb70d5195f50743c79baf9eb70e8a8bba3ab5f95d5e97f" + }, + { + "path": "overlay/src/omarchy_mt7932.py", + "destination": "src/omarchy_mt7932.py", + "sha256": "bd3abd4142e3b7c5fcdac23f67aa4cc8422321e74ff0d3f87222a2ddf2aa1861" + }, + { + "path": "overlay/tests/test_omarchy_mt7932.py", + "destination": "tests/test_omarchy_mt7932.py", + "sha256": "b9668eb4a6407f35cfdeb9addd061190720208d5ee1cbb4f969cb6499aa6a822" + }, + { + "path": "overlay/src/omarchy_mesa.py", + "destination": "src/omarchy_mesa.py", + "sha256": "fcdb678568bb33d8f8cf2275f11b16d0b66409c5bd27732133d5e728bb06983b" + }, + { + "path": "overlay/tests/test_omarchy_mesa.py", + "destination": "tests/test_omarchy_mesa.py", + "sha256": "372d07d0e1860299bdf0a56b834d60748b5e130a2ef185f53ff9e8f12b3e790a" } ] }, @@ -266,17 +286,17 @@ }, { "path": "verify-source-lock.py", - "sha256": "21c230c7120388e0691ccd34ad621073c7c677b3f3d55028ec78249f4805597f" + "sha256": "ee2cd52a254696ca86594db4d843b2580d22286d55ce189ec9f34b8b21019302" }, { "path": "rebuild-python-overlay.py", - "sha256": "3cc29ff5c7bd834b40a50ef2271417efcd33bc7bc747b11c743d5ba2042017c2" + "sha256": "324c324ffde5c68294890243010e7764b44849df81c9f1605ec8bbe00c13e65c" } ], "validation_artifact": { - "filename": "installer-v0.9.2-omarchy.28.tar.gz", - "size_bytes": 17843348, - "sha256": "0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146", + "filename": "installer-v0.9.2-omarchy.30.tar.gz", + "size_bytes": 17852406, + "sha256": "2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc", "reproducibility_scope": "two-identical-python-overlay-repacks-authenticated-base", "signature": "absent", "metadata_sha256": "2e6181ce6b6e17c11039e04bfadade8d10ae0e11889885ad8c9960b68f179a5d" @@ -303,9 +323,13 @@ "upstream_base_sha256": "c8d4d2a80f3a88a9ba4d6d4024c0001062e35d15d72ae530681a0612e6525984", "upstream_sha256": "894fb8cd2ffc672bb96a14969ee9de2879d250c8c5990fee122ccf66d9b50024", "base_sha256": "95760ed02d44b5457acb81559a4e2d892ffbb7f0f2b7417c0b2eb02937945ae7", - "sha256": "d7fb4fcc9bdc13cba5a9f9c9ecace6317c60d816915950d9dfa05f826737afbd" + "sha256": "1208e9105815aa2d639190d8d26fc6804fadea01e776cce6e499ef2676a91b4e" } ] + }, + "base_patch": { + "path": "patches/base/0001-omarchy-engine-runtime.patch", + "sha256": "c6f625c62250993fe8dd11f210225d2667b6ab341fa8922209ce0b435a3b60a7" } } } diff --git a/Engine/tests/test_rebuild_python_overlay.py b/Engine/tests/test_rebuild_python_overlay.py index b5100f3..91bffcc 100644 --- a/Engine/tests/test_rebuild_python_overlay.py +++ b/Engine/tests/test_rebuild_python_overlay.py @@ -86,6 +86,8 @@ def setUp(self): self.checkout.mkdir() self.patch = root / "0001-omarchy-engine-runtime.patch" self.patch.write_text(PATCH) + self.base_patch = root / "base-0001-omarchy-engine-runtime.patch" + self.base_patch.write_text(PATCH) self.git("init", "-q") self.write("asahi_firmware/bluetooth.py", b"old\n") self.write("src/main.py", MAIN_BASE) @@ -114,7 +116,9 @@ def delta(self, *records): return {"base_commit": self.base_commit, "files": list(records or [BLUETOOTH])} def rebuild(self, delta, archive_files): - return REBUILD.upstream_delta(self.checkout, delta, archive_with(archive_files), self.patch) + return REBUILD.upstream_delta( + self.checkout, delta, archive_with(archive_files), self.patch, self.base_patch + ) def test_exact_python_delta_is_overlaid(self): self.commit() @@ -162,6 +166,30 @@ def test_base_engine_without_the_downstream_patch_is_rejected(self): {"asahi_firmware/bluetooth.py": b"old\n", "main.py": MAIN_BASE}, ) + def test_base_keeps_the_patch_it_was_built_with(self): + # The current patch has moved on; the base is still rebuilt with its own patch and must + # match what the base engine shipped. + self.patch.write_text(PATCH.replace("+import omarchy_runtime", "+import omarchy_neo")) + self.write("src/main.py", MAIN_NEW) + self.commit() + current = MAIN_NEW.replace(b"import os\n", b"import os\nimport omarchy_neo\n") + overlay = self.rebuild( + self.delta(BLUETOOTH, {**MAIN, "sha256": digest(current)}), + {"asahi_firmware/bluetooth.py": b"old\n", "main.py": MAIN_BASE_PATCHED}, + ) + self.assertEqual(overlay["main.py"], current) + + def test_base_rebuilt_with_the_current_patch_is_rejected(self): + self.patch.write_text(PATCH.replace("+import omarchy_runtime", "+import omarchy_neo")) + self.base_patch.write_text(PATCH.replace("+import omarchy_runtime", "+import omarchy_neo")) + self.write("src/main.py", MAIN_NEW) + self.commit() + with self.assertRaisesRegex(ValueError, "patched upstream delta digest mismatch"): + self.rebuild( + self.delta(BLUETOOTH, MAIN), + {"asahi_firmware/bluetooth.py": b"old\n", "main.py": MAIN_BASE_PATCHED}, + ) + def test_patched_content_must_match_lock(self): self.write("src/main.py", MAIN_NEW) self.commit() diff --git a/Engine/tests/test_verify_source_lock.py b/Engine/tests/test_verify_source_lock.py index 1dd0338..5173c75 100644 --- a/Engine/tests/test_verify_source_lock.py +++ b/Engine/tests/test_verify_source_lock.py @@ -157,15 +157,26 @@ def test_patched_and_unpatched_files_are_accepted(self): delta_record("src/main.py", True), ), PATCH, + PATCH, ) def test_repository_lock_matches_the_downstream_patch(self): lock = json.loads((ENGINE_ROOT / "source-lock.json").read_text()) patch = ENGINE_ROOT / lock["downstream_overlay"]["patch"]["path"] + base_patch = ENGINE_ROOT / lock["incremental_build"]["base_patch"]["path"] VERIFY_SOURCE_LOCK.require_upstream_delta( - lock["incremental_build"]["upstream_delta"], patch.read_text() + lock["incremental_build"]["upstream_delta"], + patch.read_text(), + base_patch.read_text(), ) + def test_patched_file_missing_from_the_base_patch_is_rejected(self): + base_patch = PATCH.split("diff --git a/src/main.py")[0] + with self.assertRaisesRegex(ValueError, "base patch coverage"): + VERIFY_SOURCE_LOCK.require_upstream_delta( + delta(delta_record("src/main.py", True)), PATCH, base_patch + ) + def test_patch_paths_are_read_from_git_headers(self): self.assertEqual( VERIFY_SOURCE_LOCK.patched_paths(PATCH), {"build.sh", "src/main.py"} @@ -174,13 +185,13 @@ def test_patch_paths_are_read_from_git_headers(self): def test_patched_file_declared_unpatched_is_rejected(self): with self.assertRaisesRegex(ValueError, "patch coverage"): VERIFY_SOURCE_LOCK.require_upstream_delta( - delta(delta_record("src/main.py", False)), PATCH + delta(delta_record("src/main.py", False)), PATCH, PATCH ) def test_unpatched_file_declared_patched_is_rejected(self): with self.assertRaisesRegex(ValueError, "patch coverage"): VERIFY_SOURCE_LOCK.require_upstream_delta( - delta(delta_record("asahi_firmware/bluetooth.py", True)), PATCH + delta(delta_record("asahi_firmware/bluetooth.py", True)), PATCH, PATCH ) def test_unpatched_file_with_distinct_shipped_digest_is_rejected(self): @@ -192,19 +203,20 @@ def test_unpatched_file_with_distinct_shipped_digest_is_rejected(self): ) ), PATCH, + PATCH, ) def test_non_python_file_is_rejected(self): with self.assertRaisesRegex(ValueError, "Python only"): VERIFY_SOURCE_LOCK.require_upstream_delta( - delta(delta_record("build.sh", True)), PATCH + delta(delta_record("build.sh", True)), PATCH, PATCH ) def test_record_without_upstream_digests_is_rejected(self): record = delta_record("asahi_firmware/bluetooth.py", False) del record["upstream_sha256"] with self.assertRaisesRegex(ValueError, "file record"): - VERIFY_SOURCE_LOCK.require_upstream_delta(delta(record), PATCH) + VERIFY_SOURCE_LOCK.require_upstream_delta(delta(record), PATCH, PATCH) def test_duplicate_or_escaping_paths_are_rejected(self): record = delta_record("asahi_firmware/bluetooth.py", False) @@ -213,11 +225,11 @@ def test_duplicate_or_escaping_paths_are_rejected(self): [delta_record("../main.py", False)], ): with self.assertRaisesRegex(ValueError, "upstream delta path"): - VERIFY_SOURCE_LOCK.require_upstream_delta(delta(*records), PATCH) + VERIFY_SOURCE_LOCK.require_upstream_delta(delta(*records), PATCH, PATCH) def test_empty_delta_is_rejected(self): with self.assertRaisesRegex(ValueError, "upstream delta lock record"): - VERIFY_SOURCE_LOCK.require_upstream_delta(delta(), PATCH) + VERIFY_SOURCE_LOCK.require_upstream_delta(delta(), PATCH, PATCH) if __name__ == "__main__": diff --git a/Engine/verify-source-lock.py b/Engine/verify-source-lock.py index 5f5a24e..612d5fc 100755 --- a/Engine/verify-source-lock.py +++ b/Engine/verify-source-lock.py @@ -100,7 +100,7 @@ def patched_paths(patch: str) -> set[str]: return set(re.findall(r"^diff --git a/(\S+) b/\S+$", patch, re.MULTILINE)) -def require_upstream_delta(delta: dict, patch: str) -> None: +def require_upstream_delta(delta: dict, patch: str, base_patch: str) -> None: required = { "path", "downstream_patched", @@ -116,6 +116,7 @@ def require_upstream_delta(delta: dict, patch: str) -> None: if not isinstance(delta["files"], list) or not delta["files"]: raise ValueError("invalid upstream delta lock record") patched = patched_paths(patch) + base_patched = patched_paths(base_patch) seen = set() for record in delta["files"]: if ( @@ -144,6 +145,8 @@ def require_upstream_delta(delta: dict, patch: str) -> None: raise ValueError( "downstream patch coverage differs from source lock: " + path ) + if record["downstream_patched"] and path not in base_patched: + raise ValueError("base patch coverage differs from source lock: " + path) if not record["downstream_patched"] and ( record["base_sha256"] != record["upstream_base_sha256"] or record["sha256"] != record["upstream_sha256"] @@ -226,9 +229,12 @@ def verify(engine_root: Path, checkout: Path) -> None: raise ValueError("overlay destination is invalid") for item in lock["build_recipe"]: require_digest(engine_root, item, "build recipe") + base_patch = lock["incremental_build"]["base_patch"] + require_digest(engine_root, base_patch, "base patch") require_upstream_delta( lock["incremental_build"]["upstream_delta"], (engine_root / overlay["patch"]["path"]).read_text(encoding="utf-8"), + (engine_root / base_patch["path"]).read_text(encoding="utf-8"), ) diff --git a/Packaging/build-app.sh b/Packaging/build-app.sh index 5ef6bd3..afe563c 100755 --- a/Packaging/build-app.sh +++ b/Packaging/build-app.sh @@ -46,8 +46,8 @@ helper_identifier="$INSTALLER_HELPER_IDENTIFIER" app_name="$INSTALLER_APP_NAME.app" app_executable_name="OmarchyAppleInstallerApp" daemon_plist_name="$helper_identifier.plist" -engine_file_name="installer-v0.9.2-omarchy.28.tar.gz" -engine_digest="0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" +engine_file_name="installer-v0.9.2-omarchy.30.tar.gz" +engine_digest="2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc" if [[ $signing_identity == "-" ]]; then client_requirement="identifier \"$app_identifier\"" @@ -87,6 +87,9 @@ sealed_catalog_signature="$release_directory/catalog.json.sig" [[ ${OMARCHY_PRIVATE_PLAIN_TEST:-0} != "1" || ${OMARCHY_PRIVATE_LIMINE_TEST:-0} != "1" ]] \ || fail "private plain and Limine profiles are mutually exclusive" +[[ ${OMARCHY_DEVELOPER_BUILD:-0} != "1" \ + || ( ${OMARCHY_PRIVATE_PLAIN_TEST:-0} != "1" && ${OMARCHY_PRIVATE_LIMINE_TEST:-0} != "1" ) ]] \ + || fail "the developer build cannot also be a private profile" sealed_catalog_available=false if [[ -e $sealed_catalog || -L $sealed_catalog \ || -e $sealed_catalog_signature || -L $sealed_catalog_signature ]]; then @@ -109,6 +112,11 @@ fi if [[ ${OMARCHY_PRIVATE_LIMINE_TEST:-0} == "1" && $sealed_catalog_available != "true" ]]; then fail "private Limine builds require a sealed private catalog" fi +# A developer catalog can admit Macs no public catalog does, so it must never +# reach a developer build over the network. +if [[ ${OMARCHY_DEVELOPER_BUILD:-0} == "1" && $sealed_catalog_available != "true" ]]; then + fail "developer builds require a sealed developer catalog" +fi if [[ ${OMARCHY_PRIVATE_LIMINE_TEST:-0} == "1" ]]; then python3 "$script_directory/private-test/prepare-limine-assets.py" --verify-release "$release_directory" >/dev/null @@ -270,8 +278,11 @@ for model in json.loads(catalog.read_text())["models"]: if source.stat().st_size != artifact["sizeBytes"] or hashlib.sha256(source.read_bytes()).hexdigest() != expected: raise SystemExit("bundled engine differs from signed catalog") target = destination / name - if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() != expected: - raise SystemExit("bundled engine conflicts with inspection engine") + if target.exists(): + if hashlib.sha256(target.read_bytes()).hexdigest() != expected: + raise SystemExit("bundled engine conflicts with inspection engine") + # The inspection engine is already this exact execution engine. + continue shutil.copyfile(source, target) target.chmod(0o444) PYCODE @@ -294,6 +305,9 @@ fi if [[ ${OMARCHY_PRIVATE_LIMINE_TEST:-0} == "1" ]]; then plutil -insert OmarchyPrivateLimineTest -bool true "$contents/Info.plist" fi +if [[ ${OMARCHY_DEVELOPER_BUILD:-0} == "1" ]]; then + plutil -insert OmarchyDeveloperBuild -bool true "$contents/Info.plist" +fi plutil -replace CFBundleVersion \ -string "$build_number" "$contents/Info.plist" plutil -replace CFBundleIdentifier \ diff --git a/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift b/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift index 537ecdb..e8c2455 100644 --- a/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift +++ b/Sources/OmarchyAppleInstaller/OmarchyRemovalRecognition.swift @@ -10,8 +10,11 @@ static let minimumFreeSpace: UInt64 = 1 << 30 static let memberGap: UInt64 = 16 << 20 - // asahi-installer's STUB_SIZE is 2,499,805,184 bytes. - static let stubSizes: ClosedRange = 2_300_000_000...2_700_000_000 + // asahi-installer's STUB_SIZE is 2,499,805,184 bytes; a stub on macOS 26 + // firmware (the MacBook Neo) is 5,999,951,872. + static let stubSizes: [ClosedRange] = [ + 2_300_000_000...2_700_000_000, 5_800_000_000...6_200_000_000, + ] static let maximumESP: UInt64 = 1 << 30 static func recognize(_ snapshot: RemovalSnapshot) throws -> RemovalLayout { @@ -85,7 +88,8 @@ let systems = volumes.filter { $0.roles == ["System"] } let data = volumes.filter { $0.roles == ["Data"] } guard !systems.isEmpty, !data.isEmpty else { return .foreign } - guard stubSizes.contains(part.size), volumes.count == 4, systems.count == 1, + guard stubSizes.contains(where: { $0.contains(part.size) }), volumes.count == 4, + systems.count == 1, data.count == 1, let group = systems[0].group, data[0].group == group, volumes.filter({ $0.roles == ["Preboot"] }).count == 1, volumes.filter({ $0.roles == ["Recovery"] }).count == 1 @@ -250,9 +254,13 @@ return created } - /// m1n1's variables follow the first "STACKBOT" up to the first NUL, one per - /// line (asahi-installer m1n1.py extract_vars). nil when there is no such region. + /// The EFI partitions a startup container's m1n1 chainloads. Aurora's J700 + /// Stage 1 (the MacBook Neo) names its one ESP in a versioned, CRC-checked + /// config block; asahi's m1n1 keeps variables after the first "STACKBOT" up + /// to the first NUL, one per line (asahi-installer m1n1.py extract_vars). + /// nil when neither is there. static func efiPartitions(bootObject: Data) -> [String]? { + if let aurora = auroraStage1Partition(bootObject: bootObject) { return [aurora] } guard let marker = bootObject.range(of: Data("STACKBOT".utf8)) else { return nil } let tail = bootObject[marker.upperBound...] let region = tail.prefix { $0 != 0 } @@ -261,6 +269,41 @@ return String(decoding: region, as: UTF8.self).split(separator: "\n") .filter { $0.hasPrefix(key) }.map { String($0.dropFirst(key.count)) } } + + /// Aurora's J700 Stage 1 config block (aurora-silicon/m1n1 + /// tools/fill_stage1_config.py): magic, then version, proxy window, a + /// NUL-padded 40-byte ESP PARTUUID and a 192-byte Stage 2 path, then the + /// CRC-32 of those fields. nil unless there is exactly one valid version-1 + /// block naming an ESP. + static func auroraStage1Partition(bootObject: Data) -> String? { + let magic = Data("AURORA-S1-CFG01\0".utf8) + let bodySize = 4 + 4 + 40 + 192 + let bytes = [UInt8](bootObject) + guard let first = bootObject.range(of: magic), + bootObject.range(of: magic, in: first.upperBound.. UInt32 { + (0..<4).reduce(UInt32(0)) { $0 | UInt32(bytes[offset + $1]) << (8 * $1) } + } + guard word(start) == 1, word(start + bodySize) == crc32(body) else { return nil } + let field = body[8..<48].prefix { $0 != 0 } + guard body[(8 + field.count)..<48].allSatisfy({ $0 == 0 }), + let uuid = UUID(uuidString: String(decoding: field, as: UTF8.self)) + else { return nil } + return uuid.uuidString + } + + static func crc32(_ bytes: [UInt8]) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in bytes { + crc ^= UInt32(byte) + for _ in 0..<8 { crc = crc & 1 == 1 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1 } + } + return ~crc + } } /// Every message says what was found and ends by saying nothing changed. diff --git a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift index 0396ad7..55f9b18 100644 --- a/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift +++ b/Sources/OmarchyAppleInstaller/ValidationEngineArtifact.swift @@ -18,11 +18,11 @@ /// An installation engine fix ships in a catalog without rebuilding /// and re-notarizing the app. Nothing may require them to be equal. public struct ValidationEngineArtifactLocator: Sendable { - public static let version = "v0.9.2-omarchy.28" - public static let fileName = "installer-v0.9.2-omarchy.28.tar.gz" + public static let version = "v0.9.2-omarchy.30" + public static let fileName = "installer-v0.9.2-omarchy.30.tar.gz" public static let expectedDigest = - "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" - public static let expectedSizeBytes: UInt64 = 17_843_348 + "sha256:2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc" + public static let expectedSizeBytes: UInt64 = 17_852_406 public init() {} diff --git a/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift b/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift index 6a6733b..363fdb7 100644 --- a/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift +++ b/Sources/OmarchyInstallerUXCore/InstallerBuildProfile.swift @@ -7,6 +7,9 @@ case standard case privatePlain case privateLimine + /// For bring-up teams: its sealed catalog may admit Macs the public + /// catalogs don't, so its state never mixes with theirs. + case developer public static var current: Self { resolve(infoDictionary: Bundle.main.infoDictionary ?? [:]) @@ -20,6 +23,9 @@ if infoDictionary["OmarchyPrivateLimineTest"] as? Bool == true { return .privateLimine } + if infoDictionary["OmarchyDeveloperBuild"] as? Bool == true { + return .developer + } return .standard } @@ -31,12 +37,13 @@ case .standard: InstallerProductIdentity.appIdentifier case .privatePlain: InstallerProductIdentity.appIdentifier + ".private-m3-20260922" case .privateLimine: InstallerProductIdentity.appIdentifier + ".private-limine-20260922" + case .developer: InstallerProductIdentity.appIdentifier + ".developer" } } public var startupSequence: String { switch self { - case .standard, .privateLimine: "m1n1 → U-Boot → Limine → Omarchy" + case .standard, .privateLimine, .developer: "m1n1 → U-Boot → Limine → Omarchy" case .privatePlain: "m1n1 → U-Boot → GRUB → Omarchy" } } diff --git a/Sources/OmarchyInstallerUXCore/MacModelNames.swift b/Sources/OmarchyInstallerUXCore/MacModelNames.swift index f1c826d..4fca0a9 100644 --- a/Sources/OmarchyInstallerUXCore/MacModelNames.swift +++ b/Sources/OmarchyInstallerUXCore/MacModelNames.swift @@ -87,6 +87,7 @@ "apple,j613": mac("MacBook Air 13-inch (M3, 2024)", "MacBook Air", "M3"), "apple,j615": mac("MacBook Air 15-inch (M3, 2024)", "MacBook Air", "M3"), "apple,j614s": mac("MacBook Pro 14-inch (M4 Pro, 2024)", "MacBook Pro", "M4"), + "apple,j700": mac("MacBook Neo (A18 Pro, 2026)", "MacBook Neo", "A18 Pro"), ] } #endif diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift index 6d92d8b..4cf8059 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalFixtures.swift @@ -127,6 +127,18 @@ ], installs: [convergedInstall]) } + /// The MacBook Neo layout: macOS 26 firmware needs a 6 GB stub. + static func neoMacos26() -> RemovalSnapshot { + snapshot( + macOSSize: 194_332_676_096, + [ + .part(convergedInstall.stub, "Apple_APFS", 5_999_951_872), + .part(convergedInstall.esp, "EFI", 524_288_000, name: "EFI - OMARC"), + .part(convergedInstall.linux[0], "Linux Filesystem", 2_147_483_648), + .part(convergedInstall.linux[1], "Linux Filesystem", 256_798_965_760), + ], installs: [convergedInstall]) + } + static let alarmInstall = Install( name: "Asahi Alarm Minimal", stub: id(3), esp: id(4), linux: [id(5)], group: id(600)) diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift index 67cf790..d34c750 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/OmarchyRemovalTests.swift @@ -19,6 +19,63 @@ XCTAssertEqual(plan.targetMacOSBytes, 994_662_584_320) } + func testANeoInstallWithItsMacos26StubIsRecognised() throws { + let disk = FakeRemovalDisk(F.neoMacos26(), install: F.convergedInstall) + let plan = try OmarchyRemovalPlan(disks: disk) + XCTAssertEqual(plan.kind, .installation) + XCTAssertEqual( + plan.members.map(\.uuid), + [F.convergedInstall.stub, F.convergedInstall.esp] + F.convergedInstall.linux) + } + + func testANeoStubBootingAurorasJ700Stage1IsRecognised() throws { + let disk = FakeRemovalDisk(F.neoMacos26(), install: F.convergedInstall) + disk.files!.stubBootObject = Self.auroraStage1(esp: F.convergedInstall.esp.lowercased()) + let plan = try OmarchyRemovalPlan(disks: disk) + XCTAssertEqual(plan.kind, .installation) + XCTAssertEqual( + plan.members.map(\.uuid), + [F.convergedInstall.stub, F.convergedInstall.esp] + F.convergedInstall.linux) + } + + func testAnAuroraStage1MustNameThisESPInOneValidBlock() { + let esp = F.convergedInstall.esp.lowercased() + let versionTwo = Self.auroraStage1(esp: esp, version: 2) + let cases: [Data] = [ + Self.auroraStage1(esp: F.id(77).lowercased()), + Self.auroraStage1(esp: esp, corruptCRC: true), + versionTwo, + Self.auroraStage1(esp: esp) + Self.auroraStage1(esp: esp), + ] + for bootObject in cases { + let disk = FakeRemovalDisk(F.neoMacos26(), install: F.convergedInstall) + disk.files!.stubBootObject = bootObject + XCTAssertThrowsError(try OmarchyRemovalPlan(disks: disk)) { + XCTAssertTrue( + ($0 as? RemovalFailure)?.message.contains( + "the m1n1 boot object in its startup container doesn’t point to this EFI partition") + ?? false, "\($0)") + } + } + } + + /// Aurora's J700 Stage 1 as aurora-silicon/m1n1 tools/fill_stage1_config.py + /// fills it: asahi's m1n1 version marker, one config block, a STACKBOT tail. + static func auroraStage1(esp: String, version: UInt32 = 1, corruptCRC: Bool = false) -> Data { + func le(_ value: UInt32) -> [UInt8] { + (0..<4).map { UInt8(truncatingIfNeeded: value >> (8 * $0)) } + } + var body = le(version) + le(5000) + body += Array(esp.utf8) + [UInt8](repeating: 0, count: 40 - esp.utf8.count) + let path = Array(";m1n1/boot.bin".utf8) + body += path + [UInt8](repeating: 0, count: 192 - path.count) + var image = Data("m1n1 stage 1 ##m1n1_ver##v1.6.1\0 code STACKBOT data ".utf8) + image.append(Data("AURORA-S1-CFG01\0".utf8)) + image.append(contentsOf: body + le(RemovalEvidence.crc32(body) ^ (corruptCRC ? 1 : 0))) + image.append(Data(" more code STACKBOT".utf8)) + return image + } + func testOlderOmarchyMacInstallIsRecognisedWithItsOneRootPartition() throws { let disk = FakeRemovalDisk(F.alarm(), install: F.alarmInstall) let plan = try OmarchyRemovalPlan(disks: disk) diff --git a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift index fafa9b5..b72692e 100644 --- a/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift +++ b/Tests/OmarchyAppleInstallerTrustCoreTests/ValidationEngineArtifactTests.swift @@ -8,19 +8,19 @@ func testM3CapableInspectionIdentityIsPinnedExactly() { XCTAssertEqual( ValidationEngineArtifactLocator.version, - "v0.9.2-omarchy.28" + "v0.9.2-omarchy.30" ) XCTAssertEqual( ValidationEngineArtifactLocator.fileName, - "installer-v0.9.2-omarchy.28.tar.gz" + "installer-v0.9.2-omarchy.30.tar.gz" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedDigest, - "sha256:0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146" + "sha256:2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc" ) XCTAssertEqual( ValidationEngineArtifactLocator.expectedSizeBytes, - 17_843_348 + 17_852_406 ) } diff --git a/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift b/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift index 73e749e..b79cfcd 100644 --- a/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/InstallerBuildProfileTests.swift @@ -40,6 +40,25 @@ XCTAssertFalse(profile.startupSequence.contains("GRUB")) } + func testDeveloperBuildKeepsItsOwnStateAndHidesChannels() { + let profile = InstallerBuildProfile.resolve(infoDictionary: ["OmarchyDeveloperBuild": true]) + XCTAssertEqual(profile, .developer) + XCTAssertTrue(profile.allowsEncryption) + XCTAssertFalse(profile.showsReleaseChannels) + XCTAssertEqual(profile.workspaceName, InstallerProductIdentity.appIdentifier + ".developer") + for other in [InstallerBuildProfile.standard, .privatePlain, .privateLimine] { + XCTAssertNotEqual(profile.workspaceName, other.workspaceName) + } + XCTAssertEqual(profile.startupSequence, "m1n1 → U-Boot → Limine → Omarchy") + } + + func testPrivateFlagsOutrankTheDeveloperFlag() { + let profile = InstallerBuildProfile.resolve(infoDictionary: [ + "OmarchyDeveloperBuild": true, "OmarchyPrivatePlainTest": true, + ]) + XCTAssertEqual(profile, .privatePlain) + } + func testConflictingFlagsRetainPlainRestriction() { let profile = InstallerBuildProfile.resolve(infoDictionary: [ "OmarchyPrivatePlainTest": true, "OmarchyPrivateLimineTest": true, diff --git a/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift b/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift index 953bdc2..f6229ee 100644 --- a/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift +++ b/Tests/OmarchyInstallerUXCoreTests/PlainLanguageTests.swift @@ -243,6 +243,12 @@ } } + func testTheMacBookNeoHasAName() { + XCTAssertEqual(MacModelNames.name(for: "apple,j700"), "MacBook Neo (A18 Pro, 2026)") + XCTAssertEqual( + MacModelNames.supportedFamiliesSummary(["apple,j700"]), "A18 Pro: MacBook Neo") + } + func testEveryPhaseHasADistinctTitle() { let phases = [ "preflight", "existing_removal", "apfs_preparation", "stub_and_esp", diff --git a/docs/extraction.md b/docs/extraction.md index 3f638f4..fdc43d2 100644 --- a/docs/extraction.md +++ b/docs/extraction.md @@ -81,4 +81,10 @@ Swift now withholds the resize drift margin before adopting the recommended (dou The catalog's installation engine is `installer-v0.9.2-omarchy.28.tar.gz`, 17,843,348 bytes, SHA-256 `0cf1aa87760f90a545298b7cef737c9b497f2cad421d79ac59f557a81f2eb146`: the `.27` overlay with the Recovery step from #39 added. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes; compared with `.27`, only `omarchy_asahi.py`, `omarchy_runtime.py` and `version.tag` changed. The bundled inspection pins and packager select `.28` too, so the release scripts, which take the catalog engine from `Packaging/build-app.sh`, publish the engine the templates name. -Ship installer **2.1.0 or later** together with the new engine and a signed catalog whose `installer.minimumVersion` is at least **2.1.0**. Both release-input templates carry this gate, and the catalog generator refuses a lower minimum for `.18` or newer engines in this lineage. Older installers then show the update-required message before decoding recommendation fields. The bundled inspection pins now select `.28`; signed production catalogs and frozen private-test catalogs are not changed by this source update. Artifact publication, catalog signing and physical installation qualification remain separate release steps. +## macOS 26 firmware and MacBook Neo engine + +Engine `.29`, `installer-v0.9.2-omarchy.29.tar.gz`, 17,851,067 bytes, SHA-256 `14323c787b94521451504d481f783b2710c259ae1448c8039db09ee5c6d8d337`, is `.28` plus macOS 26 firmware support and the MacBook Neo (`apple,j700`): reading the restore bundle macOS 26's bootcaches omit, collecting firmware and boot images from the running macOS when the recovery image is AEA-encrypted, the Neo's own Aurora Stage 1, its C1FE trackpad firmware, and its MT7932 Wi-Fi and Bluetooth inputs collected from macOS. Two repacks with macOS `/usr/bin/python3` 3.9.6 from a fresh v0.9.2 checkout produced identical bytes, after the same checkout reproduced `.28` exactly. Compared with `.28`, `omarchy_mt7932.py` is added and `main.py`, `omarchy_asahi.py` and `version.tag` changed. The bundled inspection pins, packager and both release templates selected `.29`. Public catalogs still do not admit `apple,j700`; only a developer build with a sealed developer catalog can. + +Engine `.30`, `installer-v0.9.2-omarchy.30.tar.gz`, 17,852,406 bytes, SHA-256 `2d5a14c3dde7b9ebb7076cd65a6d5a478d7f20b6396fadb52b59532752d12bdc`, adds the MacBook Neo's Touch ID calibration to the firmware it collects: one standalone signed FSC2 IMG4 read from the raw iBoot System Container and installed as `apple/mesacal-j700.bin`. Two repacks with macOS `/usr/bin/python3` 3.9.6 produced identical bytes; compared with `.29`, `omarchy_mesa.py` is added and `omarchy_asahi.py` and `version.tag` changed. The bundled inspection pins, packager and both release templates select `.30`. + +Ship installer **2.1.0 or later** together with the new engine and a signed catalog whose `installer.minimumVersion` is at least **2.1.0**. Both release-input templates carry this gate, and the catalog generator refuses a lower minimum for `.18` or newer engines in this lineage. Older installers then show the update-required message before decoding recommendation fields. The bundled inspection pins now select `.30`; signed production catalogs and frozen private-test catalogs are not changed by this source update. Artifact publication, catalog signing and physical installation qualification remain separate release steps. diff --git a/scripts/make-unsigned-catalog.py b/scripts/make-unsigned-catalog.py index 40b8655..8ad605b 100755 --- a/scripts/make-unsigned-catalog.py +++ b/scripts/make-unsigned-catalog.py @@ -20,9 +20,14 @@ higher-sequence one replaces it. The monotonic `sequence` is the only machine-enforced guard. +`--developer` is for a developer build's sealed catalog only. Its inputs may +add `developer_models`: groups of boards from the manifest's `developer` +section, each with its own payload and metadata. The main group still covers +exactly every M1, M2 and M3 Mac. Channel publishing rejects such a catalog. + Usage: make-unsigned-catalog.py --base-url URL --assets-dir DIR --inputs FILE - [--output FILE] [--now ISO8601] + [--developer] [--output FILE] [--now ISO8601] """ from __future__ import annotations @@ -57,6 +62,7 @@ REQUIRED_INSTALLER_KEYS = frozenset( {"minimum_version", "latest_version", "download_url"} ) +DEVELOPER_GROUP_KEYS = frozenset({"payload_name", "metadata_name", "device_identifiers"}) DEVICE_IDENTIFIER_PATTERN = re.compile(r"^apple,[0-9a-z]+$") EVIDENCE_REVISION_PATTERN = re.compile(r"^[0-9a-z.-]+$") @@ -165,7 +171,42 @@ def parse_version(value: str) -> tuple[int, int, int]: return (int(parts[0]), int(parts[1]), int(parts[2])) -def load_inputs(path: Path) -> dict: +def load_developer_models(document: dict) -> list[dict]: + """Validate a developer catalog's extra groups, failing closed.""" + groups = document["developer_models"] + if not isinstance(groups, list) or not groups: + raise SystemExit("inputs developer_models must be a non-empty list") + allowed = supported_models.developer_boards() + names = {document["payload_name"], document["metadata_name"], document["engine_name"]} + boards = set(document["device_identifiers"]) + for group in groups: + if not isinstance(group, dict) or set(group) != DEVELOPER_GROUP_KEYS: + raise SystemExit( + "inputs developer_models entries must have exactly " + f"{', '.join(sorted(DEVELOPER_GROUP_KEYS))}" + ) + for key in ("payload_name", "metadata_name"): + name = group[key] + if not isinstance(name, str) or not name or "/" in name or name in {".", ".."}: + raise SystemExit(f"inputs developer_models {key} must be a plain file name: {name}") + if name in names: + raise SystemExit(f"inputs developer_models reuses the file name {name}") + names.add(name) + identifiers = group["device_identifiers"] + if not isinstance(identifiers, list) or not identifiers: + raise SystemExit("inputs developer_models device_identifiers must be a non-empty list") + for identifier in identifiers: + if identifier in boards: + raise SystemExit(f"inputs developer_models lists {identifier} twice") + if identifier not in allowed: + raise SystemExit( + f"{identifier} is not a developer board in scripts/supported-models.json" + ) + boards.add(identifier) + return groups + + +def load_inputs(path: Path, developer: bool = False) -> dict: """Read and fully validate the per-release inputs, failing closed.""" if not path.is_file() or path.is_symlink(): raise SystemExit(f"unsafe or missing inputs file: {path}") @@ -180,7 +221,7 @@ def load_inputs(path: Path) -> dict: missing = REQUIRED_INPUT_KEYS - keys if missing: raise SystemExit(f"inputs file is missing keys: {', '.join(sorted(missing))}") - unknown = keys - REQUIRED_INPUT_KEYS + unknown = keys - REQUIRED_INPUT_KEYS - ({"developer_models"} if developer else set()) if unknown: raise SystemExit(f"inputs file has unknown keys: {', '.join(sorted(unknown))}") @@ -262,6 +303,8 @@ def load_inputs(path: Path) -> dict: if not isinstance(download_url, str) or not download_url.startswith("https://"): raise SystemExit(f"inputs installer.download_url must be https: {download_url}") + if "developer_models" in document: + load_developer_models(document) return document @@ -295,6 +338,11 @@ def parse_arguments() -> argparse.Namespace: "--now", help="override the issue time as YYYY-MM-DDTHH:MM:SSZ (tests only)", ) + parser.add_argument( + "--developer", + action="store_true", + help="allow developer_models: for a developer build's sealed catalog only", + ) arguments = parser.parse_args() if not arguments.base_url.startswith("https://"): @@ -320,18 +368,44 @@ def issue_time(override: str | None) -> datetime.datetime: return parsed.replace(tzinfo=datetime.timezone.utc) -def main() -> None: - arguments = parse_arguments() - inputs = load_inputs(arguments.inputs) - assets = arguments.assets_dir +def model_group(inputs: dict, group: dict, assets: Path, base_url: str) -> tuple[list[dict], int]: + """The catalog models for one payload's boards, and its payload part count.""" engine = require_regular_file(assets / inputs["engine_name"]) - metadata = require_regular_file(assets / inputs["metadata_name"]) - payload = require_regular_file(assets / inputs["payload_name"]) + metadata = require_regular_file(assets / group["metadata_name"]) + payload = require_regular_file(assets / group["payload_name"]) - payload_artifact = artifact(payload, arguments.base_url) + payload_artifact = artifact(payload, base_url) parts = discover_parts(payload) if parts: - payload_artifact["parts"] = part_records(payload, parts, arguments.base_url) + payload_artifact["parts"] = part_records(payload, parts, base_url) + shared = { + "status": "enabled", + "asahiInstallerTag": inputs["asahi_installer_tag"], + "asahiInstallerRevision": inputs["asahi_installer_revision"], + "asahiInstallerDataRevision": inputs["asahi_installer_data_revision"], + "downstreamRevision": inputs["downstream_revision"], + "engineVersion": inputs["engine_version"], + "engineDigest": f"sha256:{digest(engine)}", + "metadataDigest": f"sha256:{digest(metadata)}", + "payloadDigest": f"sha256:{digest(payload)}", + "evidenceRevision": inputs["evidence_revision"], + "engineArtifact": artifact(engine, base_url), + "metadataArtifact": artifact(metadata, base_url), + "payloadArtifact": payload_artifact, + } + models = [ + {"deviceIdentifier": device_identifier, **shared} + for device_identifier in group["device_identifiers"] + ] + return models, len(parts) + + +def main() -> None: + arguments = parse_arguments() + inputs = load_inputs(arguments.inputs, developer=arguments.developer) + models, parts = model_group(inputs, inputs, arguments.assets_dir, arguments.base_url) + for group in inputs.get("developer_models", []): + models += model_group(inputs, group, arguments.assets_dir, arguments.base_url)[0] issued = issue_time(arguments.now) installer = inputs["installer"] @@ -345,25 +419,7 @@ def main() -> None: "latestVersion": installer["latest_version"], "downloadURL": installer["download_url"], }, - "models": [ - { - "deviceIdentifier": device_identifier, - "status": "enabled", - "asahiInstallerTag": inputs["asahi_installer_tag"], - "asahiInstallerRevision": inputs["asahi_installer_revision"], - "asahiInstallerDataRevision": inputs["asahi_installer_data_revision"], - "downstreamRevision": inputs["downstream_revision"], - "engineVersion": inputs["engine_version"], - "engineDigest": f"sha256:{digest(engine)}", - "metadataDigest": f"sha256:{digest(metadata)}", - "payloadDigest": f"sha256:{digest(payload)}", - "evidenceRevision": inputs["evidence_revision"], - "engineArtifact": artifact(engine, arguments.base_url), - "metadataArtifact": artifact(metadata, arguments.base_url), - "payloadArtifact": payload_artifact, - } - for device_identifier in inputs["device_identifiers"] - ], + "models": models, } arguments.output.parent.mkdir(parents=True, exist_ok=True) @@ -373,7 +429,7 @@ def main() -> None: print(f"sequence={catalog['sequence']}") print(f"evidence_revision={inputs['evidence_revision']}") print(f"models={len(catalog['models'])}") - print(f"payload_parts={len(parts)}") + print(f"payload_parts={parts}") if __name__ == "__main__": diff --git a/scripts/release-inputs-aurora.template.json b/scripts/release-inputs-aurora.template.json index 12896f5..f0cb535 100644 --- a/scripts/release-inputs-aurora.template.json +++ b/scripts/release-inputs-aurora.template.json @@ -1,8 +1,8 @@ { "payload_name": "omarchy-2026.09.13-aarch64-apple-silicon-aurora-os-package.zip", - "engine_name": "installer-v0.9.2-omarchy.28.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.30.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.28", + "engine_version": "v0.9.2-omarchy.30", "evidence_revision": "4.0.3-mac.2.20260913-aurora", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c", diff --git a/scripts/release-inputs.template.json b/scripts/release-inputs.template.json index a72dd84..0240457 100644 --- a/scripts/release-inputs.template.json +++ b/scripts/release-inputs.template.json @@ -1,8 +1,8 @@ { "payload_name": "omarchy-2026.09.13-aarch64-apple-silicon-asahi-os-package.zip", - "engine_name": "installer-v0.9.2-omarchy.28.tar.gz", + "engine_name": "installer-v0.9.2-omarchy.30.tar.gz", "metadata_name": "installer_data.json", - "engine_version": "v0.9.2-omarchy.28", + "engine_version": "v0.9.2-omarchy.30", "evidence_revision": "4.0.3-mac.1.20260913", "asahi_installer_tag": "v0.9.2", "asahi_installer_revision": "dffbb38ef0c00c0431c609ecd8a00f42deb5b24c", diff --git a/scripts/supported-models.json b/scripts/supported-models.json index b551bbd..f254074 100644 --- a/scripts/supported-models.json +++ b/scripts/supported-models.json @@ -39,5 +39,8 @@ "refused": { "apple,j575d": "Mac Studio (M3 Ultra, 2025): the pinned v0.9.2 engine has no j575dap device or T6032 chip entry, so it cannot inspect or plan this Mac", "apple,j614s": "MacBook Pro 14-inch (M4 Pro, 2024): M4 and later Macs are not supported" + }, + "developer": { + "apple,j700": "MacBook Neo (A18 Pro, T8140): boots its own J700 m1n1 and Stage 1 from its own image; developer builds only" } } diff --git a/scripts/supported_models.py b/scripts/supported_models.py index c490545..4be3cac 100755 --- a/scripts/supported_models.py +++ b/scripts/supported_models.py @@ -5,7 +5,9 @@ pinned engine can inspect. A catalog that offers any Mac offers all of them, so a release or preview can never ship a subset by accident. A channel with no Mac release yet (an empty catalog) is the only exception. `refused` names each -board that stays out and why. +board that stays out and why. `developer` names each board only a developer +build's sealed catalog may add, with its own image; channel catalogs never +carry one, so check-catalog rejects it like any unknown board. Usage: supported_models.py check-catalog FILE exit 1 unless FILE covers every Mac @@ -28,9 +30,17 @@ def load(path: Path = MANIFEST) -> tuple[list[str], dict[str, str]]: raise SystemExit(f"{path}: supported lists a Mac twice") if set(supported) & set(refused): raise SystemExit(f"{path}: a Mac is both supported and refused") + if set(document.get("developer", {})) & (set(supported) | set(refused)): + raise SystemExit(f"{path}: a developer board is also supported or refused") return supported, refused +def developer_boards(path: Path = MANIFEST) -> dict[str, str]: + """The boards only a developer build's sealed catalog may add, with why.""" + load(path) + return dict(json.loads(path.read_text()).get("developer", {})) + + def coverage_errors(identifiers: list[str]) -> list[str]: """Why IDENTIFIERS (the Macs a catalog enables) is not exactly the supported set.""" supported, refused = load() diff --git a/scripts/tests/test_make_unsigned_catalog.py b/scripts/tests/test_make_unsigned_catalog.py index 3da0049..e5286ea 100644 --- a/scripts/tests/test_make_unsigned_catalog.py +++ b/scripts/tests/test_make_unsigned_catalog.py @@ -38,7 +38,12 @@ def write_inputs(self, document: dict | None = None) -> Path: path.write_text(json.dumps(document or self.inputs, indent=2)) return path - def generate(self, document: dict | None = None, output: str = "catalog.json"): + def generate( + self, + document: dict | None = None, + output: str = "catalog.json", + developer: bool = False, + ): return subprocess.run( [ sys.executable, @@ -53,13 +58,30 @@ def generate(self, document: dict | None = None, output: str = "catalog.json"): str(self.directory / output), "--now", NOW, + *(["--developer"] if developer else []), ], capture_output=True, text=True, ) - def assertRejected(self, document: dict, fragment: str) -> None: - result = self.generate(document) + def with_neo(self) -> dict: + """The inputs plus a MacBook Neo group with its own split payload.""" + document = json.loads(json.dumps(self.inputs)) + neo = { + "payload_name": "neo-os-package.zip", + "metadata_name": "installer_data-neo.json", + "device_identifiers": ["apple,j700"], + } + (self.assets / neo["metadata_name"]).write_bytes(b"neo-metadata" * 8) + content = b"neo-payload" * 64 + (self.assets / neo["payload_name"]).write_bytes(content) + (self.assets / f"{neo['payload_name']}.part00").write_bytes(content[:300]) + (self.assets / f"{neo['payload_name']}.part01").write_bytes(content[300:]) + document["developer_models"] = [neo] + return document + + def assertRejected(self, document: dict, fragment: str, developer: bool = False) -> None: + result = self.generate(document, developer=developer) self.assertNotEqual(result.returncode, 0, result.stdout) self.assertIn(fragment, result.stderr) @@ -198,6 +220,7 @@ def test_the_manifest_is_every_m1_m2_and_m3_mac(self) -> None: self.assertEqual(len(manifest["supported"]), 34) self.assertTrue(set(M3_MACS) <= set(manifest["supported"])) self.assertEqual(set(manifest["refused"]), {"apple,j575d", "apple,j614s"}) + self.assertEqual(set(manifest["developer"]), {"apple,j700"}) for template in sorted(MANIFEST.parent.glob("release-inputs*.template.json")): identifiers = json.loads(template.read_text())["device_identifiers"] self.assertEqual( @@ -247,6 +270,62 @@ def test_the_committed_template_is_valid(self) -> None: catalog["models"][0]["payloadDigest"], f"sha256:{digest}" ) + def test_a_developer_catalog_gives_the_neo_its_own_payload(self) -> None: + result = self.generate(self.with_neo(), developer=True) + self.assertEqual(result.returncode, 0, result.stderr) + models = json.loads((self.directory / "catalog.json").read_text())["models"] + self.assertEqual( + [m["deviceIdentifier"] for m in models], + self.inputs["device_identifiers"] + ["apple,j700"], + ) + main, neo = models[0], models[-1] + self.assertEqual(main["payloadArtifact"]["fileName"], self.inputs["payload_name"]) + self.assertNotIn("parts", main["payloadArtifact"]) + self.assertEqual(neo["payloadArtifact"]["fileName"], "neo-os-package.zip") + self.assertEqual(neo["metadataArtifact"]["fileName"], "installer_data-neo.json") + self.assertEqual(len(neo["payloadArtifact"]["parts"]), 2) + self.assertEqual(neo["engineArtifact"], main["engineArtifact"]) + neo_digest = hashlib.sha256((self.assets / "neo-os-package.zip").read_bytes()).hexdigest() + self.assertEqual(neo["payloadDigest"], f"sha256:{neo_digest}") + + def test_developer_models_need_the_developer_flag(self) -> None: + self.assertRejected(self.with_neo(), "unknown keys") + + def test_a_developer_group_takes_only_developer_boards(self) -> None: + for identifier, fragment in ( + ("apple,j293", "lists apple,j293 twice"), + ("apple,j614s", "not a developer board"), + ("apple,j604", "not a developer board"), + ): + document = self.with_neo() + document["developer_models"][0]["device_identifiers"] = [identifier] + self.assertRejected(document, fragment, developer=True) + + def test_a_developer_group_reuses_no_file_name(self) -> None: + document = self.with_neo() + document["developer_models"][0]["metadata_name"] = self.inputs["metadata_name"] + self.assertRejected(document, "reuses the file name", developer=True) + + def test_the_main_group_still_covers_every_mac_in_a_developer_catalog(self) -> None: + document = self.with_neo() + document["device_identifiers"].remove("apple,j613") + self.assertRejected(document, "missing apple,j613", developer=True) + + def test_a_developer_catalog_never_passes_the_channel_check(self) -> None: + self.generate(self.with_neo(), developer=True) + check = subprocess.run( + [ + sys.executable, + str(SCRIPT.with_name("supported_models.py")), + "check-catalog", + str(self.directory / "catalog.json"), + ], + capture_output=True, + text=True, + ) + self.assertNotEqual(check.returncode, 0) + self.assertIn("apple,j700 is not in scripts/supported-models.json", check.stderr) + if __name__ == "__main__": unittest.main()