Files
wehub-resource-sync 3e779be6f3
CI / test (3.14, ubuntu-24.04-arm) (push) Waiting to run
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

1753 lines
78 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# SPDX-License-Identifier: MIT
set -euo pipefail
###############################################################################
# winpodx installer (v2)
#
# Usage:
# curl -sSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
# or: ./install.sh [--main] [--ref TAG] [--source PATH] [--image-tar PATH]
# [--mode r|a|c|n] [--backend podman|docker|manual]
# [--no-gui] [--manual] [--skip-deps] [--win-version VER]
# [--win-iso PATH] [--storage-dir PATH] [--help]
#
# Installs winpodx to ~/.local/bin/winpodx-app/ and creates a launcher.
# Python always runs from a private venv under
# ~/.local/bin/winpodx-app/.venv ; no system-python changes.
#
# Install mode (interactive prompt, or preselect with --mode / WINPODX_MODE):
# --mode r Recommended — winpodx's recommended stack (Podman backend
# + deps); installs missing system packages via the distro
# package manager (sudo). This is the historical behaviour.
# --mode a Automatic — reuse what's already installed; only install
# what's strictly missing; pick the backend from what's
# present (prefer an already-working docker/podman).
# --mode c Custom — choose backend (podman/docker) and
# whether to include the GUI, then install accordingly.
# --mode n No — cancel without changing anything.
# (env: WINPODX_MODE=<r|a|c|n>)
# Default: prompt when a terminal is reachable — including
# `curl ... | bash` (prompts read from /dev/tty) — and 'r'
# (Recommended) when fully non-interactive (CI / cron /
# stdin+/dev/tty both absent).
#
# Backend / GUI:
# --backend BACKEND podman | docker | manual. Passed through to
# `winpodx setup --backend <x>`. (env: WINPODX_BACKEND)
# (libvirt was dropped in 0.6.0 — dockur covers device
# passthrough now, #286.)
# --verbose, -v Stream raw container logs during the Windows-boot wait.
# Default collapses the ISO download to one clean progress
# line + hides UEFI boot noise. (env: WINPODX_VERBOSE=1)
# --no-gui Headless / CLI-only: skip installing PySide6 into the
# venv. Everything else still installs.
# (env: WINPODX_NO_GUI=1)
#
# Version selection (default: latest GitHub release):
# --main Install from git main HEAD (development, may be unstable).
# (env: WINPODX_REF=main)
# --ref TAG Install a specific tag/branch/commit.
# (env: WINPODX_REF=<ref>)
#
# Local-path options (for offline / air-gapped installs):
# --source PATH Copy winpodx from PATH instead of git clone.
# (env: WINPODX_SOURCE)
# --image-tar PATH Preload Windows container image from PATH via
# `podman load -i` (or `docker load -i`).
# (env: WINPODX_IMAGE_TAR)
# --skip-deps Skip the distro dependency install phase.
# Fails early if required tools aren't already present.
# (env: WINPODX_SKIP_DEPS=1)
# --win-version VER Windows edition for fresh installs
# (11 | 10 | ltsc11 | ltsc10 | iot11 | tiny11 | tiny10 |
# 2025 | 2022 | 2019 | 2016 — see docs/ARCHITECTURE.md).
# (env: WINPODX_WIN_VERSION)
# --win-iso PATH Install from a local Windows ISO at PATH instead of
# downloading it from Microsoft. The ISO is staged into
# the storage dir as `custom.iso` (dockur's bring-your-own
# convention); reflink-copied where the filesystem supports
# it, so it costs no extra space on btrfs/xfs. Saves the
# ~5-8 GB download on every purge/reinstall cycle (#647).
# (env: WINPODX_WIN_ISO)
# --storage-dir PATH On a FRESH install, put the Windows VM disk + ISO at PATH
# (e.g. a roomier partition) instead of
# ~/.local/share/winpodx/storage. The dir is created and
# gets the same prep as the default (chattr +C on btrfs,
# SSD emulation if applicable) (#646). To relocate an
# EXISTING install, use `winpodx setup --migrate-storage
# --migrate-storage-target PATH`. (env: WINPODX_STORAGE_DIR)
# --manual Install winpodx + create the venv only — skip
# 'winpodx setup', 'winpodx pod wait-ready', app
# discovery, and reverse-open. Finish provisioning
# yourself via the first-run prompt on the next
# 'winpodx' invocation. (env: WINPODX_MANUAL=1)
# -h, --help Print this help and exit
###############################################################################
INSTALL_DIR="$HOME/.local/bin/winpodx-app"
VENV_DIR="$INSTALL_DIR/.venv"
VENV_PY="$VENV_DIR/bin/python"
LAUNCHER="$HOME/.local/bin/winpodx-run"
SYMLINK="$HOME/.local/bin/winpodx"
REPO_URL="https://github.com/kernalix7/winpodx.git"
REPO_API="https://api.github.com/repos/kernalix7/winpodx"
# Local-path overrides (env or flag). Flags take precedence over env.
WINPODX_SOURCE="${WINPODX_SOURCE:-}"
WINPODX_IMAGE_TAR="${WINPODX_IMAGE_TAR:-}"
WINPODX_SKIP_DEPS="${WINPODX_SKIP_DEPS:-}"
# v0.2.2.2: explicit ref selection. Empty -> auto-detect latest release tag
# at install time. Set to "main" via --main flag for development builds.
WINPODX_REF="${WINPODX_REF:-}"
# v0.5.x: --win-version flag picks the dockur Windows edition for fresh
# installs (e.g. ltsc10, iot11, tiny11). Empty -> dockur default "11".
# Ignored when an existing winpodx.toml is present (setup skips
# re-configuration for upgrade flows). See #178.
WINPODX_WIN_VERSION="${WINPODX_WIN_VERSION:-}"
WINPODX_WIN_ISO="${WINPODX_WIN_ISO:-}"
WINPODX_STORAGE_DIR="${WINPODX_STORAGE_DIR:-}"
WINPODX_MANUAL="${WINPODX_MANUAL:-}"
# v2: new knobs.
WINPODX_NO_GUI="${WINPODX_NO_GUI:-}"
WINPODX_BACKEND="${WINPODX_BACKEND:-}"
WINPODX_MODE="${WINPODX_MODE:-}"
# Bypass the too-old-podman guard (#271): proceed with podman even when the
# probe sees major < 4, e.g. you upgraded podman out-of-band since.
WINPODX_ALLOW_OLD_PODMAN="${WINPODX_ALLOW_OLD_PODMAN:-}"
# v2: --verbose streams raw container logs during the Windows-boot wait;
# default collapses the ISO download to a clean progress line + hides UEFI noise.
WINPODX_VERBOSE="${WINPODX_VERBOSE:-}"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
log() { echo -e "${GREEN}[WinPodX]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
err() { echo -e "${RED}[error]${NC} $*" >&2; }
usage() {
sed -n '4,93p' "${BASH_SOURCE[0]:-/dev/null}" 2>/dev/null || cat <<'USAGE_EOF'
WinPodX installer — see install.sh header for full usage.
Flags:
--mode r|a|c|n Install mode (Recommended / Automatic / Custom / No)
--backend BACKEND podman | docker | manual
--no-gui Headless install — skip PySide6 in the venv
--verbose, -v Stream raw container logs during the Windows-boot wait
(default: clean ISO-download progress + hidden UEFI noise)
--main Install from git main HEAD (development)
--ref TAG Install a specific tag/branch/commit
--source PATH Copy from local repo instead of git clone
--image-tar PATH Load container image from local tar
--skip-deps Skip distro dependency install
--win-version VER Windows edition for fresh installs
--win-iso PATH Install from a local Windows ISO instead of downloading
--storage-dir PATH Put the VM disk + ISO at PATH (fresh install; roomier partition)
--manual Install binary + venv only — skip provisioning
--allow-old-podman Proceed with podman even if its major version is < 4
(bypass the too-old-podman guard; #271)
-h, --help Print this help and exit
USAGE_EOF
}
# --- Parse flags (must precede any work) ---
while [ $# -gt 0 ]; do
case "$1" in
--main|--dev)
WINPODX_REF="main"
shift
;;
--ref)
WINPODX_REF="${2:-}"
shift 2
;;
--source)
WINPODX_SOURCE="${2:-}"
shift 2
;;
--image-tar)
WINPODX_IMAGE_TAR="${2:-}"
shift 2
;;
--skip-deps)
WINPODX_SKIP_DEPS=1
shift
;;
--win-version)
WINPODX_WIN_VERSION="${2:-}"
if [ -z "$WINPODX_WIN_VERSION" ]; then
err "--win-version requires a value (e.g. ltsc10, iot11, tiny11)"
exit 1
fi
shift 2
;;
--win-iso)
WINPODX_WIN_ISO="${2:-}"
if [ -z "$WINPODX_WIN_ISO" ]; then
err "--win-iso requires a path (e.g. /path/to/Win11.iso)"
exit 1
fi
if [ ! -f "$WINPODX_WIN_ISO" ]; then
err "--win-iso: no such file: $WINPODX_WIN_ISO"
exit 1
fi
# Resolve to an absolute path now — later `cp` may run from a
# different cwd, and the value is logged for the user.
WINPODX_WIN_ISO="$(cd "$(dirname "$WINPODX_WIN_ISO")" && pwd)/$(basename "$WINPODX_WIN_ISO")"
case "$WINPODX_WIN_ISO" in
*.iso|*.ISO) : ;;
*) log "Note: --win-iso path doesn't end in .iso — using it anyway: $WINPODX_WIN_ISO" ;;
esac
shift 2
;;
--storage-dir)
WINPODX_STORAGE_DIR="${2:-}"
if [ -z "$WINPODX_STORAGE_DIR" ]; then
err "--storage-dir requires a path (e.g. /mnt/data/winpodx)"
exit 1
fi
if [ -e "$WINPODX_STORAGE_DIR" ] && [ ! -d "$WINPODX_STORAGE_DIR" ]; then
err "--storage-dir: not a directory: $WINPODX_STORAGE_DIR"
exit 1
fi
# Create it now so we can absolutize + verify it's writable; setup
# re-creates idempotently and applies the btrfs/SSD prep.
if ! mkdir -p "$WINPODX_STORAGE_DIR" 2>/dev/null; then
err "--storage-dir: cannot create $WINPODX_STORAGE_DIR (check the path / permissions)"
exit 1
fi
if [ ! -w "$WINPODX_STORAGE_DIR" ]; then
err "--storage-dir: not writable: $WINPODX_STORAGE_DIR"
exit 1
fi
WINPODX_STORAGE_DIR="$(cd "$WINPODX_STORAGE_DIR" && pwd)"
shift 2
;;
--no-gui)
WINPODX_NO_GUI=1
shift
;;
--verbose|-v)
WINPODX_VERBOSE=1
shift
;;
--backend)
WINPODX_BACKEND="${2:-}"
if [ -z "$WINPODX_BACKEND" ]; then
err "--backend requires a value (podman | docker | manual)"
exit 1
fi
shift 2
;;
--mode)
WINPODX_MODE="${2:-}"
if [ -z "$WINPODX_MODE" ]; then
err "--mode requires a value (r | a | c | n)"
exit 1
fi
shift 2
;;
--manual)
WINPODX_MANUAL=1
shift
;;
--allow-old-podman)
WINPODX_ALLOW_OLD_PODMAN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
err "Unknown argument: $1"
usage >&2
exit 1
;;
esac
done
# Normalise + validate --backend.
if [ -n "$WINPODX_BACKEND" ]; then
WINPODX_BACKEND="$(echo "$WINPODX_BACKEND" | tr '[:upper:]' '[:lower:]')"
case "$WINPODX_BACKEND" in
podman|docker|manual) ;;
*)
err "--backend must be one of: podman, docker, manual (got '$WINPODX_BACKEND')"
exit 1
;;
esac
fi
# Normalise + validate --mode.
if [ -n "$WINPODX_MODE" ]; then
WINPODX_MODE="$(echo "$WINPODX_MODE" | tr '[:upper:]' '[:lower:]' | cut -c1)"
case "$WINPODX_MODE" in
r|a|c|n) ;;
*)
err "--mode must be one of: r, a, c, n (got '$WINPODX_MODE')"
exit 1
;;
esac
fi
# Validate --source
if [ -n "$WINPODX_SOURCE" ]; then
if [ ! -d "$WINPODX_SOURCE" ]; then
err "--source path does not exist or is not a directory: $WINPODX_SOURCE"
exit 1
fi
if [ ! -f "$WINPODX_SOURCE/pyproject.toml" ] || [ ! -d "$WINPODX_SOURCE/src/winpodx" ]; then
err "--source path does not look like a winpodx repo (missing pyproject.toml or src/winpodx/): $WINPODX_SOURCE"
exit 1
fi
log "Using local source: $WINPODX_SOURCE"
fi
# Validate --image-tar
if [ -n "$WINPODX_IMAGE_TAR" ]; then
if [ ! -f "$WINPODX_IMAGE_TAR" ]; then
err "--image-tar file does not exist: $WINPODX_IMAGE_TAR"
exit 1
fi
log "Using local image tar: $WINPODX_IMAGE_TAR"
fi
if [ -n "$WINPODX_SKIP_DEPS" ]; then
log "Skipping distro dependency install (--skip-deps)"
fi
# --- Detect distro & package manager ---
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "$ID"
else
echo "unknown"
fi
}
DISTRO=$(detect_distro)
# Detect host architecture. winpodx ships two dockur image variants:
#
# x86_64 → dockurr/windows (x86_64 Windows guest, native via KVM)
# aarch64 → dockurr/windows-arm (Windows-on-ARM guest, native via KVM)
#
# core/config.py:_default_pod_image picks the matching image on a fresh
# install. install.sh only logs the detected arch here for visibility —
# the image picker fires inside `winpodx setup` later.
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64)
ARCH_LABEL="x86_64"
;;
aarch64|arm64)
ARCH_LABEL="aarch64"
;;
*)
ARCH_LABEL="$ARCH"
;;
esac
# =====================================================================
# Fresh-install detection (governs rollback scope, see trap below).
#
# Rollback is FRESH-INSTALL ONLY: on an upgrade (a prior config OR a
# prior venv exists) a failure must NOT delete the working install. We
# snapshot this BEFORE creating any artifacts this run.
# =====================================================================
PRIOR_CONFIG="$HOME/.config/winpodx/winpodx.toml"
IS_FRESH_INSTALL=1
if [ -f "$PRIOR_CONFIG" ] || [ -e "$VENV_DIR" ]; then
IS_FRESH_INSTALL=0
fi
# =====================================================================
# Rollback (LOCKED scope).
#
# On a FAILED fresh-install run, remove ONLY winpodx's own artifacts
# created THIS run: the venv, the winpodx-run launcher + winpodx
# symlink, the desktop entry + icon, and the .install_in_progress
# marker. Never uninstall system packages; never touch a pre-existing
# ~/.config/winpodx config. On an upgrade, leave everything intact.
# =====================================================================
WINPODX_INSTALL_MARKER="${XDG_CONFIG_HOME:-$HOME/.config}/winpodx/.install_in_progress"
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICON_BASE="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor"
ICON_DIR="$ICON_BASE/scalable/apps"
# Disarmed on success.
ROLLBACK_ARMED=1
cleanup_install_marker() {
rm -f "$WINPODX_INSTALL_MARKER"
}
rollback() {
cleanup_install_marker
if [ "$ROLLBACK_ARMED" -ne 1 ]; then
return 0
fi
if [ "$IS_FRESH_INSTALL" -ne 1 ]; then
warn "Install failed during an upgrade — leaving the existing WinPodX install intact."
warn "Your previous venv, launcher, and config were not touched."
return 0
fi
warn "Rolling back WinPodX install artifacts..."
# venv + cloned/copied source tree (this whole dir is ours, created
# this run on a fresh install).
rm -rf "$INSTALL_DIR" 2>/dev/null || true
rm -f "$LAUNCHER" 2>/dev/null || true
rm -f "$SYMLINK" 2>/dev/null || true
rm -f "$DESKTOP_DIR/winpodx.desktop" 2>/dev/null || true
rm -f "$ICON_DIR/winpodx.svg" 2>/dev/null || true
# Do NOT remove ~/.config/winpodx itself — only our own marker, which
# cleanup_install_marker already handled above.
}
# ERR trap: any failed command (set -e is in effect) rolls back, then
# exits non-zero. INT/TERM abort the rest of install.sh too.
rollback_and_exit_err() {
local rc=$?
rollback
exit "$rc"
}
cleanup_and_exit_int() { rollback; exit 130; }
cleanup_and_exit_term() { rollback; exit 143; }
trap rollback_and_exit_err ERR
trap cleanup_and_exit_int INT
trap cleanup_and_exit_term TERM
# Map generic dependency names to distro-specific package names
pkg_name() {
local dep="$1"
case "$DISTRO" in
opensuse*|sles)
case "$dep" in
python3) echo "python3" ;;
python3-venv) echo "python3" ;;
podman) echo "podman" ;;
docker) echo "docker" ;;
freerdp) echo "freerdp" ;;
kvm) echo "qemu-kvm" ;;
xcb-cursor) echo "libxcb-cursor0" ;;
esac ;;
fedora|rhel|centos|rocky|alma)
case "$dep" in
python3) echo "python3" ;;
python3-venv) echo "python3" ;;
podman) echo "podman" ;;
docker) echo "docker" ;;
freerdp) echo "freerdp" ;;
kvm) echo "qemu-kvm" ;;
xcb-cursor) echo "xcb-util-cursor" ;;
esac ;;
ubuntu|debian|linuxmint|pop)
case "$dep" in
python3) echo "python3" ;;
# Debian/Ubuntu split venv + ensurepip out of python3 into
# python3-venv; without it `python3 -m venv` fails to
# bootstrap pip. This is the one package the mandatory-venv
# step may need to install before the venv can be created.
python3-venv) echo "python3-venv" ;;
podman) echo "podman" ;;
docker) echo "docker.io" ;;
freerdp)
# Debian 13+ (Trixie) and recent Ubuntu (24.10+, 25.04, 25.10)
# only ship freerdp3-x11; the stock freerdp2-x11 package is
# gone. Older systems (Debian <=12, Ubuntu 22.04 stock) only
# ship freerdp2-x11. Ubuntu 24.04 LTS ships both.
# Prefer freerdp3-x11 — the v0.5.1 launcher detects the
# FreeRDP major version at startup and emits the right
# /app: syntax for either version, so freerdp3-x11 is the
# better default when available. Fall back to freerdp2-x11
# only when the user's apt repos don't have a 3 build.
if apt-cache show freerdp3-x11 2>/dev/null | grep -q '^Package:'; then
echo "freerdp3-x11"
elif apt-cache show freerdp2-x11 2>/dev/null | grep -q '^Package:'; then
echo "freerdp2-x11"
else
# Neither in cache — emit freerdp3-x11 so apt produces
# a useful "Unable to locate package" error pointing at
# the recommended package, instead of a stale one.
echo "freerdp3-x11"
fi
;;
kvm)
# Ubuntu 24.04+ (and Mint 22+ / xubuntu 26.04 / Debian 13)
# made qemu-kvm a virtual package with no installation
# candidate; apt errors with
# ``E: Package 'qemu-kvm' has no installation candidate``
# and lists qemu-system-x86 / qemu-system-x86-hwe as the
# real providers (#200, reported by @n-osennij on
# xubuntu 26.04). On aarch64 the real package is
# qemu-system-arm. Probe apt-cache and pick the available
# candidate; fall back to qemu-kvm so apt emits the
# legacy error message on truly ancient repos.
local kvm_first kvm_second
if [ "$ARCH" = "aarch64" ]; then
kvm_first="qemu-system-arm"
kvm_second="qemu-kvm"
else
kvm_first="qemu-system-x86"
kvm_second="qemu-system-x86-hwe"
fi
if apt-cache show "$kvm_first" 2>/dev/null | grep -q '^Package:'; then
echo "$kvm_first"
elif apt-cache show "$kvm_second" 2>/dev/null | grep -q '^Package:'; then
echo "$kvm_second"
elif apt-cache show qemu-kvm 2>/dev/null | grep -q '^Package:'; then
echo "qemu-kvm"
else
echo "$kvm_first"
fi
;;
xcb-cursor) echo "libxcb-cursor0" ;;
esac ;;
arch|manjaro|endeavouros)
case "$dep" in
python3) echo "python" ;;
python3-venv) echo "python" ;;
podman) echo "podman" ;;
docker) echo "docker" ;;
freerdp) echo "freerdp" ;;
kvm) echo "qemu-full" ;;
xcb-cursor) echo "xcb-util-cursor" ;;
esac ;;
*)
echo "$dep" ;;
esac
}
install_pkg() {
local pkg="$1"
local actual
actual=$(pkg_name "$pkg")
log "Installing $actual..."
if command -v zypper >/dev/null 2>&1; then
sudo zypper install -y "$actual"
elif command -v dnf >/dev/null 2>&1; then
sudo dnf install -y "$actual"
elif command -v apt-get >/dev/null 2>&1; then
sudo apt-get install -y "$actual"
elif command -v pacman >/dev/null 2>&1; then
sudo pacman -S --noconfirm "$actual"
else
err "No supported package manager found."
err "Please install '$actual' manually."
return 1
fi
}
# --- Atomic Fedora (Silverblue / Kinoite / Sericea / Bluefin / Bazzite / ...) ---
#
# These distros use rpm-ostree (image-based, immutable root) instead of dnf.
# Layering many packages individually with rpm-ostree triggers a reboot per
# layered batch, so we sidestep the per-dependency loop entirely — winpodx is
# published on the openSUSE Build Service (OBS) as a Fedora RPM whose
# Requires: pulls in freerdp >= 3.0, python3-tomli, and Recommends: podman +
# python3-PySide6, so layering just `winpodx` is one transaction. We try
# `rpm-ostree install --apply-live` first to land the layer in the booted
# deployment without a reboot; if the running deployment can't accept the
# live apply we stage normally and prompt the user to reboot once.
#
# rpm-ostree's RPM install path is wholly separate from the venv flow below,
# so it disarms the rollback trap (its artifacts are an OBS repo file +
# layered RPM, neither of which the venv rollback should touch) and exits.
#
# An EXPLICIT source override (--main / --ref / --source / --image-tar) always
# wins: the OBS RPM only ships tagged releases, so honouring those flags means
# falling through to the git/venv flow even on Atomic (#548 — custom-image
# builders layer winpodx from main like any other Fedora package).
if command -v rpm-ostree >/dev/null 2>&1 \
&& [ -z "$WINPODX_REF" ] && [ -z "$WINPODX_SOURCE" ] && [ -z "$WINPODX_IMAGE_TAR" ]; then
ROLLBACK_ARMED=0
log "Detected rpm-ostree — Atomic Fedora install path."
if [ ! -f /etc/os-release ]; then
err "/etc/os-release missing; can't determine Fedora version for OBS repo selection."
exit 1
fi
. /etc/os-release
obs_ver="$VERSION_ID"
repo_url="https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_${obs_ver}/home:Kernalix7.repo"
log "Probing OBS repo for Fedora ${obs_ver}: $repo_url"
if ! curl -sSfI "$repo_url" >/dev/null 2>&1; then
err "No OBS repo published for Fedora_${obs_ver}."
err "Currently enabled: Fedora 42, 43, 44 at https://build.opensuse.org/project/show/home:Kernalix7"
err "Open an issue at https://github.com/kernalix7/winpodx/issues if you need another Fedora release added."
exit 1
fi
log "Adding OBS repo to /etc/yum.repos.d/..."
sudo curl -sSL "$repo_url" -o /etc/yum.repos.d/home-Kernalix7-winpodx.repo
log "Layering WinPodX via rpm-ostree install --apply-live (one transaction)..."
if sudo rpm-ostree install --apply-live --idempotent winpodx; then
log "WinPodX layered into the booted deployment — no reboot required."
log "Run: winpodx setup"
else
warn "Live apply unavailable on this deployment — staging the layer for next boot."
if sudo rpm-ostree install --idempotent winpodx; then
log "Staged. Reboot the host, then run: winpodx setup"
else
err "rpm-ostree install failed in both live and staged modes. See the rpm-ostree output above for the underlying error."
exit 1
fi
fi
exit 0
elif command -v rpm-ostree >/dev/null 2>&1; then
# rpm-ostree present, but the user asked for a specific source above — note
# the bypass (the OBS RPM only ships tagged releases) and fall through to
# the git/venv install path below.
log "rpm-ostree detected, but --main/--ref/--source/--image-tar was set — installing from source via the venv path."
fi
# =====================================================================
# Pre-sudo system check.
#
# Scan + print a summary BEFORE any sudo / package install so the user
# can see exactly what's present and decide how to proceed.
# =====================================================================
# Helpers used by the scan.
tool_version() {
# Best-effort single-line version string for a tool, or "".
local tool="$1"
case "$tool" in
podman) podman --version 2>/dev/null | head -n1 ;;
docker) docker --version 2>/dev/null | head -n1 ;;
python3) python3 --version 2>&1 | head -n1 ;;
freerdp)
local c
for c in xfreerdp3 xfreerdp wlfreerdp3 wlfreerdp; do
if command -v "$c" >/dev/null 2>&1; then
"$c" --version 2>/dev/null | head -n1 || echo "$c (version unknown)"
return 0
fi
done
;;
esac
}
# Podman major-version gate (#271). dockur/winpodx need rootless
# `group_add: keep-groups` + a modern compose; Ubuntu 22.04 ships
# podman 3.4. Flag podman as too old when major < 4.
PODMAN_PRESENT=false
PODMAN_TOO_OLD=false
PODMAN_MAJOR=0
if command -v podman >/dev/null 2>&1; then
PODMAN_PRESENT=true
# `podman --version` -> "podman version 4.9.3"
PODMAN_MAJOR="$(podman --version 2>/dev/null | sed -n 's/.*version[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n1)"
PODMAN_MAJOR="${PODMAN_MAJOR:-0}"
if [ "$PODMAN_MAJOR" -lt 4 ]; then
PODMAN_TOO_OLD=true
fi
fi
# winpodx requires the standalone `podman-compose` (NOT `podman compose`, which
# delegates to docker-compose and breaks our keep-groups extension -- #288).
# It's a separate package the `podman` package doesn't pull in.
PODMAN_COMPOSE_PRESENT=false
command -v podman-compose >/dev/null 2>&1 && PODMAN_COMPOSE_PRESENT=true
DOCKER_PRESENT=false
command -v docker >/dev/null 2>&1 && DOCKER_PRESENT=true
# FreeRDP detection — track native client, Flatpak client, and the Flatpak
# runtime separately so the install policy + Custom mode can choose a source.
# Mirrors core/rdp.py:find_freerdp's accepted native set.
FREERDP_NATIVE_PRESENT=false
for _c in xfreerdp3 xfreerdp sdl-freerdp3 sdl-freerdp wlfreerdp3 wlfreerdp; do
if command -v "$_c" >/dev/null 2>&1; then FREERDP_NATIVE_PRESENT=true; break; fi
done
FLATPAK_PRESENT=false
command -v flatpak >/dev/null 2>&1 && FLATPAK_PRESENT=true
FREERDP_FLATPAK_PRESENT=false
if [ "$FLATPAK_PRESENT" = true ] \
&& flatpak list --app --columns=application 2>/dev/null | grep -qx 'com.freerdp.FreeRDP'; then
FREERDP_FLATPAK_PRESENT=true
fi
# Any client (native or Flatpak) satisfies the requirement, so we never pull a
# redundant native package when a client is already present (#269). winpodx's
# launcher prefers the FLATPAK client when both are present (core/rdp.py
# auto order is flatpak-first, #401) — so the reported "kind" mirrors that:
# flatpak wins the label when both exist, so it matches the client the
# launcher will actually use.
FREERDP_PRESENT=false
FREERDP_KIND=""
if [ "$FREERDP_FLATPAK_PRESENT" = true ]; then
FREERDP_PRESENT=true
FREERDP_KIND="flatpak (com.freerdp.FreeRDP)"
elif [ "$FREERDP_NATIVE_PRESENT" = true ]; then
FREERDP_PRESENT=true
FREERDP_KIND="native"
fi
PYTHON3_PRESENT=false
command -v python3 >/dev/null 2>&1 && PYTHON3_PRESENT=true
KVM_PRESENT=false
[ -e /dev/kvm ] && KVM_PRESENT=true
# Probe whether `python3 -m venv` can actually bootstrap (Debian/Ubuntu
# split python3-venv / ensurepip out).
VENV_PROBE_OK=false
if [ "$PYTHON3_PRESENT" = true ] && python3 -c "import venv, ensurepip" >/dev/null 2>&1; then
VENV_PROBE_OK=true
fi
yesno() { if [ "$1" = true ]; then echo "yes"; else echo "no"; fi; }
print_system_check() {
local osname="$DISTRO"
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
osname="${PRETTY_NAME:-$DISTRO}"
fi
echo ""
echo "================ WinPodX system check ================"
echo " Distro: $osname"
echo " Architecture: $ARCH_LABEL"
echo " python3: $(yesno "$PYTHON3_PRESENT") $(tool_version python3)"
echo " python venv: $(yesno "$VENV_PROBE_OK") (python3 -m venv works)"
if [ "$PODMAN_PRESENT" = true ]; then
if [ "$PODMAN_TOO_OLD" = true ]; then
echo " podman: yes $(tool_version podman) [TOO OLD — major $PODMAN_MAJOR < 4]"
else
echo " podman: yes $(tool_version podman)"
fi
else
echo " podman: no"
fi
echo " docker: $(yesno "$DOCKER_PRESENT") $(tool_version docker)"
echo " freerdp: $(yesno "$FREERDP_PRESENT") ${FREERDP_KIND:+$FREERDP_KIND }$(tool_version freerdp)"
echo " /dev/kvm: $(yesno "$KVM_PRESENT")"
echo "======================================================"
if [ "$PODMAN_TOO_OLD" = true ]; then
echo ""
warn "podman $PODMAN_MAJOR.x is too old for WinPodX (need >= 4 for rootless"
warn "'group_add: keep-groups' + modern compose; Ubuntu 22.04 ships 3.4 — #271)."
warn "Either:"
warn " - upgrade podman (Kubic / devel:kubic:libcontainers repo:"
warn " https://software.opensuse.org/download/package?package=podman&project=devel%3Akubic%3Alibcontainers%3Aunstable ), OR"
warn " - use the Docker backend (--backend docker, or pick Docker in Custom mode)."
fi
echo ""
}
print_system_check
# =====================================================================
# Mode prompt.
#
# Resolve INSTALL_MODE (r/a/c/n). Precedence:
# 1. --mode / WINPODX_MODE if set.
# 2. interactive TTY -> prompt.
# 3. non-interactive -> 'r' (recommended), preserving old behaviour.
# =====================================================================
INSTALL_MODE="$WINPODX_MODE"
# Interactive if we can reach a real terminal for INPUT — either stdin is a
# TTY, or (the `curl ... | bash` case, where stdin is the script pipe) the
# controlling terminal /dev/tty is openable. Prompts read from $TTY_DEV so the
# mode menu works even via curl|bash. A run with no controlling terminal
# (CI / cron / `</dev/null`) stays non-interactive and defaults to Recommended.
INTERACTIVE=false
TTY_DEV=/dev/stdin
if [ -t 0 ]; then
INTERACTIVE=true
TTY_DEV=/dev/stdin
elif { true </dev/tty; } 2>/dev/null; then
INTERACTIVE=true
TTY_DEV=/dev/tty
fi
if [ -z "$INSTALL_MODE" ]; then
if [ "$IS_FRESH_INSTALL" != "1" ]; then
# Upgrade / re-run: a winpodx config (and backend) already exists, so
# the R/A/C/N mode prompt is pointless — there's nothing to choose, we
# reuse the existing setup and run the migrate path. Resolve to
# Automatic (reuse what's present, install only what's strictly
# missing) without prompting.
INSTALL_MODE="a"
log "Existing WinPodX install detected — upgrading; reusing current config (skipping install-mode prompt)."
elif [ "$INTERACTIVE" = true ]; then
cat <<'MODE_EOF'
Install mode?
[R]ecommended WinPodX's recommended stack (Podman backend + deps); installs missing system packages via the distro package manager (sudo).
[A]utomatic Reuse what's already installed; only install what's strictly missing; pick the backend from what's present (prefer an already-working docker/podman). Minimal sudo.
[C]ustom Choose backend (podman/docker) and whether to include the GUI, then install accordingly.
[N]o Cancel without changing anything.
MODE_EOF
echo -n "Choice [R/a/c/n]: "
read -r mode_answer < "$TTY_DEV"
case "$(echo "${mode_answer:-r}" | tr '[:upper:]' '[:lower:]' | cut -c1)" in
a) INSTALL_MODE="a" ;;
c) INSTALL_MODE="c" ;;
n) INSTALL_MODE="n" ;;
*) INSTALL_MODE="r" ;;
esac
else
INSTALL_MODE="r"
log "Non-interactive run with no --mode — defaulting to Recommended (r)."
fi
fi
# Mode N: clean exit, no changes. Rollback is a no-op on a fresh pre-install run.
if [ "$INSTALL_MODE" = "n" ]; then
log "Cancelled, no changes made."
ROLLBACK_ARMED=0
cleanup_install_marker
trap - EXIT ERR INT TERM
exit 0
fi
# --- Custom mode: interactively pick the source of each major dependency ---
# Components with a real source choice: the container backend (podman /
# docker), the FreeRDP client (native package / Flatpak), and the GUI
# (PySide6 yes/no). Everything else is a plain distro package with no
# meaningful alternative. Each prompt has a sensible default so just hitting
# Enter reproduces the Recommended stack.
if [ "$INSTALL_MODE" = "c" ]; then
if [ -z "$WINPODX_BACKEND" ]; then
if [ "$INTERACTIVE" = true ]; then
echo -n "Container backend? [podman/docker] (default podman): "
read -r be_answer < "$TTY_DEV"
case "$(echo "${be_answer:-podman}" | tr '[:upper:]' '[:lower:]')" in
docker) WINPODX_BACKEND="docker" ;;
*) WINPODX_BACKEND="podman" ;;
esac
else
WINPODX_BACKEND="podman"
fi
fi
if [ -z "${WINPODX_FREERDP_SOURCE:-}" ] && [ "$INTERACTIVE" = true ]; then
echo "FreeRDP client source?"
echo " [A]uto prefer the Flatpak when present, else native (recommended)"
echo " [N]ative distro freerdp3 package"
echo " [F]latpak com.freerdp.FreeRDP via Flatpak (better on distros with a broken native freerdp3-x11)"
echo -n "Choice [A/n/f]: "
read -r fr_answer < "$TTY_DEV"
case "$(echo "${fr_answer:-a}" | tr '[:upper:]' '[:lower:]' | cut -c1)" in
n) WINPODX_FREERDP_SOURCE="native" ;;
f) WINPODX_FREERDP_SOURCE="flatpak" ;;
*) WINPODX_FREERDP_SOURCE="auto" ;;
esac
fi
if [ -z "$WINPODX_NO_GUI" ] && [ "$INTERACTIVE" = true ]; then
echo -n "Install the GUI (PySide6)? [Y/n]: "
read -r gui_answer < "$TTY_DEV"
if [[ "$gui_answer" =~ ^[Nn] ]]; then
WINPODX_NO_GUI=1
fi
fi
fi
# --- Automatic mode: pick a usable, already-present backend ---
# Walk winpodx's RECOMMENDED backend order (podman first, then docker --
# podman is the project default per CLAUDE.md; libvirt was dropped in 0.6.0)
# and select the first one that's both present AND usable. So when several runtimes are
# installed, the recommended one wins; we only move down the list when the
# higher-priority runtime is absent or unusable (e.g. podman < 4 on Ubuntu
# 22.04, #271). Fall back to Recommended behaviour (podman + install missing
# deps) when nothing usable is present.
#
# Single source of truth for this priority + the podman major-version gate
# is src/winpodx/backend/select.py:choose_backend() (Python). This bash mirror
# exists because install.sh runs system-check BEFORE Python is installed and
# can't import the Python helper; tests pin the bash + Python copies to the
# same order + version gate so they cannot drift. See docs/design/ROADMAP-
# 0.6.0.md item E.
if [ "$INSTALL_MODE" = "a" ] && [ -z "$WINPODX_BACKEND" ]; then
for candidate in podman docker; do
case "$candidate" in
podman)
[ "$PODMAN_PRESENT" = true ] && [ "$PODMAN_TOO_OLD" = false ] || continue
WINPODX_BACKEND="podman"
log "Automatic: podman $PODMAN_MAJOR.x present + usable — selecting podman (recommended)."
;;
docker)
[ "$DOCKER_PRESENT" = true ] || continue
WINPODX_BACKEND="docker"
log "Automatic: podman absent/too-old, docker present — selecting docker."
;;
esac
break
done
if [ -z "$WINPODX_BACKEND" ]; then
WINPODX_BACKEND="podman"
warn "Automatic: no usable runtime present — falling back to Recommended (podman + install missing deps)."
fi
fi
# --- Recommended mode (default): podman unless --backend given ---
if [ "$INSTALL_MODE" = "r" ] && [ -z "$WINPODX_BACKEND" ]; then
WINPODX_BACKEND="podman"
fi
# Backend 'manual' implies --manual (skip the provisioning chain).
if [ "$WINPODX_BACKEND" = "manual" ]; then
WINPODX_MANUAL=1
fi
# --- Too-old-podman guard (#271, ask 3: graceful handling) ---
# Automatic mode (above) already walks past an unusable podman to docker,
# but Recommended mode and an explicit `--backend podman` do not, so
# a host with podman < 4 (Ubuntu 22.04 ships 3.4) would proceed and then fail
# at provisioning -- AFTER we'd installed packages. Refuse to blindly
# continue: offer a usable alternative when one is present, otherwise exit
# cleanly WITHOUT modifying the system (no package install has run yet here).
# WINPODX_ALLOW_OLD_PODMAN=1 / --allow-old-podman overrides (e.g. podman was
# upgraded out-of-band since the probe).
if [ "$WINPODX_BACKEND" = "podman" ] && [ "$PODMAN_TOO_OLD" = true ] && [ "$WINPODX_ALLOW_OLD_PODMAN" != "1" ]; then
warn "podman $PODMAN_MAJOR.x is too old for WinPodX (need >= 4; Ubuntu 22.04 ships 3.4 -- #271)."
if [ "$INTERACTIVE" = true ]; then
if [ "$DOCKER_PRESENT" = true ]; then
echo "docker is installed and usable."
echo " [d] switch to the docker backend"
echo " [c] continue with podman anyway (will likely fail)"
echo " [a] abort without changing anything (default)"
printf 'Choice [d/c/a]: '
read -r _pm_choice < "$TTY_DEV"
case "$(echo "${_pm_choice:-a}" | tr '[:upper:]' '[:lower:]')" in
d|docker)
WINPODX_BACKEND="docker"; log "Switched backend to docker."
;;
c|continue)
warn "Continuing with podman $PODMAN_MAJOR.x at your own risk."
;;
*)
err "Aborted: upgrade podman to >= 4 or re-run with --backend docker. No changes were made."
exit 1
;;
esac
else
echo "No alternative backend (docker) is installed."
printf 'Continue with podman %s.x anyway? It will likely fail. [y/N]: ' "$PODMAN_MAJOR"
read -r _pm_choice < "$TTY_DEV"
case "$(echo "${_pm_choice:-n}" | tr '[:upper:]' '[:lower:]')" in
y|yes)
warn "Continuing with podman $PODMAN_MAJOR.x at your own risk."
;;
*)
err "Aborted: upgrade podman to >= 4 (e.g. the Kubic repo) or install docker, then re-run. No changes were made."
exit 1
;;
esac
fi
else
# Non-interactive: do NOT silently switch the backend or run a known-
# failing podman install. Exit cleanly with guidance.
if [ "$DOCKER_PRESENT" = true ]; then
warn "docker is installed -- re-run with --backend docker."
fi
warn "Or upgrade podman to >= 4, or pass --allow-old-podman / WINPODX_ALLOW_OLD_PODMAN=1 to force."
err "Non-interactive install aborted before modifying the system: backend=podman but podman $PODMAN_MAJOR.x is too old (#271)."
exit 1
fi
fi
log "Detected distro: $DISTRO"
log "Detected arch: $ARCH_LABEL"
log "Install mode: $INSTALL_MODE | backend: ${WINPODX_BACKEND:-podman} | gui: $([ -n "$WINPODX_NO_GUI" ] && echo no || echo yes)"
# Mark the install as in-progress so child winpodx CLI invocations
# (winpodx setup, pod wait-ready, migrate, app refresh, host-open
# refresh, ...) skip the tray auto-spawn AND the tray's UNRESPONSIVE
# auto-recovery transition. Without this, install.sh's wait windows --
# where the guest is genuinely booting and RDP legitimately isn't
# reachable -- would have the tray fire a "Pod stopped responding"
# notification + try to Restart-Service TermService against a guest
# that's still running first-boot Sysprep. Marker is removed on every
# exit path via the traps above.
mkdir -p "$(dirname "$WINPODX_INSTALL_MARKER")"
echo "$$" > "$WINPODX_INSTALL_MARKER"
chmod 600 "$WINPODX_INSTALL_MARKER" 2>/dev/null || true
# =====================================================================
# Which required system deps are missing?
#
# Mode shapes what we install:
# R -> install all genuinely-missing required deps via pkg mgr.
# A -> install only genuinely-missing required deps (same set; A
# differs only in backend selection, which is already done).
# C -> install deps appropriate to the chosen backend.
# The chosen backend decides whether podman/docker is required.
# =====================================================================
log "Checking dependencies..."
MISSING=()
# Backend runtime requirement.
case "${WINPODX_BACKEND:-podman}" in
podman)
if [ "$PODMAN_PRESENT" = false ] || [ "$PODMAN_TOO_OLD" = true ]; then
# In A mode we only install genuinely-missing deps; a too-old
# podman is "present", so don't try to replace it via pkg mgr
# (the distro repo would reinstall the same old version). Warn
# instead. In R/C we add podman to MISSING only when absent.
if [ "$PODMAN_PRESENT" = false ]; then
MISSING+=("podman")
else
warn "podman is present but too old (major $PODMAN_MAJOR); see the note above. Continuing — pod start may fail until you upgrade podman or switch to --backend docker."
fi
fi
# podman-compose is required but ships separately from podman; without
# it pod creation later fails with "compose command not found" (#503,
# #580). Install it alongside podman.
[ "$PODMAN_COMPOSE_PRESENT" = false ] && MISSING+=("podman-compose")
;;
docker)
[ "$DOCKER_PRESENT" = false ] && MISSING+=("docker")
;;
esac
# FreeRDP is required for every backend (the launcher shells out to it).
# Source resolution (auto | native | flatpak). Custom mode may set
# WINPODX_FREERDP_SOURCE; default auto. winpodx's launcher prefers the FLATPAK
# client when present (flatpak-first auto order, #401). install.sh still
# installs the lightweight NATIVE package when NO client is present at all --
# pulling the whole Flatpak runtime during install is heavy, and the launcher
# uses whatever is there. An already-present Flatpak is reused (never install a
# redundant client). Only an explicit `--freerdp-source flatpak` (Custom)
# installs the Flatpak. The resolved value -> `winpodx setup --freerdp-source`.
WINPODX_FREERDP_SOURCE="${WINPODX_FREERDP_SOURCE:-auto}"
INSTALL_FREERDP_FLATPAK=false
case "$WINPODX_FREERDP_SOURCE" in
flatpak)
# Explicit Flatpak choice (e.g. native freerdp3-x11 is broken, #393).
if [ "$FREERDP_FLATPAK_PRESENT" = false ]; then
INSTALL_FREERDP_FLATPAK=true
[ "$FLATPAK_PRESENT" = false ] && MISSING+=("flatpak")
fi
;;
*) # auto: the launcher prefers an existing Flatpak (flatpak-first), but
# install the lightweight native package only when NO client (native
# or Flatpak) is present — an existing Flatpak is reused, never a
# redundant install.
[ "$FREERDP_PRESENT" = false ] && MISSING+=("freerdp")
;;
esac
# python3 is mandatory (venv host interpreter).
[ "$PYTHON3_PRESENT" = false ] && MISSING+=("python3")
# The Qt6 GUI (PySide6) needs the xcb-cursor platform-plugin runtime library
# (libxcb-cursor.so.0). Qt 6.5+ refuses to start its xcb platform plugin
# without it -- "could not load the Qt platform plugin 'xcb'" -- and it isn't
# pulled in transitively on a minimal desktop (e.g. fresh Linux Mint 22, #712).
# Only add it when the GUI is enabled and the lib isn't already present.
#
# Detection probes the real .so on disk, NOT `ldconfig -p`: on openSUSE the
# library exists at /usr/lib64/libxcb-cursor.so.0 but is absent from the ld.so
# cache, so the ldconfig probe false-negatived and re-prompted every run even
# with libxcb-cursor0 installed (#712 follow-up). Check the standard lib dirs
# (incl. Debian/Ubuntu multiarch) directly, then fall back to ldconfig for any
# non-standard prefix.
_has_libxcb_cursor() {
local d
for d in /usr/lib64 /usr/lib /lib64 /lib \
/usr/lib/x86_64-linux-gnu /usr/lib/aarch64-linux-gnu; do
[ -e "$d/libxcb-cursor.so.0" ] && return 0
done
ldconfig -p 2>/dev/null | grep -q 'libxcb-cursor\.so\.0'
}
if [ -z "$WINPODX_NO_GUI" ] && ! _has_libxcb_cursor; then
MISSING+=("xcb-cursor")
fi
if [ "$KVM_PRESENT" = false ]; then
# Pre-install hint. A surprising fraction of user bug reports start
# here -- the package install loop below will run successfully on
# most distros because qemu / qemu-kvm is already present, and then
# the container start later silently fails because hardware virt
# is off in BIOS. Print the BIOS / module / group check now so the
# user can stop, fix the actual cause, and re-run.
warn "/dev/kvm not found -- KVM hardware virtualization is required."
warn ""
warn "Before continuing, please verify:"
warn " 1. Intel VT-x / AMD-V is enabled in your BIOS / UEFI."
warn " (Reboot -> firmware setup -> 'Intel Virtualization Technology' /"
warn " 'SVM Mode' / 'VT-x'. The setting is OFF by default on many laptops.)"
warn " 2. The kvm kernel module is loaded:"
if command -v lsmod >/dev/null 2>&1; then
modules=$(lsmod 2>/dev/null | grep -E '^(kvm|kvm_intel|kvm_amd)\b' | awk '{print $1}' | tr '\n' ' ')
warn " Currently loaded: ${modules:-none}"
warn " Load it with: sudo modprobe kvm_intel (or kvm_amd on AMD)"
fi
warn " 3. Your user is in the 'kvm' group: id -nG | tr ' ' '\\n' | grep kvm"
warn ""
warn "Installing the qemu package alone won't fix BIOS / module / group issues."
warn "Most 'install ran fine but Windows never boots' bug reports trace back here."
warn ""
MISSING+=("kvm")
fi
# --- Plan summary -----------------------------------------------------------
# Now that the mode + every dependency source is resolved, show exactly what
# this run will do BEFORE touching the system (installing packages, sudo,
# provisioning Windows). Every major component is listed evenly with its
# detected state + the action this run will take. Pure logging — non-blocking.
#
# Per-component action helper: "$present" true => use existing, else (if the
# component name is in MISSING) install, else a component-specific note.
_in_missing() { local x; for x in "${MISSING[@]}"; do [ "$x" = "$1" ] && return 0; done; return 1; }
_plan_action() { # <present-bool> <missing-key>
if [ "$1" = true ]; then echo "use existing"; elif _in_missing "$2"; then echo "install"; else echo "—"; fi
}
_resolved_backend="${WINPODX_BACKEND:-podman}"
case "$_resolved_backend" in
podman) _backend_present="$PODMAN_PRESENT" ;;
docker) _backend_present="$DOCKER_PRESENT" ;;
*) _backend_present=false ;;
esac
echo ""
log "==================== install plan ===================="
log " Mode: $INSTALL_MODE (r=recommended / a=automatic / c=custom)"
log " python3: $(yesno "$PYTHON3_PRESENT") $(tool_version python3) [host interpreter + private venv]"
log " venv: $(yesno "$VENV_PROBE_OK") [python3 -m venv works]"
log " Backend: $_resolved_backend ($(_plan_action "$_backend_present" "$_resolved_backend"))"
if [ "$FREERDP_PRESENT" = true ]; then
log " FreeRDP: use existing — $FREERDP_KIND"
elif [ "$INSTALL_FREERDP_FLATPAK" = true ]; then
log " FreeRDP: install Flatpak (com.freerdp.FreeRDP)"
else
log " FreeRDP: install native package"
fi
log " KVM: $(yesno "$KVM_PRESENT") /dev/kvm [host virtualization — required, not installed]"
log " GUI: $([ -n "$WINPODX_NO_GUI" ] && echo 'no (--no-gui)' || echo 'yes (PySide6, into venv)')"
if [ "${#MISSING[@]}" -gt 0 ]; then
log " Will install: ${MISSING[*]} (via distro package manager, sudo)"
else
log " Will install: nothing — all required system packages already present"
fi
if [ "${WINPODX_MANUAL:-0}" = "1" ] || [ "$WINPODX_BACKEND" = "manual" ]; then
log " Windows VM: NOT provisioned (manual mode); run 'winpodx setup' later to finish"
else
log " Windows VM: setup -> first-boot (~7.5GB ISO on a fresh install)"
log " -> apply-fixes -> discovery -> reverse-open"
fi
log "======================================================"
echo ""
if [ ${#MISSING[@]} -gt 0 ]; then
if [ -n "$WINPODX_SKIP_DEPS" ]; then
err "--skip-deps is set but required tools are missing: ${MISSING[*]}"
err "Install them manually and re-run, or drop --skip-deps."
exit 1
fi
log "Missing: ${MISSING[*]}"
echo ""
echo " The following will be installed via $(command -v zypper || command -v dnf || command -v apt-get || command -v pacman):"
for dep in "${MISSING[@]}"; do
echo " - $(pkg_name "$dep")"
done
echo ""
if [ "$INTERACTIVE" = true ]; then
echo -n " Proceed with installation? (Y/n): "
read -r answer < "$TTY_DEV"
if [[ "$answer" =~ ^[Nn] ]]; then
err "Aborted. Install dependencies manually and try again."
exit 1
fi
else
log " Non-interactive — proceeding with package install."
fi
INSTALL_FAIL=0
for dep in "${MISSING[@]}"; do
if ! install_pkg "$dep"; then
warn "Failed to install: $(pkg_name "$dep")"
INSTALL_FAIL=$((INSTALL_FAIL + 1))
fi
done
if [ "$INSTALL_FAIL" -gt 0 ]; then
err "$INSTALL_FAIL package(s) failed to install. Fix manually and re-run."
exit 1
fi
log "All dependencies installed successfully"
else
log "All dependencies OK"
fi
# Install the Flatpak FreeRDP when that's the resolved source (auto with a
# flatpak runtime but no client yet, or an explicit --freerdp-source flatpak).
# Best-effort: if the Flatpak install fails (no flathub remote, offline, …)
# fall back to the native package so the user still ends up with a client.
if [ "$INSTALL_FREERDP_FLATPAK" = true ]; then
log "Installing FreeRDP via Flatpak (com.freerdp.FreeRDP)..."
flatpak remote-add --if-not-exists --user flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo >/dev/null 2>&1 || true
if flatpak install -y --user flathub com.freerdp.FreeRDP >/dev/null 2>&1 \
|| flatpak install -y flathub com.freerdp.FreeRDP >/dev/null 2>&1; then
log " Flatpak FreeRDP installed."
FREERDP_PRESENT=true
WINPODX_FREERDP_SOURCE="flatpak"
else
warn " Flatpak FreeRDP install failed — falling back to the native package."
WINPODX_FREERDP_SOURCE="auto"
if [ "$FREERDP_NATIVE_PRESENT" = false ]; then
if install_pkg freerdp; then
FREERDP_PRESENT=true
else
warn " Native FreeRDP install also failed — install a FreeRDP 3 client manually."
fi
fi
fi
fi
# Re-verify /dev/kvm after the install loop. Installing the qemu /
# qemu-kvm package alone does NOT enable hardware virtualisation if
# the CPU extension is off in BIOS, the kvm kernel module isn't
# loaded, or the user isn't in the `kvm` group -- @pnogaret2019-code
# hit this on Linux Mint LMDE 7 (#220) where apt happily said
# "qemu-system-x86 already up to date" while /dev/kvm stayed absent,
# and install.sh printed "All dependencies installed successfully"
# anyway. Without this guard the container starts but the VM never
# boots, and the user sees a silent stall instead of the actionable
# diagnostic below.
#
# #541: /dev/kvm is often absent simply because the kvm kernel module
# isn't loaded -- even though the CPU fully supports virtualization
# (vmx/svm flags present; e.g. confirmed by VirtualBox working on the
# same box). Try to load it before failing -- many "virtualization
# unsupported" reports just needed a modprobe.
if [ ! -e /dev/kvm ] && grep -Eq '(vmx|svm)' /proc/cpuinfo 2>/dev/null; then
if grep -q 'vmx' /proc/cpuinfo 2>/dev/null; then KVM_MOD=kvm_intel; else KVM_MOD=kvm_amd; fi
log "/dev/kvm missing but the CPU reports virtualization (vmx/svm) -- loading $KVM_MOD..."
sudo modprobe "$KVM_MOD" 2>/dev/null || true
if [ -e /dev/kvm ]; then
KVM_PRESENT=true
log "/dev/kvm is now present -- hardware virtualization enabled."
# Persist so it auto-loads on the next boot (don't make the user redo this).
if [ -d /etc/modules-load.d ]; then
printf '%s\n' "$KVM_MOD" | sudo tee /etc/modules-load.d/winpodx-kvm.conf >/dev/null 2>&1 || true
fi
fi
fi
if [ ! -e /dev/kvm ]; then
err "/dev/kvm still missing after package install."
err ""
err "Hardware virtualisation is required for WinPodX. Likely causes:"
err " 1. Intel VT-x / AMD-V disabled in BIOS / UEFI."
err " -> Reboot, enter setup, look for 'Intel Virtualization Technology'"
err " / 'SVM Mode' / 'VT-x' and enable it."
err " 2. kvm kernel module not loaded:"
if command -v lscpu >/dev/null 2>&1; then
vmx=$(lscpu 2>/dev/null | grep -i 'virtualization\|vmx\|svm' | head -1)
err " lscpu: ${vmx:-no virtualization line found}"
fi
if command -v lsmod >/dev/null 2>&1; then
modules=$(lsmod 2>/dev/null | grep -E '^(kvm|kvm_intel|kvm_amd)\b' | awk '{print $1}' | tr '\n' ' ')
err " Loaded kvm modules: ${modules:-none}"
err " -> 'sudo modprobe kvm_intel' (Intel) or 'sudo modprobe kvm_amd' (AMD)"
fi
err " 3. Your user is not in the 'kvm' group:"
if command -v id >/dev/null 2>&1; then
if id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx kvm; then
err " id: '$USER' is in the kvm group (this one is fine)."
else
err " id: '$USER' is NOT in the kvm group."
err " -> 'sudo usermod -aG kvm $USER' then log out + back in."
fi
fi
err ""
err "Fix one of the above and re-run install.sh."
exit 1
fi
# --- Check Python version (host interpreter that builds the venv) ---
PY_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
PY_MAJOR=$(echo "$PY_VERSION" | cut -d. -f1)
PY_MINOR=$(echo "$PY_VERSION" | cut -d. -f2)
if [ "$PY_MAJOR" -lt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -lt 9 ]; }; then
err "Python 3.9+ required (found $PY_VERSION)"
exit 1
fi
log "Python $PY_VERSION OK"
# --- Clone, update, or copy winpodx source ---
mkdir -p "$(dirname "$INSTALL_DIR")"
copy_from_local() {
local src="$1"
if [ -d "$INSTALL_DIR" ]; then
rm -rf "$INSTALL_DIR"
fi
mkdir -p "$INSTALL_DIR"
for item in src data config scripts install.sh uninstall.sh pyproject.toml README.md LICENSE; do
if [ -e "$src/$item" ]; then
cp -r "$src/$item" "$INSTALL_DIR/"
fi
done
}
# Resolve the install ref. Default (empty WINPODX_REF) -> latest release.
# `--main` / WINPODX_REF=main bypasses the API call so an unreachable
# api.github.com still lets dev installs proceed.
resolve_ref() {
if [ -n "$WINPODX_REF" ]; then
echo "$WINPODX_REF"
return
fi
if ! command -v curl >/dev/null 2>&1; then
echo "main"
return
fi
local latest
latest=$(curl -fsSL "$REPO_API/releases/latest" 2>/dev/null \
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n1)
if [ -n "$latest" ]; then
echo "$latest"
else
echo "main"
fi
}
if [ -n "$WINPODX_SOURCE" ]; then
# --source wins over every other path; no git at all.
log "Copying WinPodX from --source: $WINPODX_SOURCE"
copy_from_local "$WINPODX_SOURCE"
else
INSTALL_REF="$(resolve_ref)"
if [ "$INSTALL_REF" = "main" ]; then
log "Installing from git main (development)"
else
log "Installing release: $INSTALL_REF (use --main for development build)"
fi
if [ -d "$INSTALL_DIR/.git" ]; then
log "Updating existing installation to $INSTALL_REF..."
git -C "$INSTALL_DIR" fetch --quiet --tags --prune origin
# Check out the freshly-fetched ref. Prefer origin/<ref> so a BRANCH
# advances to its latest remote tip — `git fetch` updates origin/<ref>
# but NOT the stale local branch, so a plain `checkout <branch>` would
# pin the install to whatever commit was first cloned (#616: every
# re-run reinstalled the same old commit). Fall back to <ref> for tags
# / commits, which aren't origin/-prefixed.
git -C "$INSTALL_DIR" checkout --quiet --detach "origin/$INSTALL_REF" 2>/dev/null \
|| git -C "$INSTALL_DIR" checkout --quiet --detach "$INSTALL_REF" 2>/dev/null \
|| git -C "$INSTALL_DIR" checkout --quiet "$INSTALL_REF"
else
# If running from repo, copy only needed files (skip .venv, .git, etc.).
# When piped via `curl ... | bash`, bash reads from stdin and
# BASH_SOURCE[0] is unset — `set -u` would abort here without the
# default expansion. Fall through to git-clone when there's no local tree.
_src="${BASH_SOURCE[0]:-}"
if [ -n "$_src" ]; then
SCRIPT_DIR="$(cd "$(dirname "$_src")" && pwd)"
else
SCRIPT_DIR=""
fi
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/src/winpodx/__init__.py" ]; then
log "Installing from local repository..."
copy_from_local "$SCRIPT_DIR"
else
if ! command -v git >/dev/null 2>&1; then
# git isn't preinstalled on some fresh systems (e.g. Linux Mint);
# auto-install it like every other dependency so the one-liner
# is self-contained (#705). Fall back to a clear error if the
# package manager can't provide it.
log "git is required to fetch WinPodX but isn't installed; installing it..."
install_pkg git || true
if ! command -v git >/dev/null 2>&1; then
err "git is required for remote install but could not be installed automatically."
err "Install git manually (e.g. 'sudo apt-get install git') and re-run, or run from a cloned repository."
exit 1
fi
fi
log "Cloning from GitHub..."
if [ -d "$INSTALL_DIR" ]; then
rm -rf "$INSTALL_DIR"
fi
git clone --quiet "$REPO_URL" "$INSTALL_DIR"
git -C "$INSTALL_DIR" fetch --quiet --tags --prune origin
# origin/<ref> first so a branch lands on its latest tip; fall
# back to <ref> for tags / commits (see the update path above).
git -C "$INSTALL_DIR" checkout --quiet --detach "origin/$INSTALL_REF" 2>/dev/null \
|| git -C "$INSTALL_DIR" checkout --quiet --detach "$INSTALL_REF" 2>/dev/null \
|| git -C "$INSTALL_DIR" checkout --quiet "$INSTALL_REF"
fi
fi
fi
# =====================================================================
# Mandatory venv (all modes).
#
# Python ALWAYS runs from a private venv. Create it; if creation fails
# because python3-venv / ensurepip is missing, install that one package
# via the distro pkg mgr (sudo) in R/A/C, then retry; if still failing,
# error out (the ERR trap rolls back on a fresh install).
# =====================================================================
create_venv() {
rm -rf "$VENV_DIR"
python3 -m venv "$VENV_DIR"
}
log "Creating private virtualenv at $VENV_DIR ..."
if ! create_venv 2>/dev/null; then
if [ -n "$WINPODX_SKIP_DEPS" ]; then
err "python3 -m venv failed and --skip-deps is set."
err "Install your distro's python3-venv / ensurepip package and re-run."
exit 1
fi
warn "venv creation failed — likely a missing python3-venv / ensurepip. Installing it..."
install_pkg "python3-venv" || true
if ! create_venv; then
err "venv creation still failing after installing $(pkg_name python3-venv)."
err "Install your distro's python3-venv / ensurepip package manually and re-run."
exit 1
fi
fi
# Upgrade pip/setuptools/wheel in the venv (quiet; non-fatal cosmetics).
"$VENV_PY" -m pip install --quiet --upgrade pip setuptools wheel || \
warn "pip self-upgrade failed; continuing with the bundled pip."
# Install winpodx itself from the in-place source tree. This resolves
# winpodx's own declared runtime deps (tomli on 3.9/3.10 via the
# python_version marker) from pyproject. We then add the reverse-open
# icon deps (cairosvg + pyxdg) and, unless --no-gui, PySide6 — pinned to
# the same ranges pyproject declares so we don't invent versions.
log "Installing WinPodX into the venv (pip install '$INSTALL_DIR')..."
if [ -n "$WINPODX_NO_GUI" ]; then
# Headless: winpodx core + reverse-open icon quality, no PySide6.
"$VENV_PY" -m pip install --quiet "${INSTALL_DIR}[reverse-open]"
log "Headless install (--no-gui): PySide6 skipped."
else
# Full: winpodx core + reverse-open + GUI.
"$VENV_PY" -m pip install --quiet "${INSTALL_DIR}[gui,reverse-open]"
fi
# Belt-and-suspenders: ensure cairosvg + pyxdg are present even if a
# future pyproject reshuffle moves them out of the reverse-open extra.
# These two drive SVG / themed app-icon conversion for reverse-open.
if ! "$VENV_PY" -c "import cairosvg, xdg" >/dev/null 2>&1; then
log "Ensuring reverse-open icon deps (cairosvg + pyxdg) in the venv..."
"$VENV_PY" -m pip install --quiet "cairosvg>=2.7,<3.0" "pyxdg>=0.27,<1.0" || \
warn "cairosvg/pyxdg install into venv failed; SVG/themed icons will use a placeholder."
fi
# --- Load Windows container image from local tar (--image-tar) ---
# Runs AFTER the winpodx source is in place so the rest of the install
# can still proceed if the load fails (first-boot would pull from the
# registry as a fallback — warn but don't abort).
if [ -n "$WINPODX_IMAGE_TAR" ]; then
log "Loading Windows container image from $WINPODX_IMAGE_TAR..."
if command -v podman >/dev/null 2>&1; then
podman load -i "$WINPODX_IMAGE_TAR" || warn "image load failed; first boot may try the registry"
elif command -v docker >/dev/null 2>&1; then
docker load -i "$WINPODX_IMAGE_TAR" || warn "image load failed; first boot may try the registry"
else
warn "neither podman nor docker found; cannot load image tar"
fi
fi
# --- Create launcher script ---
# v2: exec the VENV's python, not system python3 + PYTHONPATH. No system
# python pollution; winpodx + its deps live entirely under the venv.
cat > "$LAUNCHER" << 'LAUNCHER_EOF'
#!/usr/bin/env bash
WINPODX_DIR="$HOME/.local/bin/winpodx-app"
exec "$WINPODX_DIR/.venv/bin/python" -m winpodx "$@"
LAUNCHER_EOF
chmod +x "$LAUNCHER"
# --- Create 'winpodx' command (symlink to launcher) ---
ln -sfn "$LAUNCHER" "$SYMLINK"
# Ensure ~/.local/bin is in PATH
if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then
warn "$HOME/.local/bin is not in PATH"
warn "Add this to your ~/.bashrc or ~/.zshrc:"
warn ' export PATH="$HOME/.local/bin:$PATH"'
fi
# --- Run setup ---
# --manual / WINPODX_MANUAL=1: skip setup + the entire provisioning
# chain below (wait-ready / migrate / discovery / reverse-open). The
# binary + venv are installed, but the Windows VM stays unprovisioned
# until the user picks one of the first-run prompt options on the next
# `winpodx` invocation (CLI Y/C/n or GUI modal -- #255 PR 1).
if [ "${WINPODX_MANUAL:-0}" = "1" ]; then
log "Manual mode (--manual / WINPODX_MANUAL=1 / --backend manual) — skipping setup + Windows provisioning."
log " Run 'winpodx setup' to finish setup (or 'winpodx gui' for the graphical first-run with auto / customize / skip)."
else
log "Running winpodx setup..."
# 0.6.0 item B: --create-only is gone. setup writes config + creates the
# container; the post-create chain runs once via `winpodx provision`
# (below) — the single source of truth shared with setup_cmd / migrate /
# pending.resume. WINPODX_NO_PROVISION makes setup itself skip its own
# full-provision tail so install.sh's explicit `winpodx provision` is the
# only run (setup would otherwise also run finish_provisioning).
SETUP_ARGS=(--non-interactive)
if [ -n "$WINPODX_BACKEND" ] && [ "$WINPODX_BACKEND" != "manual" ]; then
SETUP_ARGS+=(--backend "$WINPODX_BACKEND")
log "Backend: $WINPODX_BACKEND"
fi
if [ -n "$WINPODX_WIN_VERSION" ]; then
SETUP_ARGS+=(--win-version "$WINPODX_WIN_VERSION")
log "Installing Windows edition: $WINPODX_WIN_VERSION"
fi
if [ -n "$WINPODX_STORAGE_DIR" ]; then
SETUP_ARGS+=(--storage-path "$WINPODX_STORAGE_DIR")
log "Storage location: $WINPODX_STORAGE_DIR"
fi
# #647: hand the local ISO to setup so it stages <storage>/custom.iso
# BEFORE compose-up (dockur needs it present when the container boots).
if [ -n "$WINPODX_WIN_ISO" ]; then
SETUP_ARGS+=(--win-iso "$WINPODX_WIN_ISO")
log "Local Windows ISO: $WINPODX_WIN_ISO"
fi
# Persist the resolved FreeRDP source so the launcher honours it
# (cfg.rdp.freerdp_source). Skip "auto" — that's the default.
if [ -n "${WINPODX_FREERDP_SOURCE:-}" ] && [ "$WINPODX_FREERDP_SOURCE" != "auto" ]; then
SETUP_ARGS+=(--freerdp-source "$WINPODX_FREERDP_SOURCE")
log "FreeRDP source: $WINPODX_FREERDP_SOURCE"
fi
# WINPODX_NO_PROVISION=1: setup creates the container but skips its own
# full-provision tail; install.sh runs the chain once via the explicit
# `winpodx provision` call below (0.6.0 item B, replaces --create-only).
WINPODX_NO_PROVISION=1 "$VENV_PY" -m winpodx setup "${SETUP_ARGS[@]}" 2>/dev/null || true
fi
# NOTE: --win-iso staging now happens INSIDE `winpodx setup` (passed via
# SETUP_ARGS above), before compose-up, so dockur finds custom.iso when the
# container boots (#647). It used to be staged here, after setup had already
# started the container + dockur had begun downloading — too late.
# --- Install winpodx GUI desktop entry & icon ---
mkdir -p "$DESKTOP_DIR" "$ICON_DIR"
cp "$INSTALL_DIR/data/winpodx.desktop" "$DESKTOP_DIR/winpodx.desktop"
cp "$INSTALL_DIR/data/winpodx-icon.svg" "$ICON_DIR/winpodx.svg"
# Ensure index.theme exists (required for KDE icon cache)
if [ ! -f "$ICON_BASE/index.theme" ]; then
if [ -f /usr/share/icons/hicolor/index.theme ]; then
cp /usr/share/icons/hicolor/index.theme "$ICON_BASE/index.theme"
else
cat > "$ICON_BASE/index.theme" << 'INDEXEOF'
[Icon Theme]
Name=Hicolor
Comment=Fallback icon theme
Hidden=true
Directories=scalable/apps
[scalable/apps]
Size=64
MinSize=1
MaxSize=512
Context=Applications
Type=Scalable
INDEXEOF
fi
fi
gtk-update-icon-cache -f -t "$ICON_BASE" 2>/dev/null || true
# Rebuild KDE Plasma sycoca cache
kbuildsycoca6 --noincremental 2>/dev/null || kbuildsycoca5 --noincremental 2>/dev/null || true
log "Installed WinPodX GUI launcher and icon"
# v0.1.9: bundled app profiles were dropped. The app menu now populates
# automatically the first time the Windows pod boots — `winpodx app run
# desktop` starts the pod, the provisioner auto-fires discovery, and the
# discovered apps + their real Windows-extracted icons land in the menu.
# Manual trigger any time: `winpodx app refresh`.
# In manual mode the provisioning chain below is skipped entirely.
if [ "${WINPODX_MANUAL:-0}" = "1" ]; then
ROLLBACK_ARMED=0
cleanup_install_marker
trap - EXIT ERR INT TERM
echo ""
echo " Location: $INSTALL_DIR"
echo " Command: winpodx"
echo ""
echo " Manual mode — Windows VM was NOT provisioned."
echo ""
echo " Next step (pick one):"
echo " winpodx gui # GUI first-run modal: auto / customize / skip"
echo " winpodx setup # Run setup directly (non-interactive auto)"
echo " winpodx setup --customize"
echo " # Run setup wizard (pick every knob)"
echo ""
log "Installation complete!"
exit 0
fi
# --- Finish provisioning the Windows VM (0.6.0 item B) ---
# The wait-ready → agent-settle → apply-fixes → discovery → reverse-open
# chain used to be ~140 lines of bash here (with its own /health curl poll,
# 6× `app refresh` retry loop, and host-open listener-start). It is now the
# single `winpodx provision` command — the same chain setup_cmd, migrate,
# and pending.resume run — so there is exactly one place to fix a bug and
# one shared progress surface. install.sh just forwards $WINPODX_VERBOSE.
#
# Fresh vs upgrade are DIFFERENT flows (a blind `provision`-for-both was the
# regression in the first cut of item B):
#
# * FRESH (no prior config): `winpodx provision --require-agent`. The new
# container's install.bat already laid down the current guest scripts, so
# no guest_sync is needed. --require-agent (#271) makes discovery/apply
# refuse the FreeRDP fallback and DEFER (exit 5 -> pending) rather than
# race FreeRDP into install.bat's autologon session while the agent is
# still flapping during first boot.
#
# * UPGRADE (prior config existed): `winpodx migrate`. migrate FIRST pushes
# the refreshed guest scripts into the existing guest (guest_sync) + pins
# the image + shows release notes, THEN runs the same apply -> discovery ->
# reverse-open chain. Skipping migrate (as the first item-B cut did) left
# upgraded guests running STALE agent.ps1 / OEM scripts.
#
# Both commands stream the live boot progress (the #126 wget-ETA dynamic
# deadline + self-erasing line) and both honour the .pending_setup safety net.
#
# Exit codes (both):
# 0 — chain succeeded
# 4 — Windows guest didn't become responsive in time (wait-ready timeout)
# 5 — deferred (agent-first: agent never came up; resume via pending)
# 4 and 5 are non-fatal: the .pending_setup machinery (utils/pending.py) is the
# safety net — the next `winpodx` CLI / GUI launch resumes the chain.
# Skip the whole step with WINPODX_NO_WAIT=1 (CI / non-interactive setups).
PENDING_FILE="$HOME/.config/winpodx/.pending_setup"
if [ -f "$HOME/.config/winpodx/winpodx.toml" ] && [ "${WINPODX_NO_WAIT:-}" != "1" ]; then
PROVISION_OUT="$(mktemp)"
if [ "$IS_FRESH_INSTALL" = "1" ]; then
log "Finishing Windows provisioning (wait-ready + apply-fixes + discovery + reverse-open)..."
log " Fresh install downloads ~7.5GB Windows ISO + runs Sysprep + OEM apply (auto-extends on slow links)."
log " Subsequent installs reuse the cached ISO and finish in 2-5 min."
PROVISION_CMD=(provision --require-agent)
[ -n "$WINPODX_VERBOSE" ] && PROVISION_CMD+=(--verbose)
else
log "Upgrade detected — running migration (sync guest scripts + apply-fixes + discovery + reverse-open)..."
log " Re-uses the existing Windows install; no ISO re-download."
PROVISION_CMD=(migrate --non-interactive)
[ -n "$WINPODX_VERBOSE" ] && PROVISION_CMD+=(--verbose)
fi
# PYTHONUNBUFFERED=1 keeps the streamed per-stage progress line-buffered
# when piped. We inspect the rc explicitly and tee the output so the
# `no such container` partial-uninstall case is still detectable.
#
# Disarm the ERR-trap rollback around this call. `set +e` alone is NOT
# enough: bash fires the ERR trap on a failing *pipeline* even with
# errexit off, so a deferred (exit 5) or slow (exit 4) provision —
# where Windows is already downloaded, booted, and recoverable via the
# pending machinery — would otherwise roll back the whole fresh install
# before the rc handling below ever runs. We branch on the rc ourselves.
set +e
trap - ERR
PYTHONUNBUFFERED=1 "$SYMLINK" "${PROVISION_CMD[@]}" 2>&1 \
| tee "$PROVISION_OUT"
PROVISION_RC="${PIPESTATUS[0]}"
trap rollback_and_exit_err ERR
set -e
# Ctrl+C / SIGTERM: bail out. The traps fire in the parent shell too, but
# this covers the piped-install case where the child died from the signal
# and the parent didn't see it directly.
if [ "$PROVISION_RC" -eq 130 ] || [ "$PROVISION_RC" -eq 143 ]; then
err "Install cancelled (winpodx ${PROVISION_CMD[0]} returned $PROVISION_RC)."
err "Re-run install.sh to continue from where you left off."
rm -f "$PROVISION_OUT"
ROLLBACK_ARMED=0
cleanup_install_marker
trap - EXIT ERR INT TERM
exit "$PROVISION_RC"
fi
if [ "$PROVISION_RC" -eq 4 ] || [ "$PROVISION_RC" -eq 5 ]; then
# DEFERRED, not failed. Windows is downloaded, booted, and the pod is
# up; only the agent-first discovery deferred (5) or wait-ready ran
# long (4). Rolling back here would throw away ~15 min of ISO download
# + boot for a state that finishes itself. Record the remaining steps
# as pending — they auto-resume on the next `winpodx` invocation — and
# treat the install as a SUCCESS so the artifacts stay in place.
warn "Windows is installed and the pod is up, but app discovery deferred"
warn "until the in-guest agent finishes coming up (it can lag a minute"
warn "after the first-boot reboot). This is not a failure — it finishes"
warn "automatically on your next \`winpodx\` run, or force it now with:"
warn " winpodx app refresh"
mkdir -p "$HOME/.config/winpodx"
printf 'wait_ready\nmigrate\ndiscovery\n' > "$PENDING_FILE"
warn "Pending steps recorded at $PENDING_FILE."
elif [ "$PROVISION_RC" -ne 0 ]; then
if grep -q "no such container" "$PROVISION_OUT"; then
warn "Container is missing — likely from a partial uninstall."
warn "Recover with a full reinstall:"
warn ' curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --purge'
warn ' curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --main'
else
warn "Provisioning didn't complete in time (Windows first-boot can run long"
warn "on slow links -- see #126). Marking remaining steps as pending — they"
warn "auto-resume on the next \`winpodx\` CLI invocation or when you open the GUI."
mkdir -p "$HOME/.config/winpodx"
# wait_ready first so pending.resume re-runs the full chain.
printf 'wait_ready\nmigrate\ndiscovery\n' > "$PENDING_FILE"
warn "Pending steps recorded at $PENDING_FILE."
fi
else
rm -f "$PENDING_FILE"
fi
rm -f "$PROVISION_OUT"
fi
# --- Done. Disarm rollback (success). ---
ROLLBACK_ARMED=0
cleanup_install_marker
trap - EXIT ERR INT TERM
INSTALLED_VER="$("$VENV_PY" -c 'import winpodx; print(winpodx.__version__)' 2>/dev/null || echo '?')"
GUI_LABEL="yes"
[ -n "$WINPODX_NO_GUI" ] && GUI_LABEL="no (--no-gui)"
# Apply the update to an already-RUNNING WinPodX immediately. A long-lived tray
# or GUI keeps the OLD code in memory, so on an upgrade the new code otherwise
# only takes effect after the user manually restarts or re-logs in (this caused
# repeated "the fix didn't work" confusion). Restart ONLY the winpodx APP
# processes by targeting the 'tray' / 'gui' entrypoints precisely.
#
# NEVER use a bare `pkill -f winpodx`: that also matches conmon (the container
# monitor for the 'winpodx-windows' pod) and would tear down the running
# Windows VM + every live RDP session. The 'tray'/'gui' patterns below do not
# match conmon, the QEMU process, or this installer's own provision/migrate run.
# A fresh install has nothing running here, so this is a no-op.
if command -v pgrep >/dev/null 2>&1; then
_wpx_was_tray=0
_wpx_was_gui=0
pgrep -f 'winpodx tray' >/dev/null 2>&1 && _wpx_was_tray=1
pgrep -f 'winpodx gui' >/dev/null 2>&1 && _wpx_was_gui=1
if [ "$_wpx_was_tray" = 1 ] || [ "$_wpx_was_gui" = 1 ]; then
log "Restarting WinPodX (tray/GUI) to apply the update (the pod keeps running)…"
pkill -f 'winpodx tray' 2>/dev/null || true
pkill -f 'winpodx gui' 2>/dev/null || true
sleep 1
# If the GUI was up, relaunch it (the GUI auto-spawns its own tray);
# otherwise just relaunch the tray. Avoids a double tray.
if [ "$_wpx_was_gui" = 1 ]; then
setsid "$SYMLINK" gui >/dev/null 2>&1 </dev/null &
elif [ "$_wpx_was_tray" = 1 ]; then
setsid "$SYMLINK" tray >/dev/null 2>&1 </dev/null &
fi
fi
fi
echo ""
echo -e "${GREEN} ┌─ WinPodX installed ──────────────────────────────────────${NC}"
printf " │ version %s\n" "$INSTALLED_VER"
printf " │ backend %s\n" "${WINPODX_BACKEND:-podman}"
printf " │ GUI %s\n" "$GUI_LABEL"
printf " │ location %s\n" "$INSTALL_DIR (private venv)"
echo -e "${GREEN} └──────────────────────────────────────────────────────────${NC}"
echo ""
echo " Next steps:"
if [ -z "$WINPODX_NO_GUI" ]; then
echo " winpodx gui # open the GUI — system tray + app manager"
fi
echo " winpodx app run desktop # the full Windows desktop"
echo " winpodx app refresh # rescan the apps installed in Windows"
echo " winpodx doctor # health check if something looks off"
echo " winpodx --help # every command"
echo ""
echo " Your Windows apps are already in the application menu — click any one"
echo " and WinPodX handles the pod, the RDP session, and the window for you."
echo ""
log "Installation complete!"