feat: Add GPU acceleration via Reims vGPU (#575)

This commit is contained in:
Kroese
2026-09-28 04:33:57 +02:00
committed by GitHub
parent 7cf19d1161
commit 90725f8d2c
27 changed files with 2232 additions and 173 deletions
@@ -1,19 +0,0 @@
{
"name": "macOS 10 (Catalina)",
"service": "macos",
"containerEnv": {
"VERSION": "10"
},
"forwardPorts": [8006],
"portsAttributes": {
"8006": {
"label": "Web",
"onAutoForward": "notify"
}
},
"otherPortsAttributes": {
"onAutoForward": "ignore"
},
"dockerComposeFile": "../codespaces.yml",
"initializeCommand": "docker system prune --all --force"
}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
uses: hadolint/[email protected]
with:
dockerfile: Dockerfile
ignore: DL3008,DL3018,DL3020,DL3067
ignore: DL3008,DL3018,DL3020,DL3067,DL4006
failure-threshold: warning
-
name: Validate JSON and YML files
+151
View File
@@ -0,0 +1,151 @@
name: Build install packages
on:
workflow_dispatch:
push:
branches:
- dev
paths:
- '.github/workflows/package.yml'
- 'src/install/admin/**'
- 'src/install/setup/**'
- 'assets/install/setup/**'
jobs:
package:
runs-on: macos-15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build install packages
shell: bash
run: |
set -Eeuo pipefail
work="$RUNNER_TEMP/macos-packages"
rm -rf "$work"
mkdir -p \
"$work/admin/root/private/tmp" \
"$work/admin/scripts" \
"$work/setup/root/private/var/db" \
"$work/setup/root/Library/User Template/English.lproj" \
"$work/setup/scripts" \
assets/install/package
# Match pycreateuserpkg's known-working startosinstall layout:
# a real (but effectively empty) payload plus the account logic in scripts.
chmod 1777 "$work/admin/root/private/tmp"
python3 src/install/admin/create.py \
"$work/admin/scripts" \
admin \
admin \
Y
cp src/install/admin/postinstall "$work/admin/scripts/postinstall"
chmod 0755 "$work/admin/scripts/postinstall" "$work/admin/scripts/config"
chmod 0600 "$work/admin/scripts/user.plist"
[ ! -f "$work/admin/scripts/kcpassword" ] || chmod 0600 "$work/admin/scripts/kcpassword"
python3 src/install/admin/validate.py \
"$work/admin/scripts/user.plist" \
admin
pkgbuild \
--root "$work/admin/root" \
--ownership recommended \
--identifier com.macos.install.admin \
--version 1.0 \
--scripts "$work/admin/scripts" \
"$work/admin/component.pkg"
productbuild \
--package "$work/admin/component.pkg" \
--identifier com.macos.install.admin \
--version 1.0 \
assets/install/package/admin.pkg
# Put the two critical Setup Assistant markers in the payload itself.
# The postinstall remains responsible for generating the version-specific
# SetupAssistant preference plist.
touch "$work/setup/root/private/var/db/.AppleSetupDone"
touch "$work/setup/root/Library/User Template/English.lproj/.skipbuddy"
chmod 0644 \
"$work/setup/root/private/var/db/.AppleSetupDone" \
"$work/setup/root/Library/User Template/English.lproj/.skipbuddy"
cp src/install/setup/postinstall "$work/setup/scripts/postinstall"
cp assets/install/setup/com.apple.SetupAssistant.plist \
"$work/setup/scripts/com.apple.SetupAssistant.plist"
chmod 0755 "$work/setup/scripts/postinstall"
chmod 0644 "$work/setup/scripts/com.apple.SetupAssistant.plist"
bash -n "$work/setup/scripts/postinstall"
pkgbuild \
--root "$work/setup/root" \
--ownership recommended \
--identifier com.macos.install.skipsetup \
--version 1.0 \
--scripts "$work/setup/scripts" \
"$work/setup/component.pkg"
productbuild \
--package "$work/setup/component.pkg" \
--identifier com.macos.install.skipsetup \
--version 1.0 \
assets/install/package/skipsetup.pkg
- name: Verify install packages
shell: bash
run: |
set -Eeuo pipefail
for package in admin skipsetup; do
file="assets/install/package/$package.pkg"
[ -s "$file" ]
listing=$(xar -tf "$file")
grep -Fxq 'Distribution' <<< "$listing"
grep -Fxq 'component.pkg/PackageInfo' <<< "$listing"
grep -Fxq 'component.pkg/Payload' <<< "$listing"
grep -Fxq 'component.pkg/Scripts' <<< "$listing"
dir="$RUNNER_TEMP/verify-$package"
rm -rf "$dir"
mkdir -p "$dir"
(cd "$dir" && xar -xf "$GITHUB_WORKSPACE/$file")
grep -Fq "<product id=\"com.macos.install.$package\"" \
"$dir/Distribution"
scripts_listing=$(gzip -dc "$dir/component.pkg/Scripts" | cpio -it 2>/dev/null)
grep -Fxq './postinstall' <<< "$scripts_listing"
payload_listing=$(gzip -dc "$dir/component.pkg/Payload" | cpio -it 2>/dev/null)
case "$package" in
admin)
grep -Fxq './private/tmp' <<< "$payload_listing"
;;
skipsetup)
grep -Fxq './private/var/db/.AppleSetupDone' <<< "$payload_listing"
grep -Fxq './Library/User Template/English.lproj/.skipbuddy' <<< "$payload_listing"
;;
esac
done
shasum -a 256 \
assets/install/package/admin.pkg \
assets/install/package/skipsetup.pkg
- name: Upload install packages
uses: actions/upload-artifact@v4
with:
name: macos-install-packages-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
path: |
assets/install/package/admin.pkg
assets/install/package/skipsetup.pkg
+35 -7
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.19
FROM scratch AS base
COPY --from=qemux/qemu:7.50 / /
COPY --from=qemux/qemu:7.50 --exclude=usr/bin/qemu-system-x86_64 / /
ARG VERSION_ARG="0.0"
ARG VERSION_VM_HIDE="2.0.0"
@@ -23,15 +23,39 @@ RUN <<EOF
apt-get update
apt-get --no-install-recommends -y install \
xar \
cpio \
gzip \
mtools \
xmlstarlet
libbz2-1.0 \
xmlstarlet \
spirv-tools \
vulkan-tools
apt-get clean
# Extract macserial
wget "$REPO_OPENCORE/releases/download/$VERSION_OPENCORE/OpenCore-$VERSION_OPENCORE-RELEASE.zip" -O /tmp/opencore.zip -q --timeout=30
unzip -p /tmp/opencore.zip Utilities/macserial/macserial.linux > /usr/local/bin/macserial
# Keep matching official RELEASE and DEBUG EFI trees so runtime can switch
# between normal booting and OpenCore diagnostics without another image build.
for build in RELEASE DEBUG; do
flavour=$(printf '%s' "$build" | tr '[:upper:]' '[:lower:]')
archive="/tmp/opencore-$flavour.zip"
extract="/tmp/opencore-$flavour"
wget "$REPO_OPENCORE/releases/download/$VERSION_OPENCORE/OpenCore-$VERSION_OPENCORE-$build.zip" \
-O "$archive" -q --timeout=30
unzip -q "$archive" 'X64/EFI/*' -d "$extract"
mkdir -p "/opencore/$flavour"
cp -a "$extract/X64/EFI" "/opencore/$flavour/EFI"
[ -s "/opencore/$flavour/EFI/BOOT/BOOTx64.efi" ]
[ -s "/opencore/$flavour/EFI/OC/OpenCore.efi" ]
done
# Extract macserial from the matching official release.
unzip -p /tmp/opencore-release.zip Utilities/macserial/macserial.linux > /usr/local/bin/macserial
chmod 755 /usr/local/bin/macserial
printf '%s\n' "$VERSION_OPENCORE" > /opencore/version
# Set version file
echo "$VERSION_ARG" > /etc/version
@@ -41,6 +65,10 @@ EOF
COPY --chmod=755 ./src /run/
COPY --chmod=755 ./assets /assets/
COPY --from=qemux/qemu-macos:latest /usr/bin/qemu-system-x86_64 /usr/bin/
COPY --from=qemux/qemu-macos:latest /usr/share/qemu/reims-vgpu-gop.rom /usr/share/qemu/
ADD --chmod=755 https://github.com/qemus/qemu-macos/releases/latest/download/air-dis /usr/local/bin/air-dis
ADD --chmod=644 \
$REPO_OSX_KVM/$VERSION_OSX_KVM/OVMF_CODE.fd \
@@ -56,7 +84,7 @@ EXPOSE 22 5900 8006
ENV VERSION="14"
ENV RAM_SIZE="4G"
ENV CPU_CORES="1"
ENV CPU_CORES="2"
ENV DISK_SIZE="64G"
ENTRYPOINT ["/usr/bin/tini", "-s", "/run/entry.sh"]
+1 -1
View File
@@ -1394,7 +1394,7 @@
<key>ReloadOptionRoms</key>
<false/>
<key>RequestBootVarRouting</key>
<false/>
<true/>
<key>ResizeGpuBars</key>
<integer>-1</integer>
<key>ResizeUsePciRbIo</key>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="1">
<pkg-ref id="@IDENTIFIER@">
<bundle-version/>
</pkg-ref>
<options customize="never" require-scripts="false"/>
<choices-outline>
<line choice="default">
<line choice="@IDENTIFIER@"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="@IDENTIFIER@" visible="false">
<pkg-ref id="@IDENTIFIER@"/>
</choice>
<pkg-ref id="@IDENTIFIER@" version="1.0" onConclusion="none" installKBytes="0">#@COMPONENT@</pkg-ref>
<product id="@IDENTIFIER@" version="1.0"/>
</installer-gui-script>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<pkg-info postinstall-action="none" preserve-xattr="false" format-version="2" identifier="@IDENTIFIER@" version="1.0" install-location="/" auth="root">
<payload installKBytes="0" numberOfFiles="0"/>
<bundle-version/>
<upgrade-bundle/>
<update-bundle/>
<atomic-update-bundle/>
<strict-identifier/>
<relocate/>
<scripts>
<postinstall file="./postinstall"/>
</scripts>
</pkg-info>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DidSeeAccessibility</key>
<true/>
<key>DidSeeActivationLock</key>
<false/>
<key>DidSeeAppStore</key>
<false/>
<key>DidSeeAppearanceSetup</key>
<true/>
<key>DidSeeApplePaySetup</key>
<false/>
<key>DidSeeAvatarSetup</key>
<false/>
<key>DidSeeCloudSetup</key>
<true/>
<key>DidSeePrivacy</key>
<true/>
<key>DidSeeScreenTime</key>
<true/>
<key>DidSeeSiriSetup</key>
<true/>
<key>DidSeeSyncSetup</key>
<false/>
<key>DidSeeSyncSetup2</key>
<false/>
<key>DidSeeTouchIDSetup</key>
<false/>
<key>DidSeeTrueTone</key>
<true/>
<key>DidSeeiCloudLoginForStorageServices</key>
<true/>
<key>LastPreLoginTasksPerformedBuild</key>
<string>@OS_BUILD@</string>
<key>LastPreLoginTasksPerformedVersion</key>
<string>@OS_VERSION@</string>
<key>LastPrivacyBundleVersion</key>
<string>2</string>
<key>LastSeenBuddyBuildVersion</key>
<string>@OS_BUILD@</string>
<key>LastSeenCloudProductVersion</key>
<string>@OS_VERSION@</string>
<key>LastSeenDiagnosticsProductVersion</key>
<string>@OS_VERSION@</string>
<key>LastSeenSiriProductVersion</key>
<string>@OS_VERSION@</string>
<key>MiniBuddyLaunchReason</key>
<integer>0</integer>
<key>MiniBuddyLaunchedPostMigration</key>
<false/>
<key>MiniBuddyShouldLaunchToResumeSetup</key>
<false/>
<key>NSAddServicesToContextMenus</key>
<false/>
<key>PreviousBuildVersion</key>
<string>0</string>
<key>PreviousSystemVersion</key>
<string>0</string>
<key>SkipFirstLoginOptimization</key>
<false/>
</dict>
</plist>
+3 -4
View File
@@ -19,7 +19,7 @@ An empty default means the variable is unset and its value is determined automat
| Variable | Default | Description |
|---|---|---|
| `CPU_CORES` | `1` | Number of virtual CPU cores, such as `4`, `half`, or `max`. |
| `CPU_CORES` | `2` | Number of virtual CPU cores, such as `4`, `half`, or `max`. |
| `CPU_MODEL` | | QEMU CPU model. Selected automatically for Intel or AMD hosts when unset. |
| `CPU_FLAGS` | | Additional QEMU CPU flags. |
| `SMP` | | Custom CPU topology. Determined from `CPU_CORES` when unset. |
@@ -72,11 +72,10 @@ An empty default means the variable is unset and its value is determined automat
| Variable | Default | Description |
|---|---|---|
| `DISPLAY` | `web` | Display backend, such as `web`, `vnc`, `disabled`, or `none`. |
| `VGA` | `vmware` | QEMU video adapter model. |
| `VGA` | `vmware` | QEMU video adapter model used when `GPU=N`. |
| `WIDTH` | `1920` | Display width configured for macOS and OpenCore. |
| `HEIGHT` | `1080` | Display height configured for macOS and OpenCore. |
| `GPU` | `N` | Enables experimental GPU acceleration. |
| `RENDERNODE` | `/dev/dri/renderD128` | Render node used for GPU acceleration. |
| `GPU` | `N` | Enables hardware-accelerated graphics through Reims vGPU. |
## 🌍 Web UI
+34 -6
View File
@@ -17,10 +17,10 @@ MacOS inside a Docker container.
## Features ✨
- Runs macOS inside a Docker container
- Automatic download of the installation files
- Web-based viewer for controlling the VM
- Automatic download and hands-free installation
- Near-native performance with KVM acceleration
- Customizable CPU, memory, and storage allocation
- Hardware-accelerated graphics via [Reims vGPU](https://github.com/steelbrain/reims-vgpu)
- Dynamic memory allocation with memory ballooning
- USB passthrough and host folder sharing
- Supports NAT, user-mode, macvlan, and macvtap networking
@@ -163,7 +163,7 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/macos/refs/heads/maste
### How do I change the amount of CPU or RAM?
By default, macOS will be allowed to use a single CPU core and 4 GB of RAM.
By default, macOS will be allowed to use 2 CPU cores and 4 GB of RAM.
If you want to adjust this, you can specify the desired amount using the following environment variables:
@@ -174,9 +174,7 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/macos/refs/heads/maste
```
> [!IMPORTANT]
> On AMD systems, avoid assigning multiple CPU cores or more than 8 GB of RAM initially. Depending on the specific AMD CPU model, multiple cores may reduce performance or cause instability, while more than 8 GB of RAM may cause the installation to freeze at the country selection step. Increase the RAM only after installation, and the core count only after macOS has been running reliably for several hours.
>
> Intel processors offer much better macOS compatibility, so multiple cores and more RAM can be assigned from the start without causing these issues.
> On AMD systems, do not assign more than 8 GB of RAM during the initial installation process.
### How do I enable audio?
@@ -189,6 +187,36 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/macos/refs/heads/maste
Then enable **Audio** under **Settings → Advanced** in the web viewer. The stream is only active while this option is enabled, so it uses no extra bandwidth otherwise.
### How do I enable GPU acceleration?
To enable [Reims vGPU](https://github.com/steelbrain/reims-vgpu) hardware-accelerated graphics using an Intel or AMD GPU, add the following lines to your compose file:
```yaml
environment:
GPU: "Y"
devices:
- /dev/dri
```
For NVIDIA GPUs, the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) must be installed on the host and the GPU must be exposed to the container:
```yaml
environment:
GPU: "Y"
NVIDIA_DRIVER_CAPABILITIES: "all"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities:
- gpu
```
Reims vGPU uses macOS's built-in `AppleParavirtGPU` driver and translates Metal graphics commands to Vulkan on the Linux host, so no additional graphics driver needs to be installed inside macOS.
### How do I share files with the host?
To share files with the host, add the following volume to your compose file:
+58 -22
View File
@@ -137,34 +137,59 @@ addOvmfOptions() {
return 0
}
openCoreSource() {
local flavour="release"
enabled "$OPENCORE_DEBUG" && flavour="debug"
echo "/opencore/$flavour/EFI"
return 0
}
extractOpenCore() {
# OpenCoreBoot
ISO="/opencore.iso"
OUT="/tmp/extract"
local source template
source=$(openCoreSource)
template="$OUT/template/EFI_RELEASE/EFI"
rm -rf "$OUT"
mkdir -p "$OUT"
mkdir -p "$OUT/template"
msg="Extracting OpenCore boot image"
info "$msg..." && html "$msg..."
# Extract image file
if [ ! -d "$source" ]; then
error "Could not find official OpenCore EFI tree \"$source\"." && exit 10
fi
if [ ! -s "$ISO" ]; then
error "Could not find image file \"$ISO\"." && exit 10
fi
if ! 7z x "$ISO" -o"$OUT" > /dev/null; then
if ! 7z x "$ISO" -o"$OUT/template" > /dev/null; then
error "Failed to extract archive!" && exit 11
fi
# The bundled image supplies OpenCore binaries, but its config is replaced
# with the project template containing this VM's generated identity.
# Overwrite extracted OpenCore config with our own
CFG="$(find "$OUT" -type f -path '*/EFI/OC/config.plist' -print -quit)"
[ -z "${CFG:-}" ] && error "Could not locate extracted OpenCore config.plist under \"$OUT\"." && exit 12
if [ ! -d "$template/OC/ACPI" ] ||
[ ! -d "$template/OC/Kexts" ] ||
[ ! -d "$template/OC/Resources" ]; then
error "LongQT OpenCore template does not contain the expected EFI_RELEASE tree!" && exit 12
fi
EFI_DIR="${CFG%/OC/config.plist}"
EFI_DIR="$OUT/EFI"
cp -a "$source" "$EFI_DIR"
# Never mix old OpenCore executables/drivers with the official tree.
# Only carry over VM-specific data that OpenCore itself does not provide.
rm -rf "$EFI_DIR/OC/ACPI" "$EFI_DIR/OC/Kexts" "$EFI_DIR/OC/Resources"
cp -a "$template/OC/ACPI" "$EFI_DIR/OC/"
cp -a "$template/OC/Kexts" "$EFI_DIR/OC/"
cp -a "$template/OC/Resources" "$EFI_DIR/OC/"
CFG="$EFI_DIR/OC/config.plist"
return 0
}
@@ -179,10 +204,6 @@ checkOpenCoreFiles() {
error "Missing OpenCore.efi!" && exit 12
fi
if [ ! -s "$EFI_DIR/OC/config.plist" ]; then
error "Missing OpenCore config.plist!" && exit 12
fi
if [ ! -d "$EFI_DIR/OC/Drivers" ]; then
error "Missing OpenCore Drivers directory!" && exit 12
fi
@@ -191,6 +212,10 @@ checkOpenCoreFiles() {
error "Missing OpenCore Kexts directory!" && exit 12
fi
if [ ! -d "$EFI_DIR/OC/Resources" ]; then
error "Missing OpenCore Resources directory!" && exit 12
fi
return 0
}
@@ -213,6 +238,7 @@ configureOpenCorePlist() {
local generic="/plist/dict/key[.='PlatformInfo']/following-sibling::dict[1]/key[.='Generic']/following-sibling::dict[1]"
local output="/plist/dict/key[.='UEFI']/following-sibling::dict[1]/key[.='Output']/following-sibling::dict[1]"
local boot="/plist/dict/key[.='Misc']/following-sibling::dict[1]/key[.='Boot']/following-sibling::dict[1]"
local debug="/plist/dict/key[.='Misc']/following-sibling::dict[1]/key[.='Debug']/following-sibling::dict[1]"
xmlstarlet ed -P -L \
-u "$generic/key[.='ROM']/following-sibling::data[1]" -v "$brom" \
@@ -223,6 +249,15 @@ configureOpenCorePlist() {
-u "$output/key[.='Resolution']/following-sibling::string[1]" -v "$resolution" \
"$CFG"
# DEBUG logging goes only to the OpenCore log file on the EFI partition.
# Target 65 = logging (0x01) + file (0x40), deliberately excluding screen.
if enabled "$OPENCORE_DEBUG"; then
xmlstarlet ed -P -L \
-u "$debug/key[.='DisplayLevel']/following-sibling::integer[1]" -v 2147483714 \
-u "$debug/key[.='Target']/following-sibling::integer[1]" -v 65 \
"$CFG"
fi
# Show boot picker if requested
# Showing the picker also exposes auxiliary entries and extends the timeout
# so recovery and maintenance choices remain selectable.
@@ -402,12 +437,14 @@ printMachineDetails() {
openCoreSignature() {
local opencore config vmhide
local opencore template config vmhide source
local plist="/assets/config.plist"
[ -f "/custom.plist" ] && plist="/custom.plist"
source=$(openCoreSource)
opencore=$(sha256sum /opencore.iso | awk '{print $1}') || return 1
opencore=$(find "$source" -type f -exec sha256sum {} + | sort | sha256sum | awk '{print $1}') || return 1
template=$(sha256sum /opencore.iso | awk '{print $1}') || return 1
config=$(sha256sum "$plist" | awk '{print $1}') || return 1
vmhide=$(sha256sum /vmh.zip | awk '{print $1}') || return 1
@@ -423,6 +460,8 @@ openCoreSignature() {
echo "HEIGHT=$HEIGHT"
echo "PICKER=$PICKER"
echo "OPENCORE=$opencore"
echo "TEMPLATE=$template"
echo "DEBUG=$OPENCORE_DEBUG"
echo "PLIST=$config"
echo "VMHIDE=$vmhide"
} | sha256sum | awk '{print $1}'
@@ -435,6 +474,8 @@ prepareOpenCoreImage() {
local target="$STORAGE/boot.img"
local current previous signature
OPENCORE_DEBUG="$DEBUG"
signature=$(stateFile "sig" "boot") || exit 11
current=$(openCoreSignature)
previous=$(readState "sig" "boot") || exit 11
@@ -522,11 +563,6 @@ prepareOpenCoreImage
setOwner "$IMG" || error "Failed to set the owner for \"$IMG\" !"
BOOT_DRIVE_ID="OpenCore"
# OpenCore is immutable at runtime; persistent boot choices live in OVMF
# NVRAM, so attach the generated boot disk read-only.
DISK_OPTS+=" -device virtio-blk-pci,drive=${BOOT_DRIVE_ID},bus=pcie.0,addr=0x5,bootindex=$BOOT_INDEX"
DISK_OPTS+=" -drive file=$IMG,id=$BOOT_DRIVE_ID,format=raw,cache=unsafe,readonly=on,if=none"
DISK_OPTS+=$(createDevice "$IMG" "blk" "$BOOT_INDEX" "0x5" "raw" "" "" "OpenCore")
return 0
+1 -7
View File
@@ -135,13 +135,7 @@ checkClocksource() {
result="${result//[![:print:]]/}"
case "${result,,}" in
# A single-vCPU Intel profile is promoted to two when host timing is
# suitable, avoiding macOS issues with the one-core topology.
"${CLOCKSOURCE,,}" )
if ! needsAmdCpuProfile && [[ "$CPU_CORES" == "1" ]]; then
CPU_CORES="2"
fi
;;
"${CLOCKSOURCE,,}" ) ;;
"kvm-clock" )
warn "Nested KVM virtualization detected, this might cause issues running macOS!"
;;
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Docker environment variables
: "${GPU:="N"}" # GPU acceleration
: "${VGA:="vmware"}" # VGA adapter
: "${DISPLAY:="web"}" # Display type
: "${LOSSY:="N"}" # Lossy VNC compression
: "${VNC_PORT:="5900"}" # VNC port
# Sanitize variables
VGA=$(strip "$VGA")
LOSSY=$(strip "$LOSSY")
DISPLAY=$(strip "$DISPLAY")
VNC_PORT=$(strip "$VNC_PORT")
WSS_SOCKET="${WSS_SOCKET:-$QEMU_DIR/vnc-ws.sock}"
VGA_DEVICE="${VGA%%,*}"
VGA_OPTIONS="${VGA#"$VGA_DEVICE"}"
case "${VGA_DEVICE,,}" in
"std" | "vga" )
VGA_DEVICE="VGA"
VGA_ARG="-device" ;;
"vmware" | "vmware-svga" )
VGA_DEVICE="vmware-svga"
VGA_ARG="-device" ;;
"virtio" )
VGA_DEVICE="virtio-vga"
VGA_ARG="-device" ;;
"virtio-"* )
VGA_DEVICE="${VGA_DEVICE,,}"
VGA_ARG="-device" ;;
* )
VGA_ARG="-vga" ;;
esac
VGA="${VGA_DEVICE}${VGA_OPTIONS}"
VGA_ARG+=" ${VGA}"
# QEMU accepts a VNC display number rather than a TCP port,
# so translate the configured port back to its :N display index.
port=$(( VNC_PORT - 5900 ))
LOSSY_OPT=""
enabled "$LOSSY" && LOSSY_OPT=",lossy=on"
# Preserve the historic :0 setting as an alias for the managed web display.
[[ "$DISPLAY" == ":0" ]] && DISPLAY="web"
case "${DISPLAY,,}" in
"vnc" )
DISPLAY_OPTS="-display vnc=:${port}${LOSSY_OPT} ${VGA_ARG}" ;;
"web" )
DISPLAY_OPTS="-display vnc=:${port},websocket=unix:${WSS_SOCKET}${LOSSY_OPT} ${VGA_ARG}" ;;
"disabled" )
DISPLAY_OPTS="-display none ${VGA_ARG}" ;;
"none" )
DISPLAY_OPTS="-display none -vga none" ;;
*)
DISPLAY_OPTS="-display ${DISPLAY} ${VGA_ARG}" ;;
esac
enabled "$GPU" || return 0
msg="Configuring Reims vGPU..."
enabled "$DEBUG" && echo "$msg"
export METAL2VULKAN_LLVM_DIS="/usr/local/bin/air-dis"
if [ ! -d /dev/dri ]; then
error "GPU acceleration was requested, but '/dev/dri' was not added to the devices section of your compose file."
exit 72
fi
RENDER_NODE=""
for node in /dev/dri/renderD*; do
[ -c "$node" ] || continue
gpu_fd=""
if ! { exec {gpu_fd}<>"$node"; } 2>/dev/null; then
continue
fi
{ exec {gpu_fd}>&-; } 2>/dev/null || true
RENDER_NODE="$node"
break
done
if [ -z "$RENDER_NODE" ]; then
error "GPU acceleration was requested, but no accessible DRM render node was found in '/dev/dri'."
exit 72
fi
if ! command -v vulkaninfo >/dev/null 2>&1; then
error "GPU acceleration was requested, but 'vulkaninfo' is not available in the container."
exit 72
fi
VULKAN_SUMMARY=""
if ! VULKAN_SUMMARY="$(vulkaninfo --summary 2>&1)"; then
enabled "$DEBUG" && printf '%s\n' "$VULKAN_SUMMARY"
error "GPU acceleration was requested, but Vulkan device enumeration failed."
exit 72
fi
# Reims requires Vulkan 1.2 and rejects devices below that API floor. Mirror its
# device policy here: CPU/software Vulkan does not count as hardware rendering,
# while discrete, integrated, virtual and other non-CPU devices are eligible.
if ! awk '
function check_device() {
if (!in_device || type == "" || major < 0 || minor < 0) {
return
}
if (type != "PHYSICAL_DEVICE_TYPE_CPU" &&
(major > 1 || (major == 1 && minor >= 2))) {
compatible = 1
}
}
/^GPU[0-9]+:/ {
check_device()
in_device = 1
major = -1
minor = -1
type = ""
next
}
in_device && /^[[:space:]]*apiVersion[[:space:]]*=/ {
value = $0
sub(/^.*=[[:space:]]*/, "", value)
split(value, version, ".")
major = version[1] + 0
minor = version[2] + 0
next
}
in_device && /^[[:space:]]*deviceType[[:space:]]*=/ {
type = $0
sub(/^.*=[[:space:]]*/, "", type)
sub(/[[:space:]].*$/, "", type)
next
}
END {
check_device()
exit compatible ? 0 : 1
}
' <<< "$VULKAN_SUMMARY"; then
enabled "$DEBUG" && printf '%s\n' "$VULKAN_SUMMARY"
error "GPU acceleration was requested, but no Vulkan 1.2+ device is available."
exit 72
fi
# Vulkan 1.2 promotes 8-bit storage and viewport-index output into core feature
# structures, but support for both remains optional. Reims enables and relies on
# these capabilities when translating the guest Metal command stream, so verify
# them on the same Vulkan 1.2+ non-CPU device accepted above.
VULKAN_DETAILS=""
if ! VULKAN_DETAILS="$(env -u DISPLAY -u WAYLAND_DISPLAY vulkaninfo 2>&1)"; then
enabled "$DEBUG" && printf '%s\n' "$VULKAN_DETAILS"
error "GPU acceleration was requested, but Vulkan capability enumeration failed."
exit 72
fi
if ! awk '
function check_device() {
if (!in_device || type == "" || major < 0 || minor < 0) {
return
}
if (type != "PHYSICAL_DEVICE_TYPE_CPU" &&
(major > 1 || (major == 1 && minor >= 2)) &&
storage8 == "true" && viewport_index == "true") {
compatible = 1
}
}
/^GPU[0-9]+:/ {
check_device()
in_device = 1
major = -1
minor = -1
type = ""
storage8 = ""
viewport_index = ""
next
}
in_device && /^[[:space:]]*apiVersion[[:space:]]*=/ {
value = $0
sub(/^.*=[[:space:]]*/, "", value)
split(value, version, ".")
major = version[1] + 0
minor = version[2] + 0
next
}
in_device && /^[[:space:]]*deviceType[[:space:]]*=/ {
type = $0
sub(/^.*=[[:space:]]*/, "", type)
sub(/[[:space:]].*$/, "", type)
next
}
in_device && /^[[:space:]]*storageBuffer8BitAccess[[:space:]]*=/ {
storage8 = $0
sub(/^.*=[[:space:]]*/, "", storage8)
sub(/[[:space:]].*$/, "", storage8)
next
}
in_device && /^[[:space:]]*shaderOutputViewportIndex[[:space:]]*=/ {
viewport_index = $0
sub(/^.*=[[:space:]]*/, "", viewport_index)
sub(/[[:space:]].*$/, "", viewport_index)
next
}
END {
check_device()
exit compatible ? 0 : 1
}
' <<< "$VULKAN_DETAILS"; then
enabled "$DEBUG" && printf '%s\n' "$VULKAN_DETAILS"
error "GPU acceleration was requested, but no Vulkan 1.2+ device provides Reims' required storageBuffer8BitAccess and shaderOutputViewportIndex features."
exit 72
fi
REIMS_ROM="/usr/share/qemu/reims-vgpu-gop.rom"
if [ ! -s "$REIMS_ROM" ]; then
error "Reims GOP ROM is missing at '$REIMS_ROM'."
exit 72
fi
# Reims maps guest RAM into its Vulkan backend, so the memory must be backed by
# a shared memfd. config.sh is sourced later and turns this into the matching
# memory-backend-memfd object plus machine memory-backend property.
RAM_BACKEND="memfd"
# The upstream x86 product path keeps Reims behind a secondary conventional PCI
# bridge. The GOP ROM belongs to this same device; it is not a second display.
REIMS_OPTS="-vga none"
REIMS_OPTS+=" -device pci-bridge,chassis_nr=5,id=pci.5,bus=pcie.0,addr=1e.0"
REIMS_OPTS+=" -device reims-vgpu-pci,id=reimsvgpu,romfile=${REIMS_ROM},rombar=1,bus=pci.5,addr=01.0"
case "${DISPLAY,,}" in
"vnc" )
DISPLAY_OPTS="-display none ${REIMS_OPTS} -vnc :${port}${LOSSY_OPT}" ;;
"web" )
DISPLAY_OPTS="-display none ${REIMS_OPTS} -vnc :${port},websocket=unix:${WSS_SOCKET}${LOSSY_OPT}" ;;
"disabled" | "none" )
DISPLAY_OPTS="-display none ${REIMS_OPTS}" ;;
* )
DISPLAY_OPTS="-display ${DISPLAY} ${REIMS_OPTS}" ;;
esac
echo
info "Hardware rendering enabled successfully. Beware that this feature is still experimental!"
enabled "$DEBUG" && echo && printf '%s\n' "$VULKAN_DETAILS"
return 0
+2 -1
View File
@@ -22,7 +22,8 @@ cd /run
. memory.sh # Check available memory
. server.sh # Start webserver
. download.sh # Load functions
. install.sh # Get the OSX images
. image.sh # Prepare image
. install.sh # Run installation
. disk.sh # Initialize disks
. display.sh # Initialize graphics
. audio.sh # Initialize audio
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env bash
set -Eeuo pipefail
IMAGE_TOOLS="/run/install"
IMAGE_ASSETS="/assets/install"
checkDmgImage() {
local file="$1"
local size
if [ ! -s "$file" ]; then
error "Downloaded recovery image is missing or empty!"
return 1
fi
size=$(stat -c%s "$file")
if [ "$size" -lt 100000000 ]; then
error "Downloaded recovery image is too small: $(formatBytes "$size")"
return 1
fi
info "Checking recovery image format..."
# qemu-img validates the disk container structure without mounting or
# trusting filesystems inside the downloaded recovery image.
if ! qemu-img info "$file" >/dev/null; then
error "Downloaded recovery image is not a valid disk image!"
return 1
fi
return 0
}
buildProductPackage() {
local identifier="$1"
local scripts="$2"
local dest="$3"
local work source_file
local outer_listing scripts_listing
work=$(mktemp -d "$STORAGE/tmp/package.XXXXXX") || return 1
local component_name="component.pkg"
local component="$work/$component_name"
local verify="$work/verify"
mkdir -p "$component" "$verify"
# Match the payload-free component layout produced by Linux macOS package
# generators: PackageInfo and Scripts live below component.pkg in the same
# outer XAR as Distribution. component.pkg is not a nested XAR.
if ! /usr/bin/sed \
-e "s|@IDENTIFIER@|$identifier|g" \
"$IMAGE_ASSETS/package/PackageInfo" > "$component/PackageInfo"; then
rm -rf "$work"
error "Failed to prepare PackageInfo for $identifier."
return 1
fi
if ! (
cd "$scripts"
find . -print |
cpio -o --format odc --owner 0:80 2>/dev/null |
gzip -c > "$component/Scripts"
); then
rm -rf "$work"
error "Failed to create scripts archive for $identifier."
return 1
fi
if ! /usr/bin/sed \
-e "s|@IDENTIFIER@|$identifier|g" \
-e "s|@COMPONENT@|$component_name|g" \
"$IMAGE_ASSETS/package/Distribution" > "$work/Distribution"; then
rm -rf "$work"
error "Failed to prepare Distribution for $identifier."
return 1
fi
rm -f "$dest"
if ! (
cd "$work"
xar --compression none -cf "$dest" Distribution "$component_name"
); then
rm -rf "$work"
error "Failed to build product package $dest."
return 1
fi
if ! outer_listing=$(xar -tf "$dest" 2>/dev/null); then
rm -rf "$work" "$dest"
error "Failed to inspect product package $dest."
return 1
fi
for name in \
Distribution \
"$component_name/PackageInfo" \
"$component_name/Scripts"; do
if ! grep -Fxq "$name" <<< "$outer_listing"; then
rm -rf "$work" "$dest"
error "Product package $dest is missing $name."
return 1
fi
done
if ! (
cd "$verify"
xar -xf "$dest"
); then
rm -rf "$work" "$dest"
error "Failed to extract product package $dest for verification."
return 1
fi
if ! python3 "$IMAGE_TOOLS/package/validate.py" \
"$verify/Distribution" \
"$verify/$component_name/PackageInfo" \
"$identifier"; then
rm -rf "$work" "$dest"
error "Product package $dest failed XML validation."
return 1
fi
if ! scripts_listing=$(
gzip -dc "$verify/$component_name/Scripts" 2>/dev/null |
cpio -it 2>/dev/null
); then
rm -rf "$work" "$dest"
error "Failed to inspect the scripts archive in $dest."
return 1
fi
if ! grep -Fxq './postinstall' <<< "$scripts_listing"; then
rm -rf "$work" "$dest"
error "Product package $dest does not contain its postinstall script."
return 1
fi
mkdir -p "$verify/scripts"
if ! (
cd "$verify/scripts"
gzip -dc "$verify/$component_name/Scripts" 2>/dev/null |
cpio -idmu 2>/dev/null
); then
rm -rf "$work" "$dest"
error "Failed to extract the scripts archive in $dest."
return 1
fi
for source_file in "$scripts"/*; do
[ -f "$source_file" ] || continue
local name="${source_file##*/}"
local packaged_file="$verify/scripts/$name"
if [ ! -f "$packaged_file" ] || ! cmp -s "$source_file" "$packaged_file"; then
rm -rf "$work" "$dest"
error "Packaged script data $name failed byte-for-byte validation."
return 1
fi
local source_mode packaged_mode
source_mode=$(stat -c '%a' "$source_file")
packaged_mode=$(stat -c '%a' "$packaged_file")
if [ "$source_mode" != "$packaged_mode" ]; then
rm -rf "$work" "$dest"
error "Packaged script data $name has mode $packaged_mode instead of $source_mode."
return 1
fi
done
rm -rf "$work"
return 0
}
createAdminPackage() {
local dest="$1"
local msg="Building unattended setup packages..."
info "$msg"
local work
work=$(mktemp -d "$STORAGE/tmp/admin.XXXXXX") || return 1
local scripts="$work/scripts"
mkdir -p "$scripts"
if ! python3 "$IMAGE_TOOLS/admin/create.py" \
"$scripts" \
"$SETUP_USERNAME" \
"$SETUP_PASSWORD" \
"$SETUP_AUTOLOGIN"; then
rm -rf "$work"
error "Failed to create unattended account data."
return 1
fi
if ! cp -f "$IMAGE_TOOLS/admin/postinstall" "$scripts/postinstall"; then
rm -rf "$work"
error "Failed to prepare unattended account package script."
return 1
fi
chmod 0755 "$scripts/postinstall" "$scripts/config"
chmod 0600 "$scripts/user.plist"
[ ! -f "$scripts/kcpassword" ] || chmod 0600 "$scripts/kcpassword"
if ! bash -n "$scripts/postinstall"; then
rm -rf "$work"
error "Generated account package script is invalid."
return 1
fi
if ! python3 "$IMAGE_TOOLS/admin/validate.py" \
"$scripts/user.plist" "$SETUP_USERNAME"; then
rm -rf "$work"
error "Generated account record failed validation."
return 1
fi
if ! buildProductPackage "com.macos.install.admin" "$scripts" "$dest"; then
rm -rf "$work"
return 1
fi
rm -rf "$work"
return 0
}
createSkipSetupPackage() {
local dest="$1"
local work scripts
work=$(mktemp -d "$STORAGE/tmp/skipsetup.XXXXXX") || return 1
scripts="$work/scripts"
mkdir -p "$scripts"
if ! cp -f "$IMAGE_TOOLS/setup/postinstall" "$scripts/postinstall" ||
! cp -f "$IMAGE_ASSETS/setup/com.apple.SetupAssistant.plist" \
"$scripts/com.apple.SetupAssistant.plist"; then
rm -rf "$work"
error "Failed to prepare Setup Assistant package scripts."
return 1
fi
chmod 0755 "$scripts/postinstall"
chmod 0644 "$scripts/com.apple.SetupAssistant.plist"
if ! bash -n "$scripts/postinstall"; then
rm -rf "$work"
error "Generated Setup Assistant package script is invalid."
return 1
fi
if ! buildProductPackage "com.macos.install.skipsetup" "$scripts" "$dest"; then
rm -rf "$work"
return 1
fi
rm -rf "$work"
return 0
}
createAutomatedInstallationFiles() {
local script="$1"
if ! cp -f "$IMAGE_TOOLS/recovery/launch.sh" "$script"; then
error "Failed to prepare automated Recovery files."
return 1
fi
chmod 0755 "$script"
return 0
}
prepareInstallationState() {
local state="$1"
local script="$state/launch.sh"
local admin="$state/admin.pkg"
local setup="$state/skipsetup.pkg"
local firstboot="$state/firstboot.sh"
local daemon="$state/com.dockur.macos.firstboot.plist"
rm -rf "$state"
if ! makeDir "$state"; then
error "Failed to create unattended installation state directory."
return 1
fi
if ! createAutomatedInstallationFiles "$script" ||
! cp -f "$IMAGE_ASSETS/package/admin.pkg" "$admin" ||
! cp -f "$IMAGE_ASSETS/package/skipsetup.pkg" "$setup" ||
! cp -f "$IMAGE_TOOLS/firstboot/launch.sh" "$firstboot" ||
! cp -f "$IMAGE_TOOLS/firstboot/com.dockur.macos.firstboot.plist" "$daemon"; then
rm -rf "$state"
return 1
fi
chmod 0755 "$script" "$firstboot"
chmod 0644 "$admin" "$setup" "$daemon"
if ! cmp -s "$IMAGE_TOOLS/recovery/launch.sh" "$script" ||
! cmp -s "$IMAGE_ASSETS/package/admin.pkg" "$admin" ||
! cmp -s "$IMAGE_ASSETS/package/skipsetup.pkg" "$setup" ||
! cmp -s "$IMAGE_TOOLS/firstboot/launch.sh" "$firstboot" ||
! cmp -s "$IMAGE_TOOLS/firstboot/com.dockur.macos.firstboot.plist" "$daemon" ||
[ ! -s "$admin" ] || [ ! -s "$setup" ]; then
rm -rf "$state"
error "Staged unattended installation files failed validation."
return 1
fi
if ! bash -n "$firstboot"; then
rm -rf "$state"
error "First-boot account setup script is invalid."
return 1
fi
if ! python3 -c 'import plistlib, sys; plistlib.load(open(sys.argv[1], "rb"))' "$daemon"; then
rm -rf "$state"
error "First-boot LaunchDaemon plist is invalid."
return 1
fi
return 0
}
patchRecoveryBootstrap() {
local image="$1"
local result
if ! result=$(python3 "$IMAGE_TOOLS/recovery/patch.py" "$image"); then
error "Failed to patch the Recovery startup hook."
return 1
fi
info "$result"
return 0
}
prepareAutomatedRecovery() {
local source="$1"
local dest="$2"
local qemu_info
local msg="Preparing automated installation..."
info "$msg" && html "$msg"
if ! patchRecoveryBootstrap "$source"; then
return 1
fi
# The source keeps its .dmg suffix while being patched, so QEMU can probe
# the exact final bytes using its DMG driver before the file is moved.
if ! qemu_info=$(qemu-img info --output=json "$source" 2>/dev/null); then
error "Patched recovery image is not recognized by QEMU."
return 1
fi
if ! python3 -c '
import json, sys
info = json.load(sys.stdin)
raise SystemExit(0 if info.get("format") == "dmg" else 1)
' <<< "$qemu_info"; then
error "QEMU did not identify the patched recovery image as DMG."
return 1
fi
if ! mv -f "$source" "$dest"; then
error "Failed to save automated recovery image to $dest."
return 1
fi
return 0
}
return 0
+69 -105
View File
@@ -11,6 +11,7 @@ set -Eeuo pipefail
: "${WIDTH:="1920"}" # Horizontal
: "${HEIGHT:="1080"}" # Vertical
: "${MODEL:="iMacPro1,1"}" # Device model
: "${MANUAL:="Y"}" # Manual installation
# Sanitize variables
SN=$(strip "$SN")
@@ -21,8 +22,18 @@ MODEL=$(strip "$MODEL")
WIDTH=$(strip "$WIDTH")
HEIGHT=$(strip "$HEIGHT")
BASE_IMG_ID="InstallMedia"
BASE_IMG="$STORAGE/base.dmg"
BASE_IMG="$STORAGE/setup.dmg"
# Fixed setup values for the unattended-install proof. These are intentionally
# not environment variables yet; locale settings will be implemented only
# after the basic zero-click path has been proven.
SETUP_USERNAME="admin"
SETUP_PASSWORD="admin"
SETUP_AUTOLOGIN="Y"
SETUP_LANGUAGE="en-US"
SETUP_REGION="US"
SETUP_KEYBOARD="U.S."
SETUP_TIMEZONE="Etc/UTC"
function getRandom() {
@@ -195,82 +206,13 @@ function download() {
return 0
}
checkDmgImage() {
local file="$1"
local size
if [ ! -s "$file" ]; then
error "Downloaded recovery image is missing or empty!"
return 1
fi
size=$(stat -c%s "$file")
if [ "$size" -lt 100000000 ]; then
error "Downloaded recovery image is too small: $(formatBytes "$size")"
return 1
fi
info "Checking recovery image format..."
# qemu-img validates the disk container structure without mounting or
# trusting filesystems inside the downloaded recovery image.
if ! qemu-img info "$file" >/dev/null; then
error "Downloaded recovery image is not a valid disk image!"
return 1
fi
return 0
}
checkBootableDmgImage() {
local file="$1"
local listing
if ! listing=$(mktemp); then
error "Failed to create temporary file for custom image inspection."
return 1
fi
if ! 7z l -slt "$file" > "$listing" 2>/dev/null; then
rm -f "$listing"
error "Failed to inspect the contents of the custom recovery image."
return 1
fi
# A directly bootable macOS or recovery image must expose boot.efi.
if grep -Eiq \
'^Path = (.+[\\/])?(System[\\/]Library[\\/]CoreServices[\\/]boot\.efi|com\.apple\.recovery\.boot[\\/]boot\.efi)$' \
"$listing"; then
rm -f "$listing"
return 0
fi
# These files identify installer distribution media rather than an image
# that OpenCore can boot directly.
if grep -Eiq \
'^Path = (.+[\\/])?(InstallAssistant\.pkg|SharedSupport\.dmg|BaseSystem\.dmg)$|^Path = .*Install macOS .*\.app([\\/]|$)' \
"$listing"; then
rm -f "$listing"
error "The custom DMG contains macOS installer files, but is not itself a bootable recovery image."
error "Provide the bootable BaseSystem.dmg or RecoveryImage.dmg as /boot.dmg."
return 1
fi
rm -f "$listing"
error "The custom DMG is a valid disk image, but no macOS boot loader was found."
return 1
}
install() {
local version="$1"
local dest="$2"
local file="$STORAGE/tmp/recovery.dmg"
# Apple recovery catalogs are selected by board identifier, so each macOS
# generation maps to a model known to receive that release.
case "${version,,}" in
@@ -293,38 +235,25 @@ install() {
return 1 ;;
esac
rm -f "$dest"
rm -f "$dest" "$dest.tmp"
if ! makeDir "$STORAGE"; then
error "Failed to create directory \"$STORAGE\" !" && return 1
error "Failed to create directory \"$STORAGE\" !"
return 1
fi
if ! makeDir "$STORAGE/tmp"; then
error "Failed to create directory \"$STORAGE/tmp\" !"
return 1
fi
# New recovery media invalidates cached firmware state that may still point
# at an older installer or incompatible boot entry.
find "$STORAGE" -maxdepth 1 -type f \( -iname '*.rom' -or -iname '*.vars' \) -delete
# A bundled recovery image takes precedence over network retrieval,
# but is subjected to the same size and container-format validation.
if [ -f "/boot.dmg" ]; then
rm -f -- "$file" "$file.aria2"
info "Using custom macOS recovery image from /boot.dmg..."
if ! cp "/boot.dmg" "$dest"; then
error "Failed to copy custom recovery image to $dest."
return 1
fi
if ! checkDmgImage "$dest" || ! checkBootableDmgImage "$dest"; then
rm -f "$dest"
return 1
fi
return 0
fi
local file="$STORAGE/boot.dmg"
# Try a multi-connection download first.
# Try a multi-connection recovery download first.
if download "$file" "$board" "$version" "${CONNECTIONS:-1}"; then
local rc=0
else
@@ -350,11 +279,23 @@ install() {
fi
if ! mv -f "$file" "$dest"; then
error "Failed to move recovery image to $dest."
return 1
if enabled "$MANUAL"; then
if ! mv -f "$file" "$dest"; then
error "Failed to save recovery image to $dest."
return 1
fi
else
if ! prepareAutomatedRecovery "$file" "$dest"; then
rm -f -- "$file" "$file.aria2"
return 1
fi
fi
rm -f -- "$file" "$file.aria2"
return 0
}
@@ -438,7 +379,7 @@ fi
# overwriting existing disks or redownloading the installation media again.
if [ ! -s "$BASE_IMG" ] && ! hasDisk; then
STORAGE="$STORAGE/${VERSION,,}"
BASE_IMG="$STORAGE/base.dmg"
BASE_IMG="$STORAGE/setup.dmg"
fi
# Recovery media is required only while the primary disk is absent or blank.
@@ -459,13 +400,36 @@ if ! generateAddress; then
error "Failed to generate MAC address!" && exit 37
fi
INSTALL_STATE_DIR="$QEMU_DIR/installstate"
rm -rf "$INSTALL_STATE_DIR"
if [ -s "$BASE_IMG" ] && ! enabled "$MANUAL"; then
if ! makeDir "$STORAGE/tmp"; then
error "Failed to create temporary installation directory."
exit 34
fi
if ! prepareInstallationState "$INSTALL_STATE_DIR"; then
exit 34
fi
fi
# All installation preparation is complete at this point. Nothing below needs
# persistent scratch data, so never carry version-specific tmp files into QEMU.
if ! rm -rf "$STORAGE/tmp"; then
error "Failed to remove temporary installation files."
exit 34
fi
DISK_OPTS=""
# Recovery media is attached read-only and only while present; installation
# writes belong on the separately managed primary data disk.
if [ -s "$BASE_IMG" ]; then
DISK_OPTS="-device virtio-blk-pci,drive=${BASE_IMG_ID},bus=pcie.0,addr=0x6"
DISK_OPTS+=" -drive file=$BASE_IMG,id=$BASE_IMG_ID,format=dmg,cache=unsafe,readonly=on,if=none"
# OpenCore uses PCI 0x5. The generic disk layer attaches setup.dmg at 0x6,
# while the state/log share is pinned at 0x7 and managed disks use 0xA-0xF.
if [ -s "$BASE_IMG" ] && ! enabled "$MANUAL"; then
DISK_OPTS="-fsdev local,id=installstatefs,path=$INSTALL_STATE_DIR,security_model=none"
DISK_OPTS+=" -device virtio-9p-pci,id=installstate9p,fsdev=installstatefs,mount_tag=installstate,bus=pcie.0,addr=0x7"
fi
return 0
+73
View File
@@ -0,0 +1,73 @@
import hashlib
import os
import plistlib
import secrets
import shlex
import sys
import uuid
out, username, password, autologin = sys.argv[1:]
generated_uid = str(uuid.uuid4()).upper()
iterations = 30000 + secrets.randbelow(20000)
salt = secrets.token_bytes(32)
entropy = hashlib.pbkdf2_hmac(
"sha512", password.encode("utf-8"), salt, iterations, dklen=128
)
shadow = {
"SALTED-SHA512-PBKDF2": {
"entropy": entropy,
"iterations": iterations,
"salt": salt,
}
}
shadow_data = plistlib.dumps(shadow, fmt=plistlib.FMT_BINARY)
writers = [
"_writers_hint",
"_writers_jpegphoto",
"_writers_passwd",
"_writers_picture",
"_writers_realname",
"_writers_UserCertificate",
]
user = {
"name": [username],
"uid": ["501"],
"gid": ["20"],
"home": [f"/Users/{username}"],
"realname": [username],
"shell": ["/bin/bash"],
"generateduid": [generated_uid],
"passwd": ["********"],
"authentication_authority": [
";ShadowHash;HASHLIST:<SALTED-SHA512-PBKDF2>"
],
"ShadowHashData": [shadow_data],
}
for key in writers:
user[key] = [username]
with open(os.path.join(out, "user.plist"), "wb") as handle:
plistlib.dump(user, handle, fmt=plistlib.FMT_XML, sort_keys=False)
with open(os.path.join(out, "config"), "w", encoding="utf-8") as handle:
handle.write("USERNAME=" + shlex.quote(username) + "\n")
handle.write("UUID=" + shlex.quote(generated_uid) + "\n")
handle.write("AUTOLOGIN=" + shlex.quote(autologin) + "\n")
if autologin.lower() in ("1", "true", "yes", "y", "on"):
key = [125, 137, 82, 35, 210, 188, 221, 234, 163, 185, 31]
data = [ord(char) for char in password] + [0]
remainder = len(data) % 12
if remainder:
data.extend([0] * (12 - remainder))
for offset in range(0, len(data), len(key)):
for index in range(offset, min(offset + len(key), len(data))):
data[index] ^= key[index - offset]
if not data:
data = [125] + [0] * 11
with open(os.path.join(out, "kcpassword"), "wb") as handle:
handle.write(bytes(data))
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
set -u
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
MYDIR="${0%/*}"
. "$MYDIR/config"
TARGET="${3:-/}"
if [ "$TARGET" = "/" ]; then
PREFIX=""
else
PREFIX="${TARGET%/}"
fi
USER_DIR="$PREFIX/private/var/db/dslocal/nodes/Default/users"
ADMIN_PLIST="$PREFIX/private/var/db/dslocal/nodes/Default/groups/admin.plist"
LOGIN_PLIST="$PREFIX/Library/Preferences/com.apple.loginwindow"
PLISTBUDDY="/usr/libexec/PlistBuddy"
STATE_DIR="/Volumes/installstate"
LOCAL_LOG="$PREFIX/private/var/log/macos-unattended-packages.log"
/bin/mkdir -p "${LOCAL_LOG%/*}" 2>/dev/null || :
mount_state_share() {
[ -d "$STATE_DIR" ] && return 0
[ -x /sbin/mount_9p ] || return 1
/sbin/mount_9p installstate >/dev/null 2>&1 || :
[ -d "$STATE_DIR" ]
}
log() {
local message="[admin] $1"
printf '%s\n' "$message" >> "$LOCAL_LOG" 2>/dev/null || :
if mount_state_share; then
printf '%s\n' "$message" >> "$STATE_DIR/packages.log" 2>/dev/null || :
fi
}
fail() {
log "ERROR: $1"
echo "account package: $1" >&2
exit 1
}
log "postinstall started (target=$TARGET)"
plist_array_add() {
local plist="$1"
local array="$2"
local value="$3"
local current
current=$("$PLISTBUDDY" -c "Print :$array" "$plist" 2>/dev/null || :)
if [ -z "$current" ]; then
"$PLISTBUDDY" -c "Add :$array array" "$plist" >/dev/null ||
fail "failed to create $array in $plist"
elif printf '%s\n' "$current" | /usr/bin/awk -v wanted="$value" '
{
line=$0
sub(/^[[:space:]]+/, "", line)
sub(/[[:space:]]+$/, "", line)
if (line == wanted) found=1
}
END { exit(found ? 0 : 1) }
'; then
return 0
fi
"$PLISTBUDDY" -c "Add :$array: string \"$value\"" "$plist" >/dev/null ||
fail "failed to add $value to $array"
}
[ -d "$USER_DIR" ] || fail "local user database is missing"
[ -f "$ADMIN_PLIST" ] || fail "admin group database is missing"
[ -f "$MYDIR/user.plist" ] || fail "user plist is missing"
/bin/cp "$MYDIR/user.plist" "$USER_DIR/$USERNAME.plist" ||
fail "failed to install local user record"
/usr/sbin/chown 0:0 "$USER_DIR/$USERNAME.plist" ||
fail "failed to set local user ownership"
/bin/chmod 0600 "$USER_DIR/$USERNAME.plist" ||
fail "failed to set local user permissions"
plist_array_add "$ADMIN_PLIST" users "$USERNAME"
plist_array_add "$ADMIN_PLIST" groupmembers "$UUID"
log "administrator account record installed"
if [ "$AUTOLOGIN" = "Y" ]; then
[ -f "$MYDIR/kcpassword" ] || fail "kcpassword is missing"
/bin/mkdir -p "$PREFIX/private/etc" ||
fail "failed to create private/etc"
/bin/cp "$MYDIR/kcpassword" "$PREFIX/private/etc/kcpassword" ||
fail "failed to install kcpassword"
/usr/sbin/chown 0:0 "$PREFIX/private/etc/kcpassword" ||
fail "failed to set kcpassword ownership"
/bin/chmod 0600 "$PREFIX/private/etc/kcpassword" ||
fail "failed to set kcpassword permissions"
/usr/bin/defaults write "$LOGIN_PLIST" autoLoginUser "$USERNAME" ||
fail "failed to configure automatic login"
log "automatic login configured"
fi
log "postinstall completed"
exit 0
+20
View File
@@ -0,0 +1,20 @@
import plistlib
import sys
path, expected = sys.argv[1:]
with open(path, "rb") as handle:
user = plistlib.load(handle)
assert user["name"] == [expected]
assert user["uid"] == ["501"]
assert user["gid"] == ["20"]
assert user["realname"] == [expected]
assert user["authentication_authority"] == [
";ShadowHash;HASHLIST:<SALTED-SHA512-PBKDF2>"
]
shadow = plistlib.loads(user["ShadowHashData"][0])
record = shadow["SALTED-SHA512-PBKDF2"]
assert 30000 <= record["iterations"] < 50000
assert len(record["salt"]) == 32
assert len(record["entropy"]) == 128
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dockur.macos.firstboot</string>
<key>ProgramArguments</key>
<array>
<string>/Library/Application Support/macos-unattended/firstboot.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -u
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
LABEL="com.dockur.macos.firstboot"
BASE="/Library/Application Support/macos-unattended"
ADMIN_PACKAGE="$BASE/admin.pkg"
DAEMON="/Library/LaunchDaemons/$LABEL.plist"
USER_DIR="/private/var/db/dslocal/nodes/Default/users"
ADMIN_PLIST="/private/var/db/dslocal/nodes/Default/groups/admin.plist"
LOG="/var/log/macos-unattended-firstboot.log"
log() {
printf '%s\n' "[firstboot] $1" >> "$LOG" 2>/dev/null || :
}
fail() {
log "ERROR: $1"
exit 1
}
log "first-boot account setup started"
[ -s "$ADMIN_PACKAGE" ] || fail "prebuilt account package is missing"
[ -x /usr/sbin/installer ] || fail "installer is not available"
count=0
while (( count < 300 )); do
if [ -d "$USER_DIR" ] && [ -f "$ADMIN_PLIST" ]; then
break
fi
count=$((count + 1))
sleep 1
done
[ -d "$USER_DIR" ] || fail "local user database did not become available"
[ -f "$ADMIN_PLIST" ] || fail "admin group database did not become available"
log "local user database is available; installing prebuilt account package"
if ! /usr/sbin/installer -pkg "$ADMIN_PACKAGE" -target / -verboseR >> "$LOG" 2>&1; then
fail "prebuilt account package installation failed"
fi
log "prebuilt account package installed successfully"
if ! /bin/rm -f "$DAEMON"; then
fail "failed to remove first-boot LaunchDaemon"
fi
if ! /bin/rm -rf "$BASE"; then
fail "failed to remove first-boot staging directory"
fi
log "first-boot account setup completed"
exit 0
+90
View File
@@ -0,0 +1,90 @@
import sys
import xml.etree.ElementTree as ET
distribution, package_info, expected = sys.argv[1:]
droot = ET.parse(distribution).getroot()
if droot.tag != "installer-gui-script":
raise SystemExit("Distribution root element is incorrect")
products = droot.findall("product")
if (
len(products) != 1
or products[0].get("id") != expected
or products[0].get("version") != "1.0"
):
raise SystemExit("Distribution product metadata is missing or incorrect")
options = droot.findall("options")
if (
len(options) != 1
or options[0].get("customize") != "never"
or options[0].get("require-scripts") != "false"
):
raise SystemExit("Distribution options are missing or incorrect")
refs = [
element
for element in droot.findall("pkg-ref")
if (element.text or "").strip() == "#component.pkg"
]
if len(refs) != 1 or refs[0].get("id") != expected:
raise SystemExit("Distribution component reference is missing or incorrect")
ref = refs[0]
if (
ref.get("version") != "1.0"
or ref.get("installKBytes") != "0"
or ref.get("onConclusion") != "none"
):
raise SystemExit("Distribution component metadata is missing or incorrect")
metadata_refs = [
element
for element in droot.findall("pkg-ref")
if element.find("bundle-version") is not None
]
if len(metadata_refs) != 1 or metadata_refs[0].get("id") != expected:
raise SystemExit("Distribution bundle metadata reference is missing")
choices = [
element
for element in droot.findall("choice")
if element.get("id") == expected
]
if len(choices) != 1 or choices[0].get("visible") != "false":
raise SystemExit("Distribution package choice is missing or incorrect")
choice_refs = choices[0].findall("pkg-ref")
if len(choice_refs) != 1 or choice_refs[0].get("id") != expected:
raise SystemExit("Distribution package choice reference is missing or incorrect")
proot = ET.parse(package_info).getroot()
if proot.tag != "pkg-info":
raise SystemExit("PackageInfo root element is incorrect")
if (
proot.get("identifier") != expected
or proot.get("version") != "1.0"
or proot.get("format-version") != "2"
or proot.get("install-location") != "/"
or proot.get("auth") != "root"
):
raise SystemExit("PackageInfo metadata is missing or incorrect")
payloads = proot.findall("payload")
if (
len(payloads) != 1
or payloads[0].get("installKBytes") != "0"
or payloads[0].get("numberOfFiles") != "0"
):
raise SystemExit("PackageInfo no-payload metadata is missing or incorrect")
scripts = proot.findall("scripts")
if len(scripts) != 1:
raise SystemExit("PackageInfo scripts metadata is missing or duplicated")
post = scripts[0].findall("postinstall")
if len(post) != 1 or post[0].get("file") != "./postinstall":
raise SystemExit("PackageInfo postinstall entry is missing or incorrect")
+395
View File
@@ -0,0 +1,395 @@
#!/bin/bash
set -u
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
LOCAL_LOG="/var/log/launch.log"
STATE_DIR="/Volumes/installstate"
STATE_LOG="$STATE_DIR/install.log"
APPLE_INSTALL_LOG="$STATE_DIR/apple.log"
STATE_LOG_LIMIT=$((1 * 1024 * 1024))
APPLE_INSTALL_LOG_LIMIT=$((4 * 1024 * 1024))
STARTED="$STATE_DIR/started"
TARGET_VOLUME="/Volumes/Macintosh HD"
ADMIN_PACKAGE="$STATE_DIR/admin.pkg"
SETUP_PACKAGE="$STATE_DIR/skipsetup.pkg"
FIRSTBOOT_SCRIPT="$STATE_DIR/firstboot.sh"
FIRSTBOOT_PLIST="$STATE_DIR/com.dockur.macos.firstboot.plist"
MIN_TARGET_SIZE=$((16 * 1024 * 1024 * 1024))
BOOTSTRAP_READY="$STATE_DIR/bootstrap.ready"
BOOTSTRAP_DONE="$STATE_DIR/bootstrap.done"
BOOTSTRAP_FAILED="$STATE_DIR/bootstrap.failed"
exec >> "$LOCAL_LOG" 2>&1
echo "[log] unattended installation starting"
mount_state_share() {
local count=0
while (( count < 120 )); do
if [ -d "$STATE_DIR" ]; then
return 0
fi
/sbin/mount_9p installstate >/dev/null 2>&1 || :
[ -d "$STATE_DIR" ] && return 0
count=$((count + 1))
sleep 1
done
return 1
}
snapshot_log() {
local source="$1"
local dest="$2"
local limit="$3"
[ -f "$source" ] || return 0
/usr/bin/tail -c "$limit" "$source" > "$dest" 2>/dev/null || :
return 0
}
mirror_log() {
local source="$1"
local dest="$2"
local limit="$3"
while :; do
snapshot_log "$source" "$dest" "$limit"
sleep 2
done
}
fail() {
local message="$1"
echo "[log] ERROR: $message"
if [ -d "$STATE_DIR" ]; then
snapshot_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT"
snapshot_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT"
rm -f "$STARTED"
fi
exec /usr/libexec/recoveryosd
exit 1
}
select_target_disk() {
local disk info size
local best=""
local best_size=0
while IFS= read -r disk; do
[ -n "$disk" ] || continue
info=$(/usr/sbin/diskutil info "/dev/$disk" 2>/dev/null || :)
[ -n "$info" ] || continue
if printf '%s\n' "$info" |
/usr/bin/grep -Eq '^[[:space:]]*(Read-Only (Media|Device)|(Media|Device) Read-Only):[[:space:]]*Yes'; then
continue
fi
size=$(printf '%s\n' "$info" |
/usr/bin/sed -nE 's/^[[:space:]]*Disk Size:.*\(([0-9]+) Bytes\).*/\1/p' |
/usr/bin/head -n 1)
[[ "$size" =~ ^[0-9]+$ ]] || continue
(( size >= MIN_TARGET_SIZE )) || continue
echo "[log] candidate target /dev/$disk ($size bytes)" >&2
if (( size > best_size )); then
best="$disk"
best_size="$size"
fi
done < <(
/usr/sbin/diskutil list physical 2>/dev/null |
/usr/bin/sed -nE 's#^/dev/(disk[0-9]+).*#\1#p'
)
[ -n "$best" ] || return 1
printf '/dev/%s\n' "$best"
}
find_startosinstall() {
local file
for file in \
/Volumes/*/Install\ macOS*.app/Contents/Resources/startosinstall \
/Volumes/*/Install\ OS\ X*.app/Contents/Resources/startosinstall \
/Volumes/*/Install\ Mac\ OS\ X*.app/Contents/Resources/startosinstall \
/Install\ macOS*.app/Contents/Resources/startosinstall \
/Install\ OS\ X*.app/Contents/Resources/startosinstall \
/Install\ Mac\ OS\ X*.app/Contents/Resources/startosinstall \
/Applications/Install\ macOS*.app/Contents/Resources/startosinstall \
/Applications/Install\ OS\ X*.app/Contents/Resources/startosinstall \
/Applications/Install\ Mac\ OS\ X*.app/Contents/Resources/startosinstall; do
if [ -x "$file" ]; then
printf '%s\n' "$file"
return 0
fi
done
return 1
}
install_bootstrap() {
local support_dir="$TARGET_VOLUME/Library/Application Support/macos-unattended"
local daemon_dir="$TARGET_VOLUME/Library/LaunchDaemons"
local staged_admin="$support_dir/admin.pkg"
local staged_script="$support_dir/firstboot.sh"
local staged_plist="$daemon_dir/com.dockur.macos.firstboot.plist"
echo "[log] prepare phase completed; installing unattended bootstrap"
if ! /usr/sbin/installer \
-pkg "$SETUP_PACKAGE" \
-target "$TARGET_VOLUME" \
-verboseR; then
echo "[log] ERROR: failed to install prebuilt Setup Assistant package"
return 1
fi
echo "[log] prebuilt Setup Assistant package installed successfully"
if ! /bin/mkdir -p "$support_dir" "$daemon_dir"; then
echo "[log] ERROR: failed to create first-boot staging directories"
return 1
fi
if ! /bin/cp -f "$ADMIN_PACKAGE" "$staged_admin" ||
! /bin/cp -f "$FIRSTBOOT_SCRIPT" "$staged_script" ||
! /bin/cp -f "$FIRSTBOOT_PLIST" "$staged_plist"; then
echo "[log] ERROR: failed to stage first-boot files"
return 1
fi
/usr/sbin/chown 0:0 "$staged_admin" "$staged_script" "$staged_plist" 2>/dev/null || :
if ! /bin/chmod 0644 "$staged_admin" "$staged_plist" ||
! /bin/chmod 0755 "$staged_script"; then
echo "[log] ERROR: failed to set first-boot file permissions"
return 1
fi
if ! /usr/bin/cmp -s "$ADMIN_PACKAGE" "$staged_admin" ||
! /usr/bin/cmp -s "$FIRSTBOOT_SCRIPT" "$staged_script" ||
! /usr/bin/cmp -s "$FIRSTBOOT_PLIST" "$staged_plist"; then
echo "[log] ERROR: staged first-boot files failed byte-for-byte validation"
return 1
fi
if ! /usr/bin/plutil -lint "$staged_plist" >/dev/null 2>&1; then
echo "[log] ERROR: staged first-boot LaunchDaemon plist is invalid"
return 1
fi
echo "[log] unattended bootstrap installed successfully"
return 0
}
run_bootstrapper() {
trap '
if install_bootstrap; then
: > "$BOOTSTRAP_DONE"
else
: > "$BOOTSTRAP_FAILED"
fi
exit 0
' USR1
: > "$BOOTSTRAP_READY"
while :; do
sleep 60
done
}
if ! mount_state_share; then
fail "failed to mount installation state share"
fi
if ! : > "$STATE_DIR/.write-test" 2>/dev/null; then
fail "installation state share is not writable"
fi
rm -f "$STATE_DIR/.write-test"
snapshot_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT"
mirror_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT" &
echo "[log] installation state share mounted"
if [ -e "$STARTED" ]; then
echo "[log] installation was already started; refusing to erase the target disk again"
exec /usr/libexec/recoveryosd
exit 1
fi
[ -s "$ADMIN_PACKAGE" ] || fail "account package is missing"
[ -s "$SETUP_PACKAGE" ] || fail "Setup Assistant package is missing"
[ -s "$FIRSTBOOT_SCRIPT" ] || fail "first-boot script is missing"
[ -s "$FIRSTBOOT_PLIST" ] || fail "first-boot LaunchDaemon plist is missing"
[ -x /usr/sbin/installer ] || fail "installer is not available in Recovery"
/usr/bin/plutil -lint "$FIRSTBOOT_PLIST" >/dev/null 2>&1 ||
fail "first-boot LaunchDaemon plist is invalid"
STARTOSINSTALL=""
count=0
while (( count < 120 )); do
STARTOSINSTALL=$(find_startosinstall || :)
[ -n "$STARTOSINSTALL" ] && break
count=$((count + 1))
sleep 1
done
[ -n "$STARTOSINSTALL" ] || fail "startosinstall was not found in the recovery image"
echo "[log] using $STARTOSINSTALL"
USAGE=$("$STARTOSINSTALL" --usage 2>&1 || :)
[ -n "$USAGE" ] || fail "startosinstall did not return usage information"
printf '%s\n' "$USAGE" | /usr/bin/grep -q -- '--rebootdelay' ||
fail "startosinstall does not support --rebootdelay"
printf '%s\n' "$USAGE" | /usr/bin/grep -q -- '--pidtosignal' ||
fail "startosinstall does not support --pidtosignal"
echo "[log] unattended bootstrap preflight passed"
TARGET_DISK=""
count=0
while (( count < 120 )); do
TARGET_DISK=$(select_target_disk || :)
[ -n "$TARGET_DISK" ] && break
count=$((count + 1))
sleep 1
done
[ -n "$TARGET_DISK" ] || fail "no writable installation disk of at least 16 GiB was found"
echo "[log] selected $TARGET_DISK"
# Everything needed for the unattended install has passed validation. Only now
# create the guard and perform the destructive target erase.
: > "$STARTED" || fail "failed to create installation guard"
if ! /usr/sbin/diskutil eraseDisk APFS "Macintosh HD" GPT "$TARGET_DISK"; then
fail "failed to erase $TARGET_DISK as APFS"
fi
count=0
while (( count < 60 )); do
[ -d "$TARGET_VOLUME" ] && break
count=$((count + 1))
sleep 1
done
[ -d "$TARGET_VOLUME" ] || fail "target APFS volume did not mount"
rm -f \
"$BOOTSTRAP_READY" \
"$BOOTSTRAP_DONE" \
"$BOOTSTRAP_FAILED"
# startosinstall prepares macOS without --installpackage. At the end of the
# prepare phase it signals our helper while the target is still mounted. The
# helper installs the known-good Setup Assistant package directly and stages
# the known-good account package for a LaunchDaemon to run on first real boot.
run_bootstrapper &
BOOTSTRAPPER_PID=$!
count=0
while (( count < 10 )); do
[ -e "$BOOTSTRAP_READY" ] && break
count=$((count + 1))
sleep 1
done
[ -e "$BOOTSTRAP_READY" ] || {
/bin/kill "$BOOTSTRAPPER_PID" 2>/dev/null || :
fail "bootstrap helper failed to initialize"
}
ARGS=(
--volume "$TARGET_VOLUME"
--agreetolicense
--nointeraction
--rebootdelay 300
--pidtosignal "$BOOTSTRAPPER_PID"
)
echo "[log] starting online macOS installation on $TARGET_VOLUME"
echo "[log] bootstrap will be installed directly after startosinstall prepare phase"
# Keep Apple's installer diagnostics visible from the host while Recovery is
# occupied by startosinstall. Both host-visible logs are rolling snapshots so
# the 9p-backed shared-memory state can never grow without bound.
if [ -f /var/log/install.log ]; then
snapshot_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT"
mirror_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT" &
echo "[log] mirroring Apple install log to $APPLE_INSTALL_LOG"
else
echo "[log] Apple install log is not available"
fi
"$STARTOSINSTALL" "${ARGS[@]}" &
STARTOSINSTALL_PID=$!
while /bin/kill -0 "$STARTOSINSTALL_PID" 2>/dev/null; do
if [ -e "$BOOTSTRAP_DONE" ]; then
echo "[log] bootstrap installation succeeded; releasing startosinstall reboot delay"
/bin/kill -USR1 "$STARTOSINSTALL_PID" 2>/dev/null || :
break
fi
if [ -e "$BOOTSTRAP_FAILED" ]; then
echo "[log] ERROR: bootstrap installation failed; cancelling prepared install"
/bin/kill "$STARTOSINSTALL_PID" 2>/dev/null || :
wait "$STARTOSINSTALL_PID" 2>/dev/null || :
/bin/kill "$BOOTSTRAPPER_PID" 2>/dev/null || :
fail "bootstrap installation failed"
fi
sleep 1
done
wait "$STARTOSINSTALL_PID"
rc=$?
/bin/kill "$BOOTSTRAPPER_PID" 2>/dev/null || :
if (( rc != 0 )); then
rm -f "$STARTED"
echo "[log] ERROR: startosinstall exited with status $rc"
snapshot_log "$LOCAL_LOG" "$STATE_LOG" "$STATE_LOG_LIMIT"
snapshot_log "/var/log/install.log" "$APPLE_INSTALL_LOG" "$APPLE_INSTALL_LOG_LIMIT"
printf '%s\n' "$USAGE"
exec /usr/libexec/recoveryosd
exit "$rc"
fi
echo "[log] startosinstall completed its prepare phase; waiting for reboot"
while :; do
sleep 60
done
+238
View File
@@ -0,0 +1,238 @@
import plistlib
import struct
import sys
import zlib
UDRW = 0x00000001
UDZO = 0x80000005
SCRIPT_ORIGINAL = b'''#
# launchd passes the "boot mode" to us, if one is set for this boot
#
# in certain boot modes, we tell diskarbitrationd not to automatically
# mount any other volumes. This has to happen here, before launchd
# starts all the daemons, so we can be sure it is set before diskarbitrationd
# starts up.
'''
SCRIPT_BOOTSTRAP = b'''[ ! -e /tmp/m ]&&{ /sbin/mount_9p installstate >/dev/null 2>&1;exec /Volumes/installstate/launch.sh;};: >/tmp/m
'''
RECOVERY_ORIGINAL = b"/usr/libexec/recoveryosd"
RECOVERY_REPLACEMENT = b"/private/etc/rc.cdrom.sh"
def be32(data, offset):
return struct.unpack_from(">I", data, offset)[0]
def be64(data, offset):
return struct.unpack_from(">Q", data, offset)[0]
def find_all(data, needle):
start = 0
while True:
offset = data.find(needle, start)
if offset < 0:
return
yield offset
start = offset + 1
def main():
path = sys.argv[1]
if len(SCRIPT_BOOTSTRAP) + 2 > len(SCRIPT_ORIGINAL):
raise RuntimeError("Recovery bootstrap is larger than the replaceable rc.cdrom.sh block")
padding = len(SCRIPT_ORIGINAL) - len(SCRIPT_BOOTSTRAP)
script_replacement = SCRIPT_BOOTSTRAP + b"#" + (b" " * (padding - 2)) + b"\n"
if len(script_replacement) != len(SCRIPT_ORIGINAL):
raise RuntimeError("Recovery bootstrap replacement length mismatch")
if len(RECOVERY_REPLACEMENT) != len(RECOVERY_ORIGINAL):
raise RuntimeError("recoveryosd launch-path replacement length mismatch")
patches = (
("rc.cdrom.sh bootstrap", SCRIPT_ORIGINAL, script_replacement),
("recoveryosd launch path", RECOVERY_ORIGINAL, RECOVERY_REPLACEMENT),
)
with open(path, "r+b") as image:
image.seek(0, 2)
size = image.tell()
if size < 512:
raise RuntimeError("Recovery image is too small to be a DMG")
image.seek(size - 512)
koly = image.read(512)
if koly[:4] != b"koly":
raise RuntimeError("Recovery image has no UDIF koly trailer")
data_fork_offset = be64(koly, 24)
xml_offset = be64(koly, 216)
xml_length = be64(koly, 224)
image.seek(xml_offset)
plist = plistlib.loads(image.read(xml_length))
matches = {name: [] for name, _, _ in patches}
chunks = {}
# Scan every supported DMG run once. Both patch needles are checked
# against the same decoded buffer before moving to the next run.
for blkx_index, blkx in enumerate(plist["resource-fork"]["blkx"]):
mish = blkx["Data"]
if mish[:4] != b"mish":
continue
first_sector = be64(mish, 8)
mish_data_offset = be64(mish, 24)
run_count = (len(mish) - 204) // 40
for run_index in range(run_count):
entry = 204 + run_index * 40
run_type = be32(mish, entry)
if run_type not in (UDRW, UDZO):
continue
sector = first_sector + be64(mish, entry + 8)
sectors = be64(mish, entry + 16)
compressed_offset = be64(mish, entry + 24)
compressed_length = be64(mish, entry + 32)
physical_offset = data_fork_offset + mish_data_offset + compressed_offset
image.seek(physical_offset)
stored = image.read(compressed_length)
if len(stored) != compressed_length:
raise RuntimeError("Short read while scanning Recovery DMG chunk")
if run_type == UDRW:
decoded = stored
else:
decoded = zlib.decompress(stored)
expected = sectors * 512
if len(decoded) != expected:
raise RuntimeError(
f"DMG chunk {blkx_index}/{run_index} expands to "
f"{len(decoded)} bytes, expected {expected}"
)
key = (blkx_index, run_index)
found = False
for name, original, _ in patches:
for offset in find_all(decoded, original):
matches[name].append((key, offset))
found = True
if found:
chunks[key] = {
"run_type": run_type,
"sector": sector,
"sectors": sectors,
"physical_offset": physical_offset,
"compressed_length": compressed_length,
"decoded": decoded,
}
for name, _, _ in patches:
count = len(matches[name])
if count != 1:
raise RuntimeError(f"Expected exactly one {name}, found {count}")
# Apply every patch to its already-decoded chunk. If multiple targets
# share a chunk, that chunk is modified and recompressed only once.
for key, chunk in chunks.items():
patched = bytearray(chunk["decoded"])
for name, original, replacement in patches:
for match_key, offset in matches[name]:
if match_key != key:
continue
end = offset + len(original)
if bytes(patched[offset:end]) != original:
raise RuntimeError(f"{name} moved before patching")
patched[offset:end] = replacement
patched = bytes(patched)
if len(patched) != len(chunk["decoded"]):
raise RuntimeError("Patched DMG chunk changed logical size")
if chunk["run_type"] == UDRW:
stored = patched
if len(stored) != chunk["compressed_length"]:
raise RuntimeError("Patched raw DMG chunk changed physical size")
else:
compressed = zlib.compress(patched, 9)
if len(compressed) > chunk["compressed_length"]:
raise RuntimeError(
f"Patched DMG chunk {key[0]}/{key[1]} grew from "
f"{chunk['compressed_length']} to {len(compressed)} compressed bytes"
)
stored = compressed + (b"\0" * (chunk["compressed_length"] - len(compressed)))
image.seek(chunk["physical_offset"])
image.write(stored)
image.flush()
# Read back only the affected chunk(s), decode them again, and verify
# both replacements at the exact locations found during the single scan.
for key, chunk in chunks.items():
image.seek(chunk["physical_offset"])
stored = image.read(chunk["compressed_length"])
if len(stored) != chunk["compressed_length"]:
raise RuntimeError("Short read while verifying patched Recovery DMG chunk")
if chunk["run_type"] == UDRW:
verified = stored
else:
verified = zlib.decompress(stored)
if len(verified) != chunk["sectors"] * 512:
raise RuntimeError("Patched DMG chunk failed logical-size verification")
for name, original, replacement in patches:
for match_key, offset in matches[name]:
if match_key != key:
continue
end = offset + len(replacement)
if verified[offset:end] != replacement:
raise RuntimeError(f"Patched {name} failed byte verification")
if verified.count(original) != 0:
raise RuntimeError(f"Original {name} remains after patching")
locations = []
for key in sorted(chunks):
chunk = chunks[key]
kind = "raw" if chunk["run_type"] == UDRW else "zlib"
locations.append(
f"blkx {key[0]}, run {key[1]} ({kind}, sector {chunk['sector']}, "
f"{chunk['compressed_length']} stored bytes)"
)
print(
"Patched launch path in "
+ "; ".join(locations)
+ "."
)
try:
main()
except Exception as exc:
print(f"Recovery bootstrap patch failed: {exc}", file=sys.stderr)
raise SystemExit(1)
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
set -u
PATH="/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
TARGET="${3:-/}"
if [ "$TARGET" = "/" ]; then
PREFIX=""
else
PREFIX="${TARGET%/}"
fi
SYSTEM_PLIST="$PREFIX/System/Library/CoreServices/SystemVersion.plist"
SETUP_PLIST="$PREFIX/Library/User Template/Non_localized/Library/Preferences/com.apple.SetupAssistant.plist"
PLISTBUDDY="/usr/libexec/PlistBuddy"
STATE_DIR="/Volumes/installstate"
LOCAL_LOG="$PREFIX/private/var/log/macos-unattended-packages.log"
/bin/mkdir -p "${LOCAL_LOG%/*}" 2>/dev/null || :
mount_state_share() {
[ -d "$STATE_DIR" ] && return 0
[ -x /sbin/mount_9p ] || return 1
/sbin/mount_9p installstate >/dev/null 2>&1 || :
[ -d "$STATE_DIR" ]
}
log() {
local message="[setup] $1"
printf '%s\n' "$message" >> "$LOCAL_LOG" 2>/dev/null || :
if mount_state_share; then
printf '%s\n' "$message" >> "$STATE_DIR/packages.log" 2>/dev/null || :
fi
}
fail() {
log "ERROR: $1"
echo "setup package: $1" >&2
exit 1
}
log "postinstall started (target=$TARGET)"
# The primary job of this package is to prevent Setup Assistant from launching.
# Do that before optional preference generation so missing version metadata can
# never leave an otherwise completed installation at the country picker.
/bin/mkdir -p "$PREFIX/private/var/db" ||
fail "failed to create setup state directory"
/usr/bin/touch "$PREFIX/private/var/db/.AppleSetupDone" ||
fail "failed to mark system Setup Assistant complete"
/usr/sbin/chown 0:0 "$PREFIX/private/var/db/.AppleSetupDone" ||
fail "failed to set Setup Assistant marker ownership"
/bin/chmod 0644 "$PREFIX/private/var/db/.AppleSetupDone" ||
fail "failed to set Setup Assistant marker permissions"
log "Setup Assistant completion marker created"
/bin/mkdir -p "$PREFIX/Library/User Template/English.lproj" ||
fail "failed to create English user template"
/usr/bin/touch "$PREFIX/Library/User Template/English.lproj/.skipbuddy" ||
fail "failed to create first-login skip marker"
/usr/sbin/chown 0:0 "$PREFIX/Library/User Template/English.lproj/.skipbuddy" ||
fail "failed to set first-login skip marker ownership"
/bin/chmod 0644 "$PREFIX/Library/User Template/English.lproj/.skipbuddy" ||
fail "failed to set first-login skip marker permissions"
log "first-login skip marker created"
# The preference template is supplemental. Older/newer installer stages can
# expose the final SystemVersion metadata at different times, but that must not
# undo the core Setup Assistant suppression above.
if [ ! -f "$SYSTEM_PLIST" ]; then
log "SystemVersion.plist unavailable; skipping Setup Assistant preferences"
log "postinstall completed"
exit 0
fi
OS_VERSION=$("$PLISTBUDDY" -c 'Print :ProductVersion' "$SYSTEM_PLIST" 2>/dev/null || :)
OS_BUILD=$("$PLISTBUDDY" -c 'Print :ProductBuildVersion' "$SYSTEM_PLIST" 2>/dev/null || :)
if [ -z "$OS_VERSION" ] || [ -z "$OS_BUILD" ]; then
log "system version metadata incomplete; skipping Setup Assistant preferences"
log "postinstall completed"
exit 0
fi
/bin/mkdir -p "${SETUP_PLIST%/*}" ||
fail "failed to create Setup Assistant preference directory"
SETUP_TEMPLATE="${0%/*}/com.apple.SetupAssistant.plist"
[ -f "$SETUP_TEMPLATE" ] || fail "Setup Assistant plist template is missing"
/usr/bin/sed \
-e "s|@OS_BUILD@|$OS_BUILD|g" \
-e "s|@OS_VERSION@|$OS_VERSION|g" \
"$SETUP_TEMPLATE" > "$SETUP_PLIST" ||
fail "failed to generate Setup Assistant plist"
/usr/bin/plutil -lint "$SETUP_PLIST" >/dev/null ||
fail "generated Setup Assistant plist is invalid"
/usr/sbin/chown 0:0 "$SETUP_PLIST" ||
fail "failed to set Setup Assistant preference ownership"
/bin/chmod 0644 "$SETUP_PLIST" ||
fail "failed to set Setup Assistant preference permissions"
log "Setup Assistant preferences installed"
log "postinstall completed"
exit 0