Files
omarchycn/bin/omarchy-windows-vm
T
06a3dbca42 Offer to reboot when toggling sudoless Docker; show only the relevant menu entry (#8098)
* Offer to reboot when toggling sudoless Docker; show only the relevant menu entry

Group membership only takes effect on a fresh session, and in practice a logout
or newgrp isn't enough — only a reboot reliably applies it. So the setup/remove
commands now flag the reboot and offer to do it now with a gum confirm (like the
GPU toggle), and the notices say "after a reboot" instead of pointing at logout
or newgrp. The existing-user migration passes OMARCHY_DEFER_REBOOT so it does not
prompt mid-update — omarchy-update-restart still handles the reboot once the whole
update finishes.

The Setup > Security menu also showed Sudoless Docker under both Setup and
Remove. Condition the Setup entry on the group being absent (Remove already
conditions on it being present), so only the applicable one appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

* Ask omarchy-sudo-docker whether Docker needs sudo

Every place that chooses between talking to Docker directly and elevating was
testing group membership by hand, and the menu guards tested the wrong thing:
they read the running session's groups, which do not change until the reboot,
so after enabling sudoless Docker the menu still offered Setup — the one action
that could no longer do anything — while Remove stayed hidden.

Add omarchy-sudo-docker as the single answer, with the two questions that
actually differ in that window. By default it asks whether this session can
reach the socket, which is what decides if a command must elevate: lazydocker
and the Windows VM keep prompting until the reboot lands. With --configured it
asks whether the account is set up for sudoless Docker, which is what the menu
and the toggles need, so the menu switches to the action that can change state
as soon as the group is written.

Also correct a comment: nothing surfaces reboot-required in the bar; it is
omarchy-update-restart that reads it during a later update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 21:58:27 +02:00

819 lines
28 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Install, launch, stop, inspect, or remove the Windows VM
# omarchy:args=<install|remove|launch|stop|status> [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 this session can reach the Docker socket directly (sudoless Docker
# on and in effect). Asking about the socket rather than the configured groups
# keeps the prompt in place through the window where sudoless Docker is enabled
# but the reboot that grants the group has not happened yet.
docker_needs_sudo() { omarchy-sudo-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 ]] && ! docker_needs_sudo; 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" <<EOF
services:
windows:
image: $IMAGE
container_name: $CONTAINER
environment:
VERSION: "11"
RAM_SIZE: "$ram"
CPU_CORES: "$cores"
DISK_SIZE: "$disk"
USERNAME: "$username"
PASSWORD: "$esc_password"
TZ: "$tz"
ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew"
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
ports:
- 127.0.0.1:8006:8006
- 127.0.0.1:3389:3389/tcp
- 127.0.0.1:3389:3389/udp
volumes:
- $storage:/storage
- $shared:/shared
restart: "no"
stop_grace_period: 2m
EOF
# Readable by root and the docker group only. In sudoless mode the user is in
# the docker group and runs docker-compose against this file directly; in the
# default mode the elevated helper (root) reads it, and other local users
# cannot read the guest password. chown falls back to root:root if the docker
# group somehow does not exist (sudoless mode could not be enabled anyway).
chmod 0640 "$tmp" 2>/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 <<EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" >/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