diff --git a/snow_first_setup/core/backend.py b/snow_first_setup/core/backend.py index 2f7100e..64b00b1 100644 --- a/snow_first_setup/core/backend.py +++ b/snow_first_setup/core/backend.py @@ -279,3 +279,76 @@ def set_dry_run(dry: bool): def subscribe_errors(callback): global _error_subscribers _error_subscribers.append(callback) + +def run_script_streaming(name: str, args: list[str], root: bool = False, line_callback=None) -> bool: + """Execute a script and stream its output line-by-line to a callback. + + This is designed for scripts that output JSON Lines (one JSON object per line) + for real-time progress monitoring. + + Args: + name: Script name to execute + args: Arguments to pass to the script + root: Whether to run with pkexec for root privileges + line_callback: Function called with each line of output (str) + + Returns: + bool: True if script succeeded, False otherwise + """ + if dry_run: + print("dry-run (streaming)", name, args) + # Simulate some progress events for dry-run testing + import json + import time + fake_events = [ + {"type": "message", "message": "Dry run: Checking prerequisites..."}, + {"type": "step", "step": 1, "total_steps": 4, "step_name": "Creating partitions"}, + {"type": "step", "step": 2, "total_steps": 4, "step_name": "Formatting partitions"}, + {"type": "step", "step": 3, "total_steps": 4, "step_name": "Extracting filesystem"}, + {"type": "progress", "percent": 25, "message": "Layer 1/4"}, + {"type": "progress", "percent": 50, "message": "Layer 2/4"}, + {"type": "progress", "percent": 75, "message": "Layer 3/4"}, + {"type": "progress", "percent": 100, "message": "Layer 4/4"}, + {"type": "step", "step": 4, "total_steps": 4, "step_name": "Installing bootloader"}, + {"type": "complete", "message": "Installation complete (dry run)"}, + ] + for event in fake_events: + if line_callback: + line_callback(json.dumps(event)) + time.sleep(0.3) + return True + + if script_base_path is None: + print("Could not run operation", name, args, "due to missing script base path") + return False + + script_path = os.path.join(script_base_path, name) + command = [script_path] + args + if root: + command = ["pkexec"] + command + + logger.info(f"Executing streaming command: {command}") + + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + + # Read output line by line and pass to callback + for line in process.stdout: + line = line.strip() + if line and line_callback: + line_callback(line) + logger.debug(f"Stream line from {name}: {line}") + + process.wait() + + if process.returncode != 0: + report_error(name, command, f"Script exited with code {process.returncode}") + print(name, args, "returned an error (exit code", process.returncode, ")") + return False + + return True \ No newline at end of file diff --git a/snow_first_setup/scripts/install-to-disk b/snow_first_setup/scripts/install-to-disk index 760358c..eb7a5e7 100755 --- a/snow_first_setup/scripts/install-to-disk +++ b/snow_first_setup/scripts/install-to-disk @@ -1,223 +1,55 @@ #!/bin/bash set -euo pipefail -# Cleanup function to ensure mounted filesystems are unmounted -cleanup() { - local exit_code=$? - if [ -n "${MOUNTPOINT:-}" ] && [ -d "$MOUNTPOINT" ]; then - if mountpoint -q "$MOUNTPOINT" 2>/dev/null; then - echo "Unmounting $MOUNTPOINT..." - umount "$MOUNTPOINT" 2>/dev/null || true - fi - rmdir "$MOUNTPOINT" 2>/dev/null || true - fi - if [ $exit_code -ne 0 ]; then - echo "Script failed with exit code $exit_code" >&2 - fi - exit $exit_code -} - -# Set up trap to call cleanup on exit -trap cleanup EXIT INT TERM +# Install script using nbc (SNOW bootc installer) +# Outputs JSON Lines for streaming progress to the installer GUI if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then - echo "usage:" - echo "install-to-disk [fde]" + echo '{"type":"error","message":"Missing arguments. Usage: install-to-disk [fde]"}' exit 5 fi if ! [ "$UID" == "0" ]; then - echo "this script must be run with super user privileges" + echo '{"type":"error","message":"This script must be run with super user privileges"}' exit 6 fi - if ! [ -b "$3" ]; then - echo "device '$3' is not a valid block device" + echo '{"type":"error","message":"Device '"'$3'"' is not a valid block device"}' exit 9 fi -# Parse FDE parameters +IMAGE="$1" +FILESYSTEM="$2" +DEVICE="$3" FDE="${4:-false}" # Validate FDE parameter if [ "$FDE" != "true" ] && [ "$FDE" != "false" ]; then - echo "fde parameter must be 'true' or 'false', got: $FDE" + echo '{"type":"error","message":"FDE parameter must be '"'true'"' or '"'false'"', got: '"$FDE"'"}' exit 7 fi -BLOCKSETUP="direct" -if [ "$FDE" == "true" ]; then - BLOCKSETUP="tpm2-luks" -fi - -# if FDE is requested, verify that there is a tpm2 device available -if [ "$FDE" == "true" ]; then - if ! [ -e /dev/tpm0 ] && ! [ -e /dev/tpmrm0 ]; then - echo "FDE with tpm2-luks requested, but no TPM2 device found" - exit 8 - fi -fi - -# Check that bootc is available -if ! command -v bootc &> /dev/null; then - echo "bootc command not found, cannot proceed with installation" +# Check that nbc is available +if ! command -v nbc &> /dev/null; then + echo '{"type":"error","message":"nbc command not found, cannot proceed with installation"}' exit 14 fi -echo "Starting bootc installation to $3..." -if ! RUST_LOG=debug bootc \ - install \ - to-disk \ - --composefs-backend \ - --block-setup "$BLOCKSETUP" \ - --filesystem "$2" \ - --source-imgref docker://"$1" \ - --target-imgref "$1" \ - --wipe \ - --bootloader systemd \ - --karg "quiet" \ - --karg "splash" \ - "$3"; then - echo "bootc installation failed" - exit 15 -fi -echo "bootc installation completed successfully" - -# HACK: fix secure boot in bootc -# now that the install is done, we can fix the efi binaries -# to support secure boot. -# This is a workaround for the fact that the bootc installs -# systemd-boot only. What we require is the signed-shim as -# the first efi binary, which then loads systemd-boot. -# shim is hard-coded to load grub, so we'll "fix" that by copying the -# systemd-boot binaries into the right place with grub's name. -# HACK ALERT! We're copying the efi binaries from the installer image -# to the target image. This is not ideal, but it works for now. - -# Mount the EFI partition from the target device ($3) -# EFI partition is the second partition, so we use partprobe -# to ensure the kernel sees it -echo "Probing partitions on $3..." -if ! partprobe "$3"; then - echo "Failed to probe partitions on $3" - exit 16 -fi - -# Give the kernel a moment to recognize the new partitions -sleep 2 - -DEVICE="$3" - -# adjust partition names for devices that require 'p' before partition number -if [[ "$DEVICE" == *"nvme"* || "$DEVICE" == *"mmcblk"* || "$DEVICE" == *"loop"* ]]; then - DEVICE="${DEVICE}p" -fi - -EFI_PARTITION="${DEVICE}2" - -# Verify the EFI partition exists -if ! [ -b "$EFI_PARTITION" ]; then - echo "EFI partition $EFI_PARTITION does not exist or is not a block device" - exit 17 -fi - -echo "Creating temporary mount point..." -MOUNTPOINT=$(mktemp -d) -if [ ! -d "$MOUNTPOINT" ]; then - echo "Failed to create temporary mount point" - exit 18 -fi +# Build nbc install command with --json for streaming output +NBC_ARGS=( + "install" + "--image" "$IMAGE" + "--device" "$DEVICE" + "--filesystem" "$FILESYSTEM" + "--json" +) -echo "Mounting EFI partition $EFI_PARTITION to $MOUNTPOINT..." -if ! mount "$EFI_PARTITION" "$MOUNTPOINT"; then - echo "Failed to mount EFI partition $EFI_PARTITION" - rmdir "$MOUNTPOINT" 2>/dev/null || true - exit 19 -fi - - -if [ ! -d "$MOUNTPOINT/EFI/BOOT" ]; then - echo "Creating $MOUNTPOINT/EFI/BOOT directory..." - if ! mkdir -p "$MOUNTPOINT/EFI/BOOT"; then - echo "Failed to create EFI/BOOT directory" - exit 20 - fi -fi - -# make sure the source files exists -echo "Verifying source EFI files..." -if [ ! -f /usr/lib/systemd/boot/efi/systemd-bootx64.efi.signed ]; then - echo "systemd-bootx64.efi.signed not found, cannot copy to EFI partition" - exit 10 -fi -if [ ! -f /usr/lib/shim/shimx64.efi.signed ]; then - echo "shimx64.efi.signed not found, cannot copy to EFI partition" - exit 11 -fi -if [ ! -f /usr/lib/shim/fbx64.efi.signed ]; then - echo "fbx64.efi.signed not found, cannot copy to EFI partition" - exit 12 -fi -if [ ! -f /usr/lib/shim/mmx64.efi.signed ]; then - echo "mmx64.efi.signed not found, cannot copy to EFI partition" - exit 13 -fi - -# replicate a debian secureboot efi setup -echo "Creating EFI/snow directory..." -if ! mkdir -p "$MOUNTPOINT/EFI/snow"; then - echo "Failed to create EFI/snow directory" - exit 21 -fi - -echo "Copying secure boot EFI binaries..." -if ! cp /usr/lib/shim/shimx64.efi.signed "$MOUNTPOINT/EFI/snow/shimx64.efi"; then - echo "Failed to copy shimx64.efi" - exit 22 -fi -if ! cp /usr/lib/shim/fbx64.efi.signed "$MOUNTPOINT/EFI/snow/fbx64.efi"; then - echo "Failed to copy fbx64.efi" - exit 23 -fi -if ! cp /usr/lib/shim/mmx64.efi.signed "$MOUNTPOINT/EFI/snow/mmx64.efi"; then - echo "Failed to copy mmx64.efi" - exit 24 -fi -if ! cp /usr/lib/systemd/boot/efi/systemd-bootx64.efi.signed "$MOUNTPOINT/EFI/snow/grubx64.efi"; then - echo "Failed to copy systemd-bootx64.efi as grubx64.efi" - exit 25 -fi - -# create a new boot entry for shim -echo "Creating EFI boot entry..." -if command -v efibootmgr &> /dev/null; then - if ! efibootmgr --create --disk "$3" --part 2 --loader '\EFI\snow\shimx64.efi' --label "Snow Secure Boot"; then - echo "Warning: Failed to create EFI boot entry (continuing anyway)" - fi -else - echo "Warning: efibootmgr not found, skipping boot entry creation" -fi - -# finally uncomment the line in loader.conf that sets the timeout -# so that the boot menu appears, allowing the user to edit the kargs -# if needed to unlock the disk -if [ -f "$MOUNTPOINT/loader/loader.conf" ]; then - echo "Configuring bootloader timeout..." - if ! sed -i 's/^#timeout/timeout/' "$MOUNTPOINT/loader/loader.conf"; then - echo "Warning: Failed to update loader.conf (continuing anyway)" - fi -else - echo "Warning: loader.conf not found at $MOUNTPOINT/loader/loader.conf" -fi - -# clean up -echo "Unmounting EFI partition..." -if ! umount "$MOUNTPOINT"; then - echo "Warning: Failed to unmount $MOUNTPOINT cleanly" - # Try force unmount as last resort - umount -f "$MOUNTPOINT" 2>/dev/null || true +# Add FDE flag if enabled +if [ "$FDE" == "true" ]; then + NBC_ARGS+=("--fde") fi -rmdir "$MOUNTPOINT" 2>/dev/null || true -echo "Installation completed successfully!" \ No newline at end of file +# Run nbc install with JSON output streaming directly to stdout +# nbc outputs JSON Lines (one JSON object per line) for real-time progress +exec nbc "${NBC_ARGS[@]}" \ No newline at end of file diff --git a/snow_first_setup/views/install_progress.py b/snow_first_setup/views/install_progress.py index d92a33d..47dd05c 100644 --- a/snow_first_setup/views/install_progress.py +++ b/snow_first_setup/views/install_progress.py @@ -1,5 +1,6 @@ # install_progress.py +import json import threading import time @@ -23,6 +24,9 @@ def __init__(self, window, **kwargs): self.__started = False self.__finished = False self.__success = False + self.__current_step = 0 + self.__total_steps = 0 + self.__has_progress = False # True when we have percentage-based progress # Debug: verify resource presence and child realization try: @@ -54,19 +58,91 @@ def finish(self): return self.__finished and self.__success def __pulse_progress(self): - if not self.__finished: + # Only pulse if we don't have percentage-based progress + if not self.__finished and not self.__has_progress: try: self.progress_bar.pulse() except Exception as e: print("[DEBUG] pulse failed:", e) return True - return False + return not self.__finished # Keep running but don't pulse if we have percentage def __start_install_thread(self): print("[DEBUG] Starting install thread") thread = threading.Thread(target=self.__run_install, daemon=True) thread.start() + def __handle_json_line(self, line: str): + """Parse and handle a single JSON line from nbc install output.""" + try: + event = json.loads(line) + except json.JSONDecodeError: + # Not valid JSON, just log it + print(f"[DEBUG] Non-JSON output: {line}") + return + + event_type = event.get("type", "") + + if event_type == "step": + step = event.get("step", 0) + total = event.get("total_steps", 0) + step_name = event.get("step_name", "") + self.__current_step = step + self.__total_steps = total + + # Update progress bar based on steps if no percentage progress + if total > 0 and not self.__has_progress: + fraction = step / total + GLib.idle_add(self.progress_bar.set_fraction, fraction) + + # Update detail label with step info + detail_text = f"[{step}/{total}] {step_name}" + GLib.idle_add(self.detail_label.set_text, detail_text) + + elif event_type == "progress": + percent = event.get("percent", 0) + message = event.get("message", "") + self.__has_progress = True + + # Update progress bar with percentage + fraction = percent / 100.0 + GLib.idle_add(self.progress_bar.set_fraction, fraction) + + # Update detail with progress message + if message: + if self.__total_steps > 0: + detail_text = f"[{self.__current_step}/{self.__total_steps}] {message} ({percent}%)" + else: + detail_text = f"{message} ({percent}%)" + GLib.idle_add(self.detail_label.set_text, detail_text) + + elif event_type == "message": + message = event.get("message", "") + if message: + GLib.idle_add(self.detail_label.set_text, message) + + elif event_type == "warning": + message = event.get("message", "") + print(f"[WARNING] nbc: {message}") + # Optionally show warning in UI + if message: + GLib.idle_add(self.detail_label.set_text, f"⚠ {message}") + + elif event_type == "error": + message = event.get("message", "") + details = event.get("details", {}) + error_detail = details.get("error", "") + print(f"[ERROR] nbc: {message} - {error_detail}") + error_text = message + if error_detail: + error_text = f"{message}: {error_detail}" + GLib.idle_add(self.__mark_finished, False, error_text) + + elif event_type == "complete": + message = event.get("message", _("Installation complete.")) + GLib.idle_add(self.progress_bar.set_fraction, 1.0) + GLib.idle_add(self.__mark_finished, True, message) + def __run_install(self): device = getattr(self.__window, "install_target_device", None) fs = getattr(self.__window, "install_target_fs", None) @@ -78,18 +154,27 @@ def __run_install(self): GLib.idle_add(self.__mark_finished, False, _("Missing installation parameters.")) return - GLib.idle_add(self.detail_label.set_text, _("Writing image to disk…")) + GLib.idle_add(self.detail_label.set_text, _("Preparing installation…")) # Build script arguments with FDE parameters script_args = [image, fs, device, "true" if fde_enabled else "false"] - success = backend.run_script("install-to-disk", script_args, root=True) + # Use streaming script runner to get real-time JSON updates + success = backend.run_script_streaming( + "install-to-disk", + script_args, + root=True, + line_callback=self.__handle_json_line + ) + # Handle Snowfield image special case if success and "snowfield" in image: print("[DEBUG] __run_install: Snowfield image selected, importing Surface Linux secure boot key") backend.run_script("enroll-key", ["/usr/share/linux-surface-secureboot/surface.cer"], root=True) - GLib.idle_add(self.__mark_finished, success, _("Installation complete." if success else _("Installation failed."))) + # If we didn't get a complete event but the process succeeded, mark as finished + if not self.__finished: + GLib.idle_add(self.__mark_finished, success, _("Installation complete." if success else _("Installation failed."))) def __mark_finished(self, success: bool, message: str): print("[DEBUG] __mark_finished called; success=", success, "message=", message) @@ -97,6 +182,8 @@ def __mark_finished(self, success: bool, message: str): self.__success = success try: self.detail_label.set_text(message) + if success: + self.progress_bar.set_fraction(1.0) except Exception as e: print("[DEBUG] Failed to set finish message:", e) self.__window.set_ready(success)