#!/bin/bash # omarchy:summary=Install, launch, stop, inspect, or remove the Windows VM # omarchy:args= [options] # omarchy:requires-sudo=true # The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so # it needs the root-owned Docker daemon. By default the user is NOT in the docker # group (that group is root-equivalent), so Docker access is gated behind a # single polkit prompt. If the user opted into sudoless Docker # (omarchy-setup-security-sudoless-docker), the socket is reachable directly and # no prompt appears. # # The compose file lives in a root-owned directory and is only ever written by # the elevated, input-validated write_compose action below. That is the whole # point: a root-invoked `docker compose up` must never consume a file that a # process running as the user could have rewritten to bind-mount / into the # container. Earlier versions kept it under ~/.config/windows, which a rogue # process could edit and then trigger a privileged bring-up of — a file-swap # path to root. Do not move it back under $HOME. RUNTIME_DIR="${OMARCHY_WINDOWS_DIR:-/var/lib/omarchy/windows}" COMPOSE_FILE="$RUNTIME_DIR/docker-compose.yml" LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" # The guest password lives in the root-owned compose (readable by root and the # docker group), but RDP needs it as the user. Keep a private copy here, 0600 in # the user's own config, so the plaintext password is never world-readable. CREDENTIALS_FILE="$HOME/.config/windows/credentials" IMAGE="dockurr/windows" CONTAINER="omarchy-windows" # --- privilege helpers ------------------------------------------------------- # True when the user can reach the Docker socket directly (sudoless Docker on). in_docker_group() { id -nG 2>/dev/null | grep -qw docker; } # The command to hand pkexec for the privileged re-exec. pkexec runs whatever # executable it is given (after authorization) and only shows the path in the # prompt — it does NOT require the target to be root-owned. So resolve to the # packaged command and refuse to elevate anything a non-root user could have # written: a PATH-injected shim, or a user-owned dev checkout. Without this, a # prompt the user grants for the trusted helper could run an attacker's binary # as root. Fails closed (empty output) when no trustworthy target is found. priv_target() { local candidate owner mode for candidate in /usr/bin/omarchy-windows-vm "$(type -P omarchy-windows-vm 2>/dev/null)"; do [[ -n $candidate && -x $candidate ]] || continue owner=$(stat -Lc '%u' "$candidate" 2>/dev/null) || continue mode=$(stat -Lc '%a' "$candidate" 2>/dev/null) || continue [[ $owner == "0" ]] || continue ((8#$mode & 022)) && continue # writable by group or other -> reject printf '%s\n' "$candidate" return 0 done return 1 } # Run a privileged VM action. write_compose always elevates (the compose is # root-owned); the daemon operations run directly when sudoless Docker is on and # otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec # policy is auth_admin (not auth_admin_keep), so each elevated action prompts: # a launch asks once to start and, unless authorization is still cached by the # agent, again to stop. priv() { local action="$1" shift if [[ $action != write_compose ]] && in_docker_group; then "__priv_$action" "$@" return fi local target target=$(priv_target) || { echo "omarchy-windows-vm: refusing to run a non-root-owned command as root" >&2 return 1 } pkexec "$target" __priv "$action" "$@" } dc() { docker-compose -f "$COMPOSE_FILE" "$@"; } # --- validation (shared by the user-side prompts and the root-side writer) ---- valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; } valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); } valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } valid_path() { [[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1 # Reject non-normalized paths: a . or .. component canonicalizes at mount time # (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory — # host / included — into the guest. Volumes must be given already-normalized. case "$1" in *//* | */./* | */../* | */. | */..) return 1 ;; esac return 0 } valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } # The only privileged sub-actions __priv may dispatch. A bash command name # containing a slash is executed as a path, so validating the action here — not # just interpolating it into "__priv_${action}" — is what stops an action like # ../tmp/evil from running an arbitrary file as root. valid_priv_action() { case "$1" in write_compose | up | up_wait | down | status | remove) return 0 ;; *) return 1 ;; esac } # --- privileged actions (run as root via pkexec, or directly when sudoless) --- # Reads KEY=VALUE lines on stdin, re-validates every field, and writes the # compose atomically as root. Re-validation here is the security boundary: the # writer refuses rather than emit a compose an attacker could have influenced. # Only these fixed keys are honored; image, container name, devices, caps, and # port bindings are hard-coded and never taken from input. __priv_write_compose() { local ram cores disk username password tz storage shared key value while IFS='=' read -r key value; do case "$key" in RAM) ram="$value" ;; CORES) cores="$value" ;; DISK) disk="$value" ;; USERNAME) username="$value" ;; PASSWORD) password="$value" ;; TZ) tz="$value" ;; STORAGE) storage="$value" ;; SHARED) shared="$value" ;; esac done valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } valid_password "$password" || { echo "invalid password" >&2; exit 2; } valid_tz "$tz" || tz="UTC" valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; } valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; } # Neutralize anything in the password that could be misread when the compose # is parsed. Two layers apply, in this order at parse time: docker compose # variable interpolation over the raw text ($VAR / $$), then YAML parsing of # the double-quoted scalar. Encode for the inner layer first (backslash, then # double-quote) and the interpolation layer last ($ -> $$), so a password # containing " \ or $ reaches the guest verbatim. unescape() reverses this in # the opposite order for the RDP credentials. local esc_password=${password//\\/\\\\} esc_password=${esc_password//\"/\\\"} esc_password=${esc_password//\$/\$\$} mkdir -p "$RUNTIME_DIR" chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true chown root:root "$RUNTIME_DIR" 2>/dev/null || true local tmp tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") cat >"$tmp" </dev/null || true chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" 2>/dev/null || true mv -f "$tmp" "$COMPOSE_FILE" } # Read the host source of a bind mount out of the compose (e.g. /storage). # valid_path kept a ':' out of the stored path, so splitting on it is safe. get_mount_source() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 } # Refuse to bring the VM up if a bind-mount source is a symlink, or reached # through one. valid_path keeps a traversal string like /./ out of the compose, # but a symlink planted at ~/.windows or ~/Windows would redirect the privileged # mount just the same — docker follows it — and a string check cannot see that. # So verify the real directories here, as root, immediately before the mount. A # source that does not exist is fine: docker creates it as a plain directory. assert_mounts_safe() { local mnt src real for mnt in /storage /shared; do src=$(get_mount_source "$mnt") [[ -n $src ]] || { echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2 return 1 } if [[ -L $src ]]; then echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2 return 1 fi if [[ -e $src ]]; then [[ -d $src ]] || { echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2 return 1 } real=$(realpath "$src" 2>/dev/null) [[ $real == "$src" ]] || { echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2 return 1 } fi done } __priv_up() { assert_mounts_safe && dc up -d; } __priv_down() { dc down; } # Bring the VM up and wait until the guest reports it is ready, all under a # single elevation so the readiness poll does not prompt on every iteration. __priv_up_wait() { assert_mounts_safe || return 1 local status status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null) if [[ $status != "running" ]]; then dc up -d || return 1 fi # docker logs persists across restarts, so anchor the scan to the current # start time; an empty --since would match a stale "started successfully". local started_at count=0 while true; do started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then return 0 fi sleep 2 ((++count > 60)) && { echo "Timeout: Windows VM did not report ready within 2 minutes" >&2 return 1 } done } # Print the status (empty if the container does not exist) and always succeed, # so a non-zero exit from priv status means the elevation itself failed # (authorization declined) rather than "no such container". __priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } __priv_remove() { dc down 2>/dev/null || true docker rmi "$IMAGE" 2>/dev/null || true rm -f "$COMPOSE_FILE" rmdir "$RUNTIME_DIR" 2>/dev/null || true } # --- config helpers ---------------------------------------------------------- # Feed the collected settings to the elevated writer. write_compose() { local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8" printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" | priv write_compose } # Reverse, in the opposite order, the escaping __priv_write_compose applied to # the password: undo the interpolation layer ($$ -> $) first, then the YAML # layer (\" -> ", then \\ -> \). unescape() { local v=$1 v=${v//\$\$/\$} v=${v//\\\"/\"} v=${v//\\\\/\\} printf '%s' "$v" } # Store the RDP credentials privately for the user (0600) so the plaintext # password is not world-readable. The password is one validated printable line # (no newline), so plain KEY=VALUE is safe. write_credentials() { local username="$1" password="$2" old_umask mkdir -p "$(dirname "$CREDENTIALS_FILE")" old_umask=$(umask) umask 077 printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$CREDENTIALS_FILE" chmod 600 "$CREDENTIALS_FILE" 2>/dev/null || true umask "$old_umask" } # Read one field from the private credentials file; IFS on the first = keeps a # password that itself contains =. read_credential() { local want="$1" key value [[ -f $CREDENTIALS_FILE ]] || return 1 while IFS='=' read -r key value; do [[ $key == "$want" ]] && { printf '%s' "$value" return 0 } done <"$CREDENTIALS_FILE" return 1 } read_compose_value() { local key="$1" file="$2" sed -n "s/.*${key}: \"\(.*\)\"/\1/p" "$file" | head -n1 } # Older installs kept the compose under ~/.config/windows. Carry those settings # into the root-owned location (preserving the VM's data via the same volume # paths) so an upgrade does not strand or re-download an existing VM. migrate_legacy_compose() { [[ -f $COMPOSE_FILE ]] && return 0 [[ -f $LEGACY_COMPOSE_FILE ]] || return 1 echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." local ram cores disk username password tz storage shared ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") # The VM's data always lived in the user's own ~/.windows and ~/Windows; the # old compose only ever recorded those. Reconstruct them from $HOME (trusted — # this runs as the user) rather than reading host paths back from a file a # rogue process could have rewritten to bind-mount, say, / into the guest. storage="$HOME/.windows" shared="$HOME/Windows" [[ -z $tz ]] && tz="UTC" if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then echo "Could not migrate the existing configuration automatically." >&2 echo "Re-run: omarchy-windows-vm install" >&2 return 1 fi write_credentials "$username" "$password" rm -f "$LEGACY_COMPOSE_FILE" } # --- prerequisites ----------------------------------------------------------- check_prerequisites() { local DISK_SIZE_GB=${1:-64} local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead # Check for KVM support if [[ ! -e /dev/kvm ]]; then gum style \ --border normal \ --padding "1 2" \ --margin "1" \ "❌ KVM virtualization not available!" \ "" \ "Please enable virtualization in BIOS or run:" \ " sudo modprobe kvm-intel # for Intel CPUs" \ " sudo modprobe kvm-amd # for AMD CPUs" exit 1 fi # Check disk space AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then echo "❌ Insufficient disk space!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)" exit 1 fi } # --- commands ---------------------------------------------------------------- install_windows() { # Set up trap to handle Ctrl+C trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT check_prerequisites omarchy-pkg-add freerdp openbsd-netcat gum mkdir -p "$HOME/.windows" mkdir -p "$HOME/.local/share/applications" cat </dev/null [Desktop Entry] Name=Windows Comment=Start Windows VM via Docker and connect with RDP Exec=uwsm app -- omarchy-windows-vm launch Icon=windows Terminal=false Type=Application Categories=System;Virtualization; EOF # Get system resources TOTAL_RAM=$(free -h | awk 'NR==2 {print $2}') TOTAL_RAM_GB=$(awk 'NR==1 {printf "%d", $2/1024/1024}' /proc/meminfo) TOTAL_CORES=$(nproc) echo "" echo "System Resources Detected:" echo " Total RAM: $TOTAL_RAM" echo " Total CPU Cores: $TOTAL_CORES" echo "" RAM_OPTIONS="" for size in 2 4 8 16 32 64; do if ((size <= TOTAL_RAM_GB)); then RAM_OPTIONS="$RAM_OPTIONS ${size}G" fi done SELECTED_RAM=$(echo $RAM_OPTIONS | tr ' ' '\n' | gum choose --selected="4G" --header="How much RAM would you like to allocate to Windows VM?") # Check if user cancelled if [[ -z $SELECTED_RAM ]]; then echo "Installation cancelled by user" exit 1 fi SELECTED_CORES=$(gum input --placeholder="Number of CPU cores (1-$TOTAL_CORES)" --value="2" --header="How many CPU cores would you like to allocate to Windows VM?" --char-limit=2) # Check if user cancelled (Ctrl+C in gum input returns empty string) if [[ -z $SELECTED_CORES ]]; then echo "Installation cancelled by user" exit 1 fi if ! valid_cores "$SELECTED_CORES" || ((SELECTED_CORES > TOTAL_CORES)); then echo "Invalid input. Using default: 2 cores" SELECTED_CORES=2 fi AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image # Check if we have enough space for minimum if ((MAX_DISK_GB < 32)); then echo "❌ Insufficient disk space for Windows VM!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)" exit 1 fi DISK_OPTIONS="" for size in 32 64 128 256 512; do if ((size <= MAX_DISK_GB)); then DISK_OPTIONS="$DISK_OPTIONS ${size}G" fi done # Default to 64G if available, otherwise 32G DEFAULT_DISK="64G" if ! echo "$DISK_OPTIONS" | grep -q "64G"; then DEFAULT_DISK="32G" fi SELECTED_DISK=$(echo $DISK_OPTIONS | tr ' ' '\n' | gum choose --selected="$DEFAULT_DISK" --header="How much disk space would you like to give Windows VM? (64GB+ recommended)") # Check if user cancelled if [[ -z $SELECTED_DISK ]]; then echo "Installation cancelled by user" exit 1 fi # Extract just the number for prerequisite check DISK_SIZE_NUM=$(echo "$SELECTED_DISK" | sed 's/G//') # Re-check prerequisites with selected disk size check_prerequisites "$DISK_SIZE_NUM" # Prompt for username and password USERNAME=$(gum input --placeholder="Username (Press enter to use default: docker)" --header="Enter Windows username:") if [[ -z $USERNAME ]]; then USERNAME="docker" fi if ! valid_username "$USERNAME"; then echo "Invalid username (use letters, digits, - or _, up to 20 chars). Using default: docker" USERNAME="docker" fi PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:") if [[ -z $PASSWORD ]]; then PASSWORD="admin" PASSWORD_DISPLAY="(default)" else PASSWORD_DISPLAY="(user-defined)" fi if ! valid_password "$PASSWORD"; then echo "Invalid password (printable characters, up to 64). Using default: admin" PASSWORD="admin" PASSWORD_DISPLAY="(default)" fi # Display configuration summary gum style \ --border normal \ --padding "1 2" \ --margin "1" \ --align left \ --bold \ "Windows VM Configuration" \ "" \ "RAM: $SELECTED_RAM" \ "CPU: $SELECTED_CORES cores" \ "Disk: $SELECTED_DISK" \ "Username: $USERNAME" \ "Password: $PASSWORD_DISPLAY" # Ask for confirmation echo "" if ! gum confirm "Proceed with this configuration?"; then echo "Installation cancelled by user" exit 1 fi mkdir -p "$HOME/Windows" local tz tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) # Write the root-owned compose from the validated settings (one prompt if # sudoless Docker is off), then bring the stack up. write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ "$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || { echo "❌ Failed to write the Windows VM configuration." exit 1 } write_credentials "$USERNAME" "$PASSWORD" echo "" echo "Starting Windows VM installation..." echo "This will download a Windows 11 image (may take 10-15 minutes)." echo "" echo "Monitor installation progress at: http://127.0.0.1:8006" echo "" echo "Starting Windows VM with docker-compose..." if ! priv up; then echo "❌ Failed to start Windows VM!" echo " Common issues:" echo " - Docker daemon not running: sudo systemctl start docker" echo " - Port already in use: check if another VM is running" exit 1 fi echo "" echo "Windows VM is starting up!" echo "" echo "Opening browser to monitor installation..." # Open browser to monitor installation sleep 3 xdg-open "http://127.0.0.1:8006" echo "" echo "Installation is running in the background." echo "You can monitor progress at: http://127.0.0.1:8006" echo "" echo "Once finished, launch 'Windows' via Super + Space" echo "" echo "To stop the VM: omarchy-windows-vm stop" echo "" } remove_windows() { if ! gum confirm --default=false "Remove Windows VM and delete all associated data?"; then echo "Removal cancelled by user" exit 1 fi echo "Removing Windows VM..." migrate_legacy_compose 2>/dev/null || true if [[ -f $COMPOSE_FILE ]]; then priv remove || true fi rm -f "$HOME/.local/share/applications/windows-vm.desktop" rm -rf "$HOME/.config/windows" rm -rf "$HOME/.windows" echo "" echo "Windows VM removal completed!" } launch_windows() { KEEP_ALIVE=false if [[ $1 = "--keep-alive" ]] || [[ $1 = "-k" ]]; then KEEP_ALIVE=true fi if ! migrate_legacy_compose; then if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured. Please run: omarchy-windows-vm install" exit 1 fi fi # RDP credentials come from the private per-user file. Fall back to the compose # only when it is readable (sudoless mode), reversing the writer's escaping. WIN_USER=$(read_credential USERNAME) || WIN_USER="" WIN_PASS=$(read_credential PASSWORD) || WIN_PASS="" if [[ -z $WIN_USER || -z $WIN_PASS ]] && [[ -r $COMPOSE_FILE ]]; then [[ -z $WIN_USER ]] && WIN_USER=$(unescape "$(read_compose_value USERNAME "$COMPOSE_FILE")") [[ -z $WIN_PASS ]] && WIN_PASS=$(unescape "$(read_compose_value PASSWORD "$COMPOSE_FILE")") fi [[ -z $WIN_USER ]] && WIN_USER="docker" [[ -z $WIN_PASS ]] && WIN_PASS="admin" echo "Starting Windows VM (this may prompt for authorization)..." if ! priv up_wait; then echo "❌ Failed to start Windows VM!" echo " Try checking: omarchy-windows-vm status" omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" exit 1 fi # Build the connection info if [[ $KEEP_ALIVE = "true" ]]; then LIFECYCLE="VM will keep running after RDP closes To stop: omarchy-windows-vm stop" else LIFECYCLE="VM will auto-stop when RDP closes" fi gum style \ --border normal \ --padding "1 2" \ --margin "1" \ --align center \ "Connecting to Windows VM" \ "" \ "$LIFECYCLE" # FreeRDP 3 tries Kerberos before NTLM for NLA, and krb5 ships /etc/krb5.conf # as the MIT sample with default_realm = ATHENA.MIT.EDU. Every connect then # goes looking for MIT's KDC: with internet up it fails fast, but off the # network each attempt blocks ~23s and no RDP window is ever drawn. The VM # authenticates against a local Windows account, so point FreeRDP at a # realm-less config and let it fall straight through to NTLM. KRB5_CONF="$HOME/.config/windows/krb5.conf" mkdir -p "$(dirname "$KRB5_CONF")" if [[ ! -f $KRB5_CONF ]]; then printf '[libdefaults]\n dns_lookup_kdc = false\n dns_lookup_realm = false\n' >"$KRB5_CONF" fi export KRB5_CONFIG="$KRB5_CONF" # Detect display scale from Hyprland HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale') SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}') RDP_SCALE="" if ((SCALE_PERCENT >= 170)); then RDP_SCALE="/scale:180" elif ((SCALE_PERCENT >= 130)); then RDP_SCALE="/scale:140" fi # If scale is less than 130%, don't set any scale (use default 100) # Connect with RDP in fullscreen (auto-detects resolution) xfreerdp3 /u:"$WIN_USER" /p:"$WIN_PASS" /v:127.0.0.1:3389 -grab-keyboard /sound /microphone /clipboard /cert:ignore /title:"Windows VM - Omarchy" /dynamic-resolution /gfx:AVC444 /floatbar:sticky:off,default:visible,show:fullscreen $RDP_SCALE # After RDP closes, stop the container unless --keep-alive was specified if [[ $KEEP_ALIVE = "false" ]]; then echo "" echo "RDP session closed. Stopping Windows VM..." if priv down; then echo "Windows VM stopped." else echo "⚠️ Could not stop the Windows VM (authorization declined?)." echo " It may still be running. Stop it with: omarchy-windows-vm stop" fi else echo "" echo "RDP session closed. Windows VM is still running." echo "To stop it: omarchy-windows-vm stop" fi } stop_windows() { migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." exit 1 fi echo "Stopping Windows VM..." if priv down; then echo "Windows VM stopped." else echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running." exit 1 fi } status_windows() { migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." echo "To set up: omarchy-windows-vm install" exit 1 fi if ! CONTAINER_STATUS=$(priv status); then echo "Could not query the Windows VM (authorization declined?)." echo "To try again: omarchy-windows-vm status" exit 1 fi if [[ -z $CONTAINER_STATUS ]]; then echo "Windows VM container not found." echo "To start: omarchy-windows-vm launch" elif [[ $CONTAINER_STATUS = "running" ]]; then gum style \ --border normal \ --padding "1 2" \ --margin "1" \ --align left \ "Windows VM Status: RUNNING" \ "" \ "Web interface: http://127.0.0.1:8006" \ "RDP available: port 3389" \ "" \ "To connect: omarchy-windows-vm launch" \ "To stop: omarchy-windows-vm stop" else echo "Windows VM is stopped (status: $CONTAINER_STATUS)" echo "To start: omarchy-windows-vm launch" fi } show_usage() { echo "Usage: omarchy-windows-vm [command] [options]" echo "" echo "Commands:" echo " install Install and configure Windows VM" echo " remove Remove Windows VM and optionally its data" echo " launch [options] Start Windows VM (if needed) and connect via RDP" echo " Options:" echo " --keep-alive, -k Keep VM running after RDP closes" echo " stop Stop the running Windows VM" echo " status Show current VM status" echo " help Show this help message" echo "" echo "Examples:" echo " omarchy-windows-vm install # Set up Windows VM for first time" echo " omarchy-windows-vm launch # Connect to VM (auto-stop on exit)" echo " omarchy-windows-vm launch -k # Connect to VM (keep running)" echo " omarchy-windows-vm stop # Shut down the VM" } # Main command dispatcher case "$1" in __priv) ((EUID == 0)) || { echo "omarchy-windows-vm __priv must run as root" >&2 exit 1 } action="$2" shift 2 valid_priv_action "$action" || { echo "omarchy-windows-vm: unknown privileged action" >&2 exit 1 } "__priv_${action}" "$@" ;; install) install_windows ;; remove) remove_windows ;; launch | start) launch_windows "$2" ;; stop | down) stop_windows ;; status) status_windows ;; help | --help | -h | "") show_usage ;; *) echo "Unknown command: $1" >&2 echo "" >&2 show_usage >&2 exit 1 ;; esac