From 015714f7133e1e343c8598395a9cf1e908a8d9ae Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Sat, 23 May 2026 21:56:51 -0300 Subject: [PATCH] vmix CLI, laptop images, SDL display CLI (nix run .#): - `vmix build` and `vmix copy` subcommands - --image, --generalize key=val, --to-disk, --to-remote-disk - SDL display auto-detected via DISPLAY temp file passthrough - --print-build-logs for visible build progress - -S 4k sparse writes for faster disk copy Images: - win10.laptop and win11.laptop bundles (no VirtIO, keeps defender/hibernation) - templates.bundles.laptop shared template list - win11 adds reg.disableUCPD on top Build improvements: - consistent === vmix: === log prefixes - SDL display via /tmp/.vmix-display-$$ temp file Env helpers: - .env-export-vmix-cli-local: vmix alias for local flake Co-Authored-By: Claude Opus 4.6 (1M context) --- .env-export-vmix-cli-local | 2 + flake.lock | 12 +- flake.nix | 210 ++++++++++++++---- lib/images/windows/helpers/customizeImage.nix | 16 +- lib/images/windows/helpers/makeImage.nix | 21 +- lib/images/windows/templates/default.nix | 28 ++- lib/images/windows/win10/images.nix | 6 + lib/images/windows/win11/images.nix | 8 + 8 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 .env-export-vmix-cli-local diff --git a/.env-export-vmix-cli-local b/.env-export-vmix-cli-local new file mode 100644 index 0000000..49db03e --- /dev/null +++ b/.env-export-vmix-cli-local @@ -0,0 +1,2 @@ +export VMIX_FLAKE="path:$PWD" +alias vmix='nix run "$VMIX_FLAKE" --' diff --git a/flake.lock b/flake.lock index 5ab4db7..6898726 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1715534503, - "narHash": "sha256-5ZSVkFadZbFP1THataCaSf0JH2cAH3S29hU9rrxTEqk=", - "owner": "nixos", + "lastModified": 1779102034, + "narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=", + "owner": "NixOS", "repo": "nixpkgs", - "rev": "2057814051972fa1453ddfb0d98badbea9b83c06", + "rev": "687f05a9184cad4eaf905c48b63649e3a86f5433", "type": "github" }, "original": { - "owner": "nixos", - "ref": "nixos-unstable", + "owner": "NixOS", + "ref": "nixos-25.11", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index efb2bad..2dbd6de 100644 --- a/flake.nix +++ b/flake.nix @@ -21,61 +21,154 @@ lib.${system} = vmixLib; - packages.${system} = { - toDisk = pkgs.writeShellScriptBin "vmix-to-disk" '' - set -euo pipefail + packages.${system}.default = pkgs.writeShellScriptBin "vmix" '' + set -euo pipefail - usage() { - echo "Usage: vmix-to-disk " - echo "" - echo "Write a vmix qcow2 image to a physical disk and expand the Windows partition." - echo "" - echo "Examples:" - echo " vmix-to-disk /nix/store/...-win10-ltsc-vmix.qcow2 /dev/sda" - echo " vmix-to-disk ./my-image.qcow2 /dev/nvme0n1" - exit 1 - } + usage() { + echo "Usage:" + echo " vmix build --image [--generalize key=val,...] [--out-link PATH]" + echo " vmix copy --image [--generalize key=val,...] --to-disk /dev/sdX" + echo " vmix copy --image [--generalize key=val,...] --to-remote-disk user@host:/dev/sdX" + echo "" + echo "Commands:" + echo " build Build a vmix image (optionally generalized)" + echo " copy Build a vmix image and write it to a disk" + echo "" + echo "Options:" + echo " --image PATH Image path in vmixLib (e.g. windows.images.win10.laptop)" + echo " --generalize KEY=VAL,... Generalize with comma-separated options:" + echo " username=User password= hostname=WIN-PC" + echo " timezone=UTC bgColor=8e8cd8" + echo " --to-disk DEVICE Write to local disk and expand partitions" + echo " --to-remote-disk SSH:DEV Stream to remote disk via SSH and expand partitions" + echo " e.g. root@10.10.10.100:/dev/sda" + echo " --out-link PATH Symlink for the build result (default: ./result)" + echo "" + echo "Examples:" + echo " vmix build --image windows.images.win10.laptop \\" + echo " --generalize username=Sagar,password=secret,hostname=LAPTOP" + echo "" + echo " vmix copy --image windows.images.win10.laptop \\" + echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\" + echo " --to-disk /dev/sda" + echo "" + echo " vmix copy --image windows.images.win10.laptop \\" + echo " --generalize username=Sagar,password=secret,hostname=LAPTOP \\" + echo " --to-remote-disk root@10.10.10.100:/dev/nvme0n1" + exit 1 + } - [[ ''${#} -ne 2 ]] && usage + [[ ''${#} -eq 0 ]] && usage - IMAGE="$1" - DISK="$2" + COMMAND="$1"; shift - [[ $EUID -ne 0 ]] && { echo "Error: must run as root"; exit 1; } - [[ ! -f "$IMAGE" ]] && { echo "Error: image not found: $IMAGE"; exit 1; } - [[ ! -b "$DISK" ]] && { echo "Error: not a block device: $DISK"; exit 1; } + case "$COMMAND" in + build|copy) ;; + --help|-h) usage ;; + *) echo "Unknown command: $COMMAND"; usage ;; + esac - if ${pkgs.util-linux}/bin/mount | grep -q "^''${DISK}"; then - echo "Error: $DISK has mounted partitions — unmount first" + IMAGE_NAME="" + GENERALIZE="" + TO_DISK="" + TO_REMOTE_DISK="" + OUT_LINK="./result" + + while [[ ''${#} -gt 0 ]]; do + case "$1" in + --image) IMAGE_NAME="$2"; shift 2 ;; + --generalize) GENERALIZE="$2"; shift 2 ;; + --to-disk) TO_DISK="$2"; shift 2 ;; + --to-remote-disk) TO_REMOTE_DISK="$2"; shift 2 ;; + --out-link) OUT_LINK="$2"; shift 2 ;; + --help|-h) usage ;; + *) echo "Unknown option: $1"; usage ;; + esac + done + + [[ -z "$IMAGE_NAME" ]] && { echo "Error: --image is required"; usage; } + + if [[ "$COMMAND" == "copy" && -z "$TO_DISK" && -z "$TO_REMOTE_DISK" ]]; then + echo "Error: copy requires --to-disk or --to-remote-disk" + usage + fi + + # parse --generalize key=val,key=val into nix attrs + GENERALIZE_EXPR="" + if [[ -n "$GENERALIZE" ]]; then + NIX_ARGS="" + IFS=',' read -ra PAIRS <<< "$GENERALIZE" + for pair in "''${PAIRS[@]}"; do + key="''${pair%%=*}" + val="''${pair#*=}" + NIX_ARGS+="$key = \"$val\"; " + done + GENERALIZE_EXPR=".generalize { $NIX_ARGS }" + fi + + FLAKE_DIR="${self}" + + echo "=== vmix $COMMAND ===" + echo "Image: $IMAGE_NAME" + [[ -n "$GENERALIZE" ]] && echo "Generalize: $GENERALIZE" + [[ -n "$TO_DISK" ]] && echo "To disk: $TO_DISK" + [[ -n "$TO_REMOTE_DISK" ]] && echo "To remote: $TO_REMOTE_DISK" + echo "" + + # pass DISPLAY to nix builds via temp file (daemon sanitizes env vars) + VMIX_DISPLAY_FILE="/tmp/.vmix-display-$$" + if [[ -n "''${DISPLAY:-}" ]]; then + echo "$DISPLAY" > "$VMIX_DISPLAY_FILE" + chmod 666 "$VMIX_DISPLAY_FILE" + fi + trap 'rm -f "$VMIX_DISPLAY_FILE"' EXIT + + echo "Building image..." + ${pkgs.nix}/bin/nix build --out-link "$OUT_LINK" --print-build-logs --impure --expr " + let + vmixLib = (builtins.getFlake \"$FLAKE_DIR\").lib.${system}; + image = vmixLib.$IMAGE_NAME; + in image$GENERALIZE_EXPR + " + + IMAGE_FILE=$(readlink -f "$OUT_LINK") + echo "Built: $IMAGE_FILE" + + if [[ -n "$TO_DISK" ]]; then + echo "" + + [[ $EUID -ne 0 ]] && { echo "Error: --to-disk requires root"; exit 1; } + [[ ! -b "$TO_DISK" ]] && { echo "Error: not a block device: $TO_DISK"; exit 1; } + + if ${pkgs.util-linux}/bin/mount | grep -q "^''${TO_DISK}"; then + echo "Error: $TO_DISK has mounted partitions — unmount first" exit 1 fi - DISK_SIZE=$(${pkgs.util-linux}/bin/blockdev --getsize64 "$DISK") + DISK_SIZE=$(${pkgs.util-linux}/bin/blockdev --getsize64 "$TO_DISK") DISK_SIZE_GB=$(( DISK_SIZE / 1024 / 1024 / 1024 )) - echo "=== vmix to physical disk ===" - echo "Image: $IMAGE" - echo "Target: $DISK ($DISK_SIZE_GB GB)" + echo "=== writing to disk ===" + echo "Target: $TO_DISK ($DISK_SIZE_GB GB)" echo "" - echo "WARNING: This will DESTROY all data on $DISK" + echo "WARNING: This will DESTROY all data on $TO_DISK" read -rp "Continue? [y/N] " confirm [[ "$confirm" != "y" && "$confirm" != "Y" ]] && { echo "Aborted."; exit 0; } echo "" echo "[1/4] Writing image to disk..." - ${pkgs.qemu}/bin/qemu-img convert -p -f qcow2 -O raw "$IMAGE" "$DISK" + ${pkgs.qemu}/bin/qemu-img convert -p -S 4k -f qcow2 -O raw "$IMAGE_FILE" "$TO_DISK" echo "[2/4] Fixing GPT backup header..." - ${pkgs.gptfdisk}/bin/sgdisk -e "$DISK" + ${pkgs.gptfdisk}/bin/sgdisk -e "$TO_DISK" echo "[3/4] Expanding Windows partition (partition 3) to fill disk..." - ${pkgs.parted}/bin/parted -s "$DISK" resizepart 3 100% + ${pkgs.parted}/bin/parted -s "$TO_DISK" resizepart 3 100% - # detect partition device name (nvme/mmcblk use p3, others use 3) - if [[ "$DISK" == *nvme* ]] || [[ "$DISK" == *mmcblk* ]]; then - WIN_PART="''${DISK}p3" + if [[ "$TO_DISK" == *nvme* ]] || [[ "$TO_DISK" == *mmcblk* ]]; then + WIN_PART="''${TO_DISK}p3" else - WIN_PART="''${DISK}3" + WIN_PART="''${TO_DISK}3" fi echo "[4/4] Expanding NTFS filesystem on $WIN_PART..." @@ -83,14 +176,53 @@ echo "y" | ${pkgs.ntfs3g}/bin/ntfsresize --force "$WIN_PART" echo "" - echo "Done. Windows partition expanded to fill $DISK." - echo "You can now boot from the disk." - ''; - }; + echo "Done. $TO_DISK is ready to boot." + fi - apps.${system}.toDisk = { + if [[ -n "$TO_REMOTE_DISK" ]]; then + echo "" + + REMOTE_HOST="''${TO_REMOTE_DISK%%:*}" + REMOTE_DISK="''${TO_REMOTE_DISK#*:}" + + [[ -z "$REMOTE_HOST" || -z "$REMOTE_DISK" ]] && { echo "Error: --to-remote-disk format is user@host:/dev/sdX"; exit 1; } + + echo "=== writing to remote disk ===" + echo "Host: $REMOTE_HOST" + echo "Disk: $REMOTE_DISK" + echo "" + echo "WARNING: This will DESTROY all data on $REMOTE_HOST:$REMOTE_DISK" + read -rp "Continue? [y/N] " confirm + [[ "$confirm" != "y" && "$confirm" != "Y" ]] && { echo "Aborted."; exit 0; } + + echo "" + echo "[1/4] Streaming image to remote disk..." + ${pkgs.qemu}/bin/qemu-img convert -f qcow2 -O raw "$IMAGE_FILE" /dev/stdout \ + | ssh "$REMOTE_HOST" "dd of=$REMOTE_DISK bs=4M status=progress oflag=direct" + + echo "[2/4] Fixing GPT backup header..." + ssh "$REMOTE_HOST" "sgdisk -e $REMOTE_DISK" + + echo "[3/4] Expanding Windows partition (partition 3) to fill disk..." + ssh "$REMOTE_HOST" "parted -s $REMOTE_DISK resizepart 3 100%" + + if [[ "$REMOTE_DISK" == *nvme* ]] || [[ "$REMOTE_DISK" == *mmcblk* ]]; then + REMOTE_WIN_PART="''${REMOTE_DISK}p3" + else + REMOTE_WIN_PART="''${REMOTE_DISK}3" + fi + + echo "[4/4] Expanding NTFS filesystem on $REMOTE_WIN_PART..." + ssh "$REMOTE_HOST" "echo y | ntfsresize --force $REMOTE_WIN_PART" + + echo "" + echo "Done. $REMOTE_HOST:$REMOTE_DISK is ready to boot." + fi + ''; + + apps.${system}.default = { type = "app"; - program = "${self.packages.${system}.toDisk}/bin/vmix-to-disk"; + program = "${self.packages.${system}.default}/bin/vmix"; }; }; } diff --git a/lib/images/windows/helpers/customizeImage.nix b/lib/images/windows/helpers/customizeImage.nix index ef626f4..08b8a25 100644 --- a/lib/images/windows/helpers/customizeImage.nix +++ b/lib/images/windows/helpers/customizeImage.nix @@ -62,6 +62,8 @@ cdromArgs = lib.concatMapStringsSep " \\\n " (cd: "-drive file=${cd},media=cdrom,readonly=on") cdroms; + displayArg = if vncDisplay != null then "-vnc ${vncDisplay}" else null; + auditBootCommands = lib.optionalString hasAuditScript '' echo "=== vmix: injecting audit script (${name}) ===" @@ -77,9 +79,19 @@ cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd chmod +w vars.fd - echo "=== vmix: booting Audit Mode for ${name} (VNC: ${if vncDisplay != null then vncDisplay else "disabled"}) ===" + VMIX_DISPLAY="-nographic" + ${lib.optionalString (displayArg != null) ''VMIX_DISPLAY="${displayArg}"''} + ${lib.optionalString (displayArg == null) '' + VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1) + if [ -n "$VMIX_DF" ]; then + export DISPLAY=$(cat "$VMIX_DF") + VMIX_DISPLAY="-display sdl" + fi + ''} + + echo "=== vmix: booting Audit Mode for ${name} ===" timeout 1800 qemu-system-x86_64 \ - ${if vncDisplay != null then "-vnc ${vncDisplay}" else "-nographic"} \ + $VMIX_DISPLAY \ -accel kvm \ -m ${toString memSize} \ -smp ${toString smp} \ diff --git a/lib/images/windows/helpers/makeImage.nix b/lib/images/windows/helpers/makeImage.nix index fa1014f..67084b1 100644 --- a/lib/images/windows/helpers/makeImage.nix +++ b/lib/images/windows/helpers/makeImage.nix @@ -30,24 +30,35 @@ let inherit AutounattendedXml; }; + displayArg = if vncDisplay != null then "-vnc ${vncDisplay}" else null; + drv = pkgs.runCommand "${name}-vmix.qcow2" { __noChroot = true; requiredSystemFeatures = [ "kvm" ]; nativeBuildInputs = with pkgs; [ qemu ]; } '' - # create empty disk + echo "=== vmix: creating ${diskSize} disk ===" qemu-img create -f qcow2 disk.qcow2 ${diskSize} - # writable UEFI NVRAM so boot order persists across reboots cp ${pkgs.OVMF.fd}/FV/OVMF_VARS.fd vars.fd chmod +w vars.fd - echo "=== Starting Windows unattended install (this takes 15-30 minutes) ===" + echo "=== vmix: installing ${name} (unattended, 5-10 min) ===" + + VMIX_DISPLAY="-nographic" + ${lib.optionalString (displayArg != null) ''VMIX_DISPLAY="${displayArg}"''} + ${lib.optionalString (displayArg == null) '' + VMIX_DF=$(ls -t /tmp/.vmix-display-* 2>/dev/null | head -1) + if [ -n "$VMIX_DF" ]; then + export DISPLAY=$(cat "$VMIX_DF") + VMIX_DISPLAY="-display sdl" + fi + ''} # Windows installs unattended, reboots into Audit Mode, # deletes cached Autounattend, shuts down → QEMU exits. timeout 3600 qemu-system-x86_64 \ - ${if vncDisplay != null then "-vnc ${vncDisplay}" else "-nographic"} \ + $VMIX_DISPLAY \ -accel kvm \ -m ${toString memSize} \ -smp ${toString smp} \ @@ -63,7 +74,7 @@ let -drive file=${drivers.virtio-iso},media=cdrom,readonly=on \ -nic user,model=virtio-net-pci - echo "=== Windows install complete (Audit Mode image) ===" + echo "=== vmix: ${name} install complete ===" mv disk.qcow2 $out ''; in drv // { _vmixOsType = "windows"; } diff --git a/lib/images/windows/templates/default.nix b/lib/images/windows/templates/default.nix index a23c7ed..c13e39c 100644 --- a/lib/images/windows/templates/default.nix +++ b/lib/images/windows/templates/default.nix @@ -7,7 +7,7 @@ { pkgs, lib, system, drivers, makeFilesISO, ... }: let args = { inherit pkgs lib system drivers makeFilesISO; }; -in { +in rec { # Essentials (drivers, runtimes, removals, performance) essentials = { virtioTools = import ./essentials/virtio-tools.nix args; @@ -40,4 +40,30 @@ in { # Offline registry templates reg = import ./registry args; + + # Bundles — reusable template lists for common use cases + bundles = { + laptop = [ + essentials.removeIE + essentials.removeWMP + essentials.removeEdge + essentials.vcppRuntimes + essentials.bestPerformance + reg.disableTelemetry + reg.disableErrorReporting + reg.disableUpdates + reg.disableSmartScreen + reg.disablePrivacyTracking + reg.disableAI + reg.disableConsumerFeatures + reg.performanceTweaks + apps.edgeWebview + apps.thorium + apps.sandboxie + apps.sevenZip + apps.vlc + apps.imageGlass + apps.office + ]; + }; } diff --git a/lib/images/windows/win10/images.nix b/lib/images/windows/win10/images.nix index 830d96c..9105a46 100644 --- a/lib/images/windows/win10/images.nix +++ b/lib/images/windows/win10/images.nix @@ -31,4 +31,10 @@ with windows; withAMDGPU = customizeImage basic templates.essentials.amdGpuDrivers; }; + + laptop = customizeImageFold (makeImage { + name = "win10-ltsc-2021-laptop"; + upstreamISO = upstreamISOs.win10-ltsc-2021; + productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D"; + }) templates.bundles.laptop; } diff --git a/lib/images/windows/win11/images.nix b/lib/images/windows/win11/images.nix index ef2b6cb..00a0743 100644 --- a/lib/images/windows/win11/images.nix +++ b/lib/images/windows/win11/images.nix @@ -35,4 +35,12 @@ with windows; withAMDGPU = customizeImage basic templates.essentials.amdGpuDrivers; }; + + laptop = customizeImageFold (makeImage { + name = "win11-ltsc-2024-laptop"; + upstreamISO = upstreamISOs.win11-ltsc-2024; + productKey = "M7XTQ-FN8P6-TTKYV-9D4CC-J462D"; + bypassRequirements = true; + windowsVersionForVirtioDrivers = "w11"; + }) (templates.bundles.laptop ++ [ templates.reg.disableUCPD ]); }