This commit is contained in:
Kroese
2026-07-28 12:42:49 +02:00
parent 6389212ca7
commit 5fffd5416e
7 changed files with 741 additions and 21 deletions
+3
View File
@@ -49,6 +49,9 @@ ADD --chmod=664 https://github.com/qemus/virtiso-whql/releases/download/v${VERSI
FROM dockurr/windows-arm:${VERSION_ARG} AS build-arm64
FROM build-${TARGETARCH}
ARG VERSION_PYCDLIB="1.16.0"
RUN python3 -m pip install --break-system-packages --no-cache-dir "pycdlib==${VERSION_PYCDLIB}"
ARG VERSION_ARG="0.00"
RUN echo "$VERSION_ARG" > /etc/version
+31
View File
@@ -0,0 +1,31 @@
# PyCdlib ISO prototype test branch
This branch builds the modified Windows installer twice when `ISO_TEST=Y`:
1. PyCdlib rewrites the original ISO without extracting unchanged files.
2. The existing `genisoimage` path builds a reference ISO from the extracted tree.
3. The original, reference, and PyCdlib images are compared.
4. The VM boots from the PyCdlib image.
The included `compose.yml` enables `ISO_TEST=Y`. Build and run it with:
```bash
docker compose build --no-cache
docker compose up
```
Results are written under `/storage/iso-test`:
- `genisoimage.iso` — current builder reference
- `report.json` — three-way descriptor, UDF, and El Torito comparison
- `timing.json` — build durations and seconds saved
The log also prints:
```text
ISO build timing: PyCdlib 123s, genisoimage 180s, saved 57s.
```
`saved_seconds` is calculated as `genisoimage_seconds - pycdlib_seconds`; a negative value means PyCdlib was slower. The final `/storage/windows*.iso` used by QEMU is the PyCdlib image. Disable the experiment with `ISO_TEST=N` to use the unmodified production path.
This is deliberately a validation branch. Keep enough free space for the original ISO, extracted tree, PyCdlib ISO, and retained genisoimage reference ISO during the dual build.
+1
View File
@@ -4,6 +4,7 @@ services:
container_name: windows
environment:
VERSION: "11"
ISO_TEST: "Y"
devices:
- /dev/kvm
- /dev/net/tun
+1 -1
View File
@@ -63,7 +63,7 @@ else
fi
pid=$!
( sleep 30; boot ) &
waitForBoot "$pid" 30 &
rc=0
wait "$pid" || rc=$?
+112 -5
View File
@@ -1220,6 +1220,99 @@ bootWindows() {
return 0
}
buildIsoPrototype() {
local iso="$1"
local dir="$2"
local test_dir="$STORAGE/iso-test"
local py_iso="$TMP/pycdlib.iso"
local gen_iso="$test_dir/genisoimage.iso"
local report="$test_dir/report.json"
local timing="$test_dir/timing.json"
local start end py_seconds gen_seconds compare_seconds total_seconds saved_seconds
mkdir -p "$test_dir" || return 1
rm -f "$py_iso" "$gen_iso" "$report" "$timing" || return 1
start=$(date +%s)
local total_start="$start"
info "Building PyCdlib prototype image..."
local py_start="$start"
local -a args=(
build
--source "$iso"
--tree "$dir"
--output "$py_iso"
)
local boot_wim
boot_wim=$(find "$dir" -maxdepth 2 -type f -ipath '*/sources/boot.wim' -print -quit) || return 1
if [ -n "$boot_wim" ]; then
args+=(--replace "sources/boot.wim")
fi
if [ -d "$dir/\$OEM\$" ]; then
args+=(--add-tree '$OEM$')
fi
if ! python3 /run/iso-prototype.py "${args[@]}"; then
error "Failed to build PyCdlib prototype image!"
return 1
fi
end=$(date +%s)
py_seconds=$((end - py_start))
info "Building genisoimage reference image..."
local gen_start="$end"
if ! buildImage "$dir"; then
return 1
fi
end=$(date +%s)
gen_seconds=$((end - gen_start))
if ! mv -f "$BOOT" "$gen_iso"; then
error "Failed to preserve genisoimage reference image!"
return 1
fi
info "Comparing ISO metadata..."
local compare_start="$end"
if ! python3 /run/iso-prototype.py compare \
--original "$iso" \
--genisoimage "$gen_iso" \
--pycdlib "$py_iso" \
--output "$report"; then
error "Failed to compare prototype images!"
return 1
fi
end=$(date +%s)
compare_seconds=$((end - compare_start))
total_seconds=$((end - total_start))
saved_seconds=$((gen_seconds - py_seconds))
cat > "$timing" <<EOF
{
"pycdlib_seconds": $py_seconds,
"genisoimage_seconds": $gen_seconds,
"saved_seconds": $saved_seconds,
"comparison_seconds": $compare_seconds,
"total_test_seconds": $total_seconds
}
EOF
info "ISO build timing: PyCdlib ${py_seconds}s, genisoimage ${gen_seconds}s, saved ${saved_seconds}s."
info "ISO test report: $report"
info "ISO timing report: $timing"
if ! mv -f "$py_iso" "$BOOT"; then
error "Failed to select PyCdlib image for boot testing!"
return 1
fi
return 0
}
######################################
! parseVersion && exit 58
@@ -1260,12 +1353,26 @@ if ! updateImage "$DIR" "$XML" "$LANGUAGE"; then
exit 63
fi
if ! removeImage "$ISO"; then
exit 64
fi
if enabled "${ISO_TEST:-}"; then
if ! buildIsoPrototype "$ISO" "$DIR"; then
exit 65
fi
if ! removeImage "$ISO"; then
exit 64
fi
else
if ! removeImage "$ISO"; then
exit 64
fi
if ! buildImage "$DIR"; then
exit 65
fi
if ! buildImage "$DIR"; then
exit 65
fi
if ! finishInstall "$BOOT" "N"; then
+534
View File
@@ -0,0 +1,534 @@
#!/usr/bin/env python3
"""Prototype userspace Windows ISO modifier and metadata comparator.
Requires Python 3.10+ and pycdlib 1.16.0 for the ``build`` command.
The ``compare`` command has no third-party Python dependencies.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import struct
import subprocess
import sys
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
from typing import Any, Iterable
SECTOR = 2048
class PrototypeError(RuntimeError):
pass
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def sha256_region(f, offset: int, length: int) -> str:
f.seek(offset)
h = hashlib.sha256()
remaining = length
while remaining:
chunk = f.read(min(1024 * 1024, remaining))
if not chunk:
raise PrototypeError(f"Unexpected EOF at byte {offset + length - remaining}")
h.update(chunk)
remaining -= len(chunk)
return h.hexdigest()
def decode_ascii(data: bytes) -> str:
return data.decode("ascii", "replace").rstrip(" \x00")
def both_endian_u16(data: bytes) -> int:
return struct.unpack_from("<H", data, 0)[0]
def both_endian_u32(data: bytes) -> int:
return struct.unpack_from("<I", data, 0)[0]
@dataclass
class VolumeDescriptor:
sector: int
type: int
identifier: str
version: int
sha256: str
volume_id: str | None = None
volume_space_size: int | None = None
logical_block_size: int | None = None
path_table_size: int | None = None
l_path_table: int | None = None
m_path_table: int | None = None
escape_sequences_hex: str | None = None
boot_system_id: str | None = None
boot_catalog_lba: int | None = None
@dataclass
class BootEntry:
offset: int
kind: str
platform_id: int | None
bootable: bool | None
media_type: int | None
load_segment: int | None
system_type: int | None
sector_count: int | None
image_lba: int | None
image_sha256: str | None
@dataclass
class UdfTag:
sector: int
tag_id: int
version: int
serial: int
location: int
sha256: str
def parse_volume_descriptors(path: Path) -> tuple[list[VolumeDescriptor], int | None]:
result: list[VolumeDescriptor] = []
catalog_lba = None
with path.open("rb") as f:
for sector in range(16, 16 + 128):
f.seek(sector * SECTOR)
data = f.read(SECTOR)
if len(data) != SECTOR:
break
ident = decode_ascii(data[1:6])
if ident != "CD001":
# UDF bridge images may have BEA01/NSR0x/TEA01 descriptors here.
if data[1:6] in (b"BEA01", b"NSR02", b"NSR03", b"TEA01", b"CDW02"):
result.append(VolumeDescriptor(
sector=sector,
type=data[0],
identifier=decode_ascii(data[1:6]),
version=data[6],
sha256=hashlib.sha256(data).hexdigest(),
))
continue
if result:
break
continue
vd = VolumeDescriptor(
sector=sector,
type=data[0],
identifier=ident,
version=data[6],
sha256=hashlib.sha256(data).hexdigest(),
)
if vd.type in (1, 2):
vd.volume_id = decode_ascii(data[40:72])
vd.volume_space_size = both_endian_u32(data[80:88])
vd.logical_block_size = both_endian_u16(data[128:132])
vd.path_table_size = both_endian_u32(data[132:140])
vd.l_path_table = struct.unpack_from("<I", data, 140)[0]
vd.m_path_table = struct.unpack_from(">I", data, 148)[0]
if vd.type == 2:
vd.escape_sequences_hex = data[88:120].rstrip(b"\x00").hex()
elif vd.type == 0:
vd.boot_system_id = decode_ascii(data[7:39])
if vd.boot_system_id == "EL TORITO SPECIFICATION":
vd.boot_catalog_lba = struct.unpack_from("<I", data, 71)[0]
catalog_lba = vd.boot_catalog_lba
result.append(vd)
if vd.type == 255:
break
return result, catalog_lba
def boot_image_hash(f, lba: int, sector_count: int) -> str | None:
if not lba:
return None
# El Torito's count is in 512-byte virtual sectors. A zero count is legal in
# some catalogs but gives us no bounded image length to hash.
length = sector_count * 512
if length <= 0:
return None
return sha256_region(f, lba * SECTOR, length)
def parse_boot_catalog(path: Path, catalog_lba: int | None) -> dict[str, Any] | None:
if catalog_lba is None:
return None
with path.open("rb") as f:
f.seek(catalog_lba * SECTOR)
data = f.read(SECTOR)
if len(data) != SECTOR:
raise PrototypeError(f"Cannot read El Torito catalog at LBA {catalog_lba}")
validation = data[:32]
checksum_words = struct.unpack("<16H", validation)
catalog: dict[str, Any] = {
"lba": catalog_lba,
"sha256": hashlib.sha256(data).hexdigest(),
"validation_header_id": validation[0],
"validation_platform_id": validation[1],
"validation_signature_hex": validation[30:32].hex(),
"validation_checksum_valid": (sum(checksum_words) & 0xFFFF) == 0,
"entries": [],
}
platform = validation[1]
offset = 32
entries: list[BootEntry] = []
while offset + 32 <= len(data):
entry = data[offset:offset + 32]
indicator = entry[0]
if entry == b"\x00" * 32:
break
if indicator in (0x90, 0x91):
platform = entry[1]
count = struct.unpack_from("<H", entry, 2)[0]
entries.append(BootEntry(offset, "section_header", platform, None, None,
None, None, count, None, None))
elif indicator in (0x88, 0x00):
media = entry[1]
segment = struct.unpack_from("<H", entry, 2)[0]
system_type = entry[4]
count = struct.unpack_from("<H", entry, 6)[0]
lba = struct.unpack_from("<I", entry, 8)[0]
entries.append(BootEntry(
offset=offset,
kind="boot_entry",
platform_id=platform,
bootable=indicator == 0x88,
media_type=media,
load_segment=segment,
system_type=system_type,
sector_count=count,
image_lba=lba,
image_sha256=boot_image_hash(f, lba, count),
))
elif indicator == 0x44:
entries.append(BootEntry(offset, "extension", platform, None, None,
None, None, None, None, None))
else:
entries.append(BootEntry(offset, f"unknown_0x{indicator:02x}", platform,
None, None, None, None, None, None, None))
offset += 32
catalog["entries"] = [asdict(e) for e in entries]
return catalog
def parse_udf_tags(path: Path) -> list[UdfTag]:
size = path.stat().st_size
sectors = size // SECTOR
candidates = set(range(16, min(sectors, 16 + 512)))
for base in (256, sectors - 256, sectors - 1):
if 0 <= base < sectors:
candidates.update(range(max(0, base - 4), min(sectors, base + 5)))
tags: list[UdfTag] = []
with path.open("rb") as f:
for sector in sorted(candidates):
f.seek(sector * SECTOR)
data = f.read(SECTOR)
if len(data) != SECTOR:
continue
tag_id, version, checksum, _reserved, serial, _crc, _crc_len, location = struct.unpack_from(
"<HHBBHHHI", data, 0
)
if not (1 <= tag_id <= 266 and version in (2, 3)):
continue
header = bytearray(data[:16])
header[4] = 0
if (sum(header) & 0xFF) != checksum:
continue
tags.append(UdfTag(
sector=sector,
tag_id=tag_id,
version=version,
serial=serial,
location=location,
sha256=hashlib.sha256(data).hexdigest(),
))
return tags
def run_optional(command: list[str]) -> dict[str, Any]:
try:
proc = subprocess.run(command, text=True, capture_output=True, check=False)
except FileNotFoundError:
return {"available": False}
return {
"available": True,
"returncode": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
}
def inspect_iso(path: Path) -> dict[str, Any]:
descriptors, catalog_lba = parse_volume_descriptors(path)
return {
"path": str(path),
"size": path.stat().st_size,
"sha256": sha256_file(path),
"volume_descriptors": [asdict(v) for v in descriptors],
"el_torito": parse_boot_catalog(path, catalog_lba),
"udf_tags": [asdict(t) for t in parse_udf_tags(path)],
"tools": {
"isoinfo": run_optional(["isoinfo", "-d", "-i", str(path)]),
"xorriso_el_torito": run_optional([
"xorriso", "-indev", str(path), "-report_el_torito", "plain"
]),
"xorriso_system_area": run_optional([
"xorriso", "-indev", str(path), "-report_system_area", "plain"
]),
"udfinfo": run_optional(["udfinfo", str(path)]),
},
}
def descriptor_semantic(v: dict[str, Any]) -> dict[str, Any]:
ignored = {"sector", "sha256", "l_path_table", "m_path_table", "boot_catalog_lba"}
return {k: val for k, val in v.items() if k not in ignored}
def boot_semantic(catalog: dict[str, Any] | None) -> Any:
if catalog is None:
return None
entries = []
for entry in catalog["entries"]:
entries.append({k: v for k, v in entry.items()
if k not in {"offset", "image_lba"}})
return {
"validation_header_id": catalog["validation_header_id"],
"validation_platform_id": catalog["validation_platform_id"],
"validation_signature_hex": catalog["validation_signature_hex"],
"validation_checksum_valid": catalog["validation_checksum_valid"],
"entries": entries,
}
def compare_pair(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
av = a["volume_descriptors"]
bv = b["volume_descriptors"]
return {
"byte_identical": a["sha256"] == b["sha256"],
"same_size": a["size"] == b["size"],
"volume_descriptor_bytes_identical": [v["sha256"] for v in av] == [v["sha256"] for v in bv],
"volume_descriptor_semantics_equal": [descriptor_semantic(v) for v in av] == [descriptor_semantic(v) for v in bv],
"el_torito_catalog_bytes_identical": (
a["el_torito"] is not None and b["el_torito"] is not None and
a["el_torito"]["sha256"] == b["el_torito"]["sha256"]
),
"el_torito_semantics_equal": boot_semantic(a["el_torito"]) == boot_semantic(b["el_torito"]),
"udf_tag_sequence_equal": [t["tag_id"] for t in a["udf_tags"]] == [t["tag_id"] for t in b["udf_tags"]],
"udf_tag_bytes_identical": [t["sha256"] for t in a["udf_tags"]] == [t["sha256"] for t in b["udf_tags"]],
}
def print_summary(report: dict[str, Any]) -> None:
print("ISO metadata comparison")
print("=======================")
for name, info in report["images"].items():
print(f"{name:12} {info['size']:12d} bytes {info['sha256'][:16]}{info['path']}")
print()
for pair, values in report["comparisons"].items():
print(pair)
for key, value in values.items():
print(f" {key:39} {value}")
print()
def iso_candidates(path: PurePosixPath, is_dir: bool = False) -> list[str]:
parts = []
for part in path.parts:
if part == "/":
continue
# Level 4 accepts long names, but records are conventionally uppercase.
parts.append(part.upper())
base = "/" + "/".join(parts)
if is_dir:
return [base]
return [base + ";1", base]
def find_existing_record(iso, logical: PurePosixPath) -> dict[str, str]:
found: dict[str, str] = {}
probes = {
"udf_path": [str(logical)],
"joliet_path": [str(logical)],
"iso_path": iso_candidates(logical),
}
for namespace, candidates in probes.items():
for candidate in candidates:
try:
iso.get_record(**{namespace: candidate})
except Exception: # pycdlib uses several dedicated exception classes.
continue
found[namespace] = candidate
break
return found
def namespace_kwargs(iso, logical: PurePosixPath, *, is_dir: bool = False) -> dict[str, str]:
kwargs: dict[str, str] = {"iso_path": iso_candidates(logical, is_dir=is_dir)[0]}
if iso.has_joliet():
kwargs["joliet_path"] = str(logical)
if iso.has_udf():
kwargs["udf_path"] = str(logical)
return kwargs
def ensure_parent_directories(iso, logical: PurePosixPath) -> None:
current = PurePosixPath("/")
for part in logical.parent.parts:
if part == "/":
continue
current /= part
if find_existing_record(iso, current):
continue
iso.add_directory(**namespace_kwargs(iso, current, is_dir=True))
def remove_logical(iso, logical: PurePosixPath) -> bool:
found = find_existing_record(iso, logical)
if not found:
return False
iso.rm_file(**found)
return True
def add_file_all_namespaces(iso, source: Path, logical: PurePosixPath) -> None:
ensure_parent_directories(iso, logical)
iso.add_file(str(source), **namespace_kwargs(iso, logical))
def sync_file(iso, tree: Path, logical: PurePosixPath) -> None:
source = tree / str(logical).lstrip("/")
if not source.is_file():
raise PrototypeError(f"Replacement does not exist: {source}")
removed = remove_logical(iso, logical)
if not removed:
raise PrototypeError(
f"Cannot map existing file {logical} in ISO9660, Joliet, or UDF; refusing replacement"
)
add_file_all_namespaces(iso, source, logical)
def sync_tree(iso, tree: Path, logical_root: PurePosixPath) -> None:
source_root = tree / str(logical_root).lstrip("/")
if not source_root.is_dir():
raise PrototypeError(f"Addition tree does not exist: {source_root}")
for root, dirs, files in os.walk(source_root):
root_path = Path(root)
rel = root_path.relative_to(tree)
logical_dir = PurePosixPath("/") / PurePosixPath(rel.as_posix())
ensure_parent_directories(iso, logical_dir / "placeholder")
if not find_existing_record(iso, logical_dir):
iso.add_directory(**namespace_kwargs(iso, logical_dir, is_dir=True))
for name in files:
logical = logical_dir / name
remove_logical(iso, logical)
add_file_all_namespaces(iso, root_path / name, logical)
def build_pycdlib(args: argparse.Namespace) -> None:
try:
import pycdlib # type: ignore
except ImportError as exc:
raise PrototypeError(
"The build command requires pycdlib 1.16.0 (pip install pycdlib==1.16.0)"
) from exc
source = Path(args.source).resolve()
tree = Path(args.tree).resolve()
output = Path(args.output).resolve()
if output.exists():
raise PrototypeError(f"Output already exists: {output}")
iso = pycdlib.PyCdlib()
try:
iso.open(str(source))
for value in args.replace:
sync_file(iso, tree, PurePosixPath("/") / value.lstrip("/"))
for value in args.add_tree:
sync_tree(iso, tree, PurePosixPath("/") / value.lstrip("/"))
iso.force_consistency()
iso.write(str(output))
finally:
iso.close()
print(f"Created {output}")
def compare(args: argparse.Namespace) -> None:
images: dict[str, dict[str, Any]] = {}
for name, value in (("original", args.original), ("genisoimage", args.genisoimage),
("pycdlib", args.pycdlib)):
if value:
path = Path(value).resolve()
if not path.is_file():
raise PrototypeError(f"ISO not found: {path}")
images[name] = inspect_iso(path)
comparisons: dict[str, Any] = {}
names = list(images)
for i, left in enumerate(names):
for right in names[i + 1:]:
comparisons[f"{left} -> {right}"] = compare_pair(images[left], images[right])
report = {"images": images, "comparisons": comparisons}
output = Path(args.output).resolve()
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print_summary(report)
print(f"Full report: {output}")
def make_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
build = sub.add_parser("build", help="Create an ISO by modifying the original with PyCdlib")
build.add_argument("--source", required=True, help="Original Microsoft ISO")
build.add_argument("--tree", required=True, help="Current modified extracted DVD tree")
build.add_argument("--output", required=True, help="PyCdlib output ISO")
build.add_argument("--replace", action="append", default=[],
help="File to replace from --tree (repeatable)")
build.add_argument("--add-tree", action="append", default=[],
help="Directory tree to add/synchronize from --tree (repeatable)")
build.set_defaults(func=build_pycdlib)
comp = sub.add_parser("compare", help="Compare filesystem and boot metadata")
comp.add_argument("--original", required=True)
comp.add_argument("--genisoimage", required=True)
comp.add_argument("--pycdlib", required=True)
comp.add_argument("--output", default="iso-metadata-report.json")
comp.set_defaults(func=compare)
return parser
def main() -> int:
parser = make_parser()
args = parser.parse_args()
try:
args.func(args)
except PrototypeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
except Exception as exc:
print(f"UNEXPECTED ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+59 -15
View File
@@ -15,36 +15,80 @@ CONSOLE_PID="$QEMU_DIR/console.pid"
CONSOLE_SOCKET="$QEMU_DIR/console.sock"
QEMU_START_PID="$QEMU_DIR/qemu.start.pid"
bootFailed() {
bootStatus() {
local fail=""
[ ! -s "$QEMU_PTY" ] && return 1
if [[ "${BOOT_MODE,,}" == "windows_legacy" ]]; then
grep -Fq "No bootable device." "$QEMU_PTY" && fail="y"
grep -Fq "BOOTMGR is missing" "$QEMU_PTY" && fail="y"
grep -Fq \
-e "No bootable device." \
-e "BOOTMGR is missing" \
-e "Boot failed: not a bootable disk" \
-e "Boot failed: could not read the boot disk" \
"$QEMU_PTY" && return 2
grep -Eq '^Booting from (Hard|DVD/CD)' "$QEMU_PTY"
return $?
fi
[ -n "$fail" ]
grep -Fq "UEFI Interactive Shell" "$QEMU_PTY" && return 2
local last
last=$(grep -E \
'BdsDxe: starting Boot[[:xdigit:]]{4} ' \
"$QEMU_PTY" | tail -1)
[ -z "$last" ] && return 1
grep -Eq \
-e '"Windows Boot Manager"' \
-e '"UEFI QEMU .*DVD-ROM' \
-e 'CDROM\(' \
-e 'USB\(' \
<<< "$last"
}
boot() {
waitForBoot() {
[ -f "$QEMU_END" ] && return 0
local pid="$1"
local timeout="${2:-30}"
local deadline=$((SECONDS + timeout))
local screen="visit http://127.0.0.1:$WEB_PORT/ to view the screen..."
if [ -s "$QEMU_PTY" ]; then
if [ "$(stat -c%s "$QEMU_PTY")" -gt 7 ]; then
if ! bootFailed; then
while isAlive "$pid"; do
if bootStatus; then
local status=0
else
local status=$?
fi
case "$status" in
0)
if [[ "${DISPLAY,,}" == "web" ]] && ! disabled "${WEB:-Y}"; then
info "$(app) started successfully, visit http://127.0.0.1:$WEB_PORT/ to view the screen..."
info "$(app) started successfully, $screen"
else
info "$(app) started successfully."
fi
return 0
fi
fi
fi
return 0 ;;
2)
if [[ "${DISPLAY,,}" == "web" ]] && ! disabled "${WEB:-Y}"; then
warn "$(app) could not boot, $screen"
else
warn "$(app) could not boot."
fi
return 0 ;;
esac
(( SECONDS >= deadline )) && break
sleep 0.1
done
! isAlive "$pid" && return 0
[ -f "$QEMU_END" ] && return 0
error "Timeout while waiting for QEMU to boot the machine, aborting..."
terminateQemu