diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cc369fb --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Windows Docker VM Configuration +# +# Two env files are provided with optimized defaults: +# - .env.modern (8G RAM, 4 CPU) - Windows 10/11, Server 2016+ +# - .env.legacy (2G RAM, 2 CPU) - Windows 7/8, Vista, XP, 2000, Server 2003-2012, Tiny +# +# Each compose file references the appropriate env file automatically. +# Edit .env.modern or .env.legacy to customize settings. +# +# Available settings: +# +# RAM_SIZE=8G # Memory allocation +# CPU_CORES=4 # CPU cores +# DISK_SIZE=128G # Virtual disk size +# USERNAME=Docker # Windows username +# PASSWORD=admin # Windows password +# LANGUAGE=en # Installation language +# REGION=en-US # Region setting +# KEYBOARD=en-US # Keyboard layout +# WIDTH=1280 # Display width +# HEIGHT=720 # Display height +# DHCP=N # Use DHCP (Y/N) +# SAMBA=Y # Enable file sharing (Y/N) +# RESTART_POLICY=on-failure # Restart policy (no, on-failure, always, unless-stopped) +# DEBUG=N # Debug mode (Y/N) +# WINDOWS_IMAGE=dockurr/windows # Docker image (dockurr/windows-arm for ARM64) +# +# winctl.sh settings (place in .env file): +# +# AUTO_CACHE=N # Auto-cache ISOs on stop (Y/N) diff --git a/.env.legacy b/.env.legacy new file mode 100644 index 0000000..9f72db3 --- /dev/null +++ b/.env.legacy @@ -0,0 +1,32 @@ +# Legacy Systems Configuration (Windows 7/8, Vista, XP, 2000, Server 2003-2012, Tiny) + +# Resources +RAM_SIZE=2G +CPU_CORES=2 +DISK_SIZE=32G + +# User Credentials +USERNAME=docker +PASSWORD=admin + +# Language & Region +LANGUAGE=en +REGION=en-US +KEYBOARD=en-US + +# Display +WIDTH=1280 +HEIGHT=720 + +# Network +DHCP=N +SAMBA=Y + +# Restart Policy (no, on-failure, always, unless-stopped) +RESTART_POLICY=on-failure + +# Docker Image (dockurr/windows for x86, dockurr/windows-arm for ARM64) +WINDOWS_IMAGE=dockurr/windows + +# Debug +DEBUG=N diff --git a/.env.modern b/.env.modern new file mode 100644 index 0000000..ac5e811 --- /dev/null +++ b/.env.modern @@ -0,0 +1,32 @@ +# Modern Systems Configuration (Windows 10/11, Server 2016+) + +# Resources +RAM_SIZE=8G +CPU_CORES=4 +DISK_SIZE=128G + +# User Credentials +USERNAME=docker +PASSWORD=admin + +# Language & Region +LANGUAGE=en +REGION=en-US +KEYBOARD=en-US + +# Display +WIDTH=1280 +HEIGHT=720 + +# Network +DHCP=N +SAMBA=Y + +# Restart Policy (no, on-failure, always, unless-stopped) +RESTART_POLICY=on-failure + +# Docker Image (dockurr/windows for x86, dockurr/windows-arm for ARM64) +WINDOWS_IMAGE=dockurr/windows + +# Debug +DEBUG=N diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 7dd6f48..0f19a94 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -22,6 +22,7 @@ jobs: with: locale: "US" level: warning + fail_level: error pattern: | *.md *.sh @@ -32,6 +33,7 @@ jobs: uses: reviewdog/action-hadolint@v1 with: level: warning + fail_level: error reporter: github-pr-review hadolint_ignore: DL3006 DL3008 github_token: ${{ secrets.GITHUB_TOKEN }} @@ -40,6 +42,7 @@ jobs: uses: reviewdog/action-yamllint@v1 with: level: warning + fail_level: error reporter: github-pr-review github_token: ${{ secrets.GITHUB_TOKEN }} - @@ -47,6 +50,7 @@ jobs: uses: reviewdog/action-actionlint@v1 with: level: warning + fail_level: error reporter: github-pr-review github_token: ${{ secrets.GITHUB_TOKEN }} - @@ -54,6 +58,7 @@ jobs: uses: reviewdog/action-shfmt@v1 with: level: warning + fail_on_error: "true" shfmt_flags: "-i 2 -ci -bn" github_token: ${{ secrets.GITHUB_TOKEN }} - @@ -61,6 +66,7 @@ jobs: uses: reviewdog/action-shellcheck@v1 with: level: warning + fail_level: error reporter: github-pr-review shellcheck_flags: -x -e SC1091 -e SC2001 -e SC2002 -e SC2034 -e SC2064 -e SC2153 -e SC2317 -e SC2028 github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 8b13789..8fc5726 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,18 @@ +# Environment file (contains local settings) +.env +# Ignore data folder contents but keep structure +data/* +!data/*/ +data/*/** +!data/*/.gitkeep + +# Snapshot contents (generated at runtime) +snapshots/* +!snapshots/.gitkeep + +# Instance compose files and registry (generated at runtime) +instances/ + +# ISO cache (cached ISOs for faster instance creation) +cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6d9507f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2026-01-30 + +### Added +- **winctl.sh**: Management script for Windows Docker containers + - 20+ commands: start, stop, restart, status, logs, shell, stats, build, rebuild, list, inspect, monitor, check, refresh, open, pull, disk, snapshot, restore, clean, destroy, instances, cache, help + - Interactive menus for version selection + - Prerequisites checking (Docker, Compose, KVM, TUN, memory, disk) + - Color-coded output with professional table formatting + - Safety confirmations for destructive operations + - Support for all 22 Windows versions across 4 categories + - JSON status cache (`~/.cache/winctl/status.json`) with auto-refresh +- **Multi-instance support**: run multiple instances of the same version + - `start --new` creates auto-numbered instances with allocated ports + - `start --new ` creates named instances + - `--clone` copies data from the base version + - JSON registry (`instances/registry.json`) tracks all instances + - `instances` lists all registered instances; `destroy` removes them +- **ISO cache**: skip re-downloading ISOs for new instances + - `cache download ` downloads original ISOs using the container's download logic + - `cache save ` caches from data directory (skips rebuilt ISOs) + - Auto-restore on `start --new` copies cached ISOs before container starts + - `cache list` / `cache rm` / `cache flush` for cache management + - Rebuilt ISOs (genisoimage output) detected and skipped automatically +- **Auto-cache on stop**: `AUTO_CACHE=Y` in `.env` caches unprocessed ISOs when stopping +- **Snapshot & restore**: back up and restore VM data directories +- **Topic-based help system**: `help [commands|instances|cache|examples|config|all]` + - Interactive numbered menu in terminal mode + - Auto-disabled in pipes, CI, and batch environments + - Aligned columns using `_help_row()` with ANSI-safe formatting +- **ARM64 auto-detection**: blocks unsupported versions, shows `[x86 only]` tags +- **LAN IP detection** with remote access URLs shown on start +- **Port conflict detection** before starting containers +- **Disk usage monitoring** with per-VM and snapshot breakdowns +- Multi-version compose structure with organized folders (`compose/`) +- Environment file configuration (`.env` / `.env.example`) +- Two resource profiles: modern (8G RAM, 4 CPU) and legacy (2G RAM, 2 CPU) +- Per-version data folders under `data/` +- Pre-configured compose files for all Windows versions: + - Desktop: Win 11, 10, 8.1, 7 (with Enterprise variants) + - Legacy: Vista, XP, 2000 + - Server: 2003, 2008, 2012, 2016, 2019, 2022, 2025 + - Tiny: Tiny11, Tiny10 +- Unique port mappings for each version (no conflicts) +- CLAUDE.md for Claude Code guidance +- WINCTL_GUIDE.md comprehensive user guide + +### Changed +- Default storage location changed from `./windows` to `./data/` +- Compose files now use `env_file` for centralized configuration +- Restart policy changed from `always` to `on-failure` +- `clean --data` now unregisters instances and removes compose files + +### Resource Profiles + +| Profile | RAM | CPU | Disk | Used By | +|---------|-----|-----|------|---------| +| Modern | 8G | 4 | 128G | Win 10/11, Server 2016+ | +| Legacy | 2G | 2 | 32G | Win 7/8, Vista, XP, 2000, Server 2003-2012, Tiny | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6b25e8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is **dockur/windows** - a Docker container that runs Windows inside QEMU with KVM acceleration. It provides automatic Windows installation with ISO downloading, VirtIO driver injection, and unattended setup via answer files. + +## Architecture + +### Entry Point & Script Chain + +The container starts via `/run/entry.sh` which sources scripts in sequence: +1. `start.sh` → `utils.sh` → `reset.sh` → `server.sh` → `define.sh` → `mido.sh` → `install.sh` +2. Then: `disk.sh` → `display.sh` → `network.sh` → `samba.sh` → `boot.sh` → `proc.sh` → `power.sh` → `memory.sh` → `config.sh` → `finish.sh` +3. Finally launches `qemu-system-x86_64` with constructed arguments + +### Key Components + +- **src/define.sh**: Version parsing, language mapping, and Windows edition detection. Maps user-friendly version strings (e.g., "11", "10l", "2022") to internal identifiers +- **src/mido.sh**: Microsoft ISO downloader - scrapes Microsoft's download portal to get direct ISO links +- **src/install.sh**: ISO extraction, image detection, driver injection, answer file customization, and ISO rebuilding using `wimlib-imagex` and `genisoimage` +- **src/samba.sh**: Configures Samba for host-guest file sharing (appears as "Shared" folder on desktop) +- **assets/*.xml**: Unattended answer files for different Windows versions + +### Build System + +- Base image: `qemux/qemu` (QEMU with web-based VNC viewer) +- VirtIO drivers downloaded at build time from `qemus/virtiso-whql` +- Multi-arch support: amd64 native, arm64 via `dockur/windows-arm` + +### GitHub Codespaces + +The `.devcontainer/` directory provides GitHub Codespaces configurations — separate from the `compose/` files used by `winctl.sh`. + +- `devcontainer.json` (root): Default config, launches Windows 11 Pro +- Numbered subfolders (010–210): Alternative configs for each Windows version +- `codespaces.yml`: Shared compose file using `ghcr.io/dockur/windows` +- Runs a single VM at a time on ports 8006/3389 (no unique port mapping needed) +- Do not sync ports with `compose/` files — they serve different use cases + +## Commands + +### Linting & Validation + +```bash +# ShellCheck for all shell scripts +shellcheck -x --source-path=src src/*.sh + +# Dockerfile linting +hadolint Dockerfile + +# XML validation (answer files) +# Uses action-pack/valid-xml in CI +``` + +### Building + +```bash +# Build Docker image locally +docker build -t windows . + +# Build with version argument +docker build --build-arg VERSION_ARG=1.0 -t windows . +``` + +### Testing Locally + +```bash +# Run container (requires KVM) +docker run -it --rm -e "VERSION=11" -p 8006:8006 --device=/dev/kvm --device=/dev/net/tun --cap-add NET_ADMIN -v "${PWD}/storage:/storage" windows + +# Access web viewer at http://localhost:8006 +``` + +## CI/CD + +- **check.yml**: Runs on PRs - ShellCheck, Hadolint, XML/JSON/YAML validation +- **build.yml**: Manual trigger - builds multi-arch image, pushes to Docker Hub and GHCR +- **test.yml**: Runs check.yml on PRs + +ShellCheck exclusions (from CI): SC1091, SC2001, SC2002, SC2034, SC2064, SC2153, SC2317, SC2028 + +## Key Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| VERSION | "11" | Windows version (11, 10, 10l, 2022, etc.) or ISO URL | +| LANGUAGE | "en" | Installation language | +| USERNAME | "Docker" | Windows username | +| PASSWORD | "admin" | Windows password | +| DISK_SIZE | "64G" | Virtual disk size | +| RAM_SIZE | "4G" | RAM allocation | +| CPU_CORES | "2" | CPU cores | +| MANUAL | "" | Set to "Y" for manual installation | + +## Git Remotes & Pull Requests + +### Remotes + +| Remote | Repository | Purpose | +|--------|-----------|---------| +| `origin` | `michelabboud/windows` | Personal repo, push directly to `master` | +| `fork` | `michelabboud/windows-1` | Fork of `dockur/windows`, used for PRs to upstream | + +### Creating a PR to upstream + +1. Push changes to a feature branch on the fork: + ```bash + git push fork master: + ``` +2. Create the PR: + ```bash + gh pr create --repo dockur/windows --head michelabboud: --base master --title "..." --body "..." + ``` +3. Create a matching issue: + ```bash + gh issue create --repo dockur/windows --title "..." --body "..." + ``` + +### Updating an existing PR + +Push new commits to the same branch on the fork: +```bash +git push fork master: +``` +The PR updates automatically. Update the PR description if needed: +```bash +gh pr edit --repo dockur/windows --body "..." +``` + +### Updating the GitHub release + +```bash +gh release edit --notes "..." +``` + +### Active PRs + +| PR | Branch | Issue | Description | +|----|--------|-------|-------------| +| [#1637](https://github.com/dockur/windows/pull/1637) | `feat/winctl-management-script` | [#1639](https://github.com/dockur/windows/issues/1639) | winctl.sh management script with ARM64 support | +| [#1638](https://github.com/dockur/windows/pull/1638) | `fix/reviewdog-fail-level` | [#1640](https://github.com/dockur/windows/issues/1640) | Replace deprecated fail_on_error with fail_level in reviewdog actions | + +## Adding New Windows Versions + +1. Add version aliases in `src/define.sh` `parseVersion()` function +2. Create answer file in `assets/` named `{version}.xml` +3. Add driver folder mapping in `src/install.sh` `addDriver()` function diff --git a/WINCTL_GUIDE.md b/WINCTL_GUIDE.md new file mode 100644 index 0000000..fb8f596 --- /dev/null +++ b/WINCTL_GUIDE.md @@ -0,0 +1,1272 @@ +# winctl.sh User Guide + +A comprehensive guide to managing Windows Docker containers with `winctl.sh`. + +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Commands Reference](#commands-reference) +- [Snapshots & Restore](#snapshots--restore) +- [Multi-Instance Support](#multi-instance-support) +- [ISO Cache](#iso-cache) +- [Configuration](#configuration) +- [ARM64 Setup](#arm64-setup) +- [Interactive Menus](#interactive-menus) +- [Common Scenarios](#common-scenarios) +- [Troubleshooting](#troubleshooting) +- [Tips & Tricks](#tips--tricks) + +--- + +## Overview + +`winctl.sh` is a management script for running Windows virtual machines inside Docker containers. It provides: + +- **22 Windows versions** from Windows 2000 to Windows 11 +- **Simple commands** to start, stop, and manage containers +- **Interactive menus** when you don't specify a version +- **Snapshot & restore** for backing up and restoring VM data +- **LAN IP detection** with remote access URLs shown automatically +- **Port conflict detection** before starting containers +- **Disk usage monitoring** with per-VM and snapshot breakdowns +- **Status caching** for fast performance +- **Resource profiles** optimized for modern and legacy systems +- **ARM64 auto-detection** with architecture-aware image selection and version filtering + +### Supported Windows Versions + +| Category | Versions | +|----------|----------| +| **Desktop** | win11, win11e, win11l, win10, win10e, win10l, win81, win81e, win7, win7e | +| **Legacy** | vista, winxp, win2k | +| **Server** | win2025, win2022, win2019, win2016, win2012, win2008, win2003 | +| **Tiny** | tiny11, tiny10 | + +### ARM64 Support + +The script auto-detects your CPU architecture. On ARM64 systems (e.g., Apple Silicon, Ampere), only the following versions are supported: + +| Version | Name | +|---------|------| +| win11 | Windows 11 Pro | +| win11e | Windows 11 Enterprise | +| win11l | Windows 11 LTSC | +| win10 | Windows 10 Pro | +| win10e | Windows 10 Enterprise | +| win10l | Windows 10 LTSC | + +To run on ARM64, set the Docker image in your `.env.modern` file: + +```bash +WINDOWS_IMAGE=dockurr/windows-arm +``` + +The `winctl.sh list` command shows `[x86 only]` tags on ARM64 for unsupported versions, and `winctl.sh start` blocks unsupported versions with a clear error message. + +### Port Mappings + +Each version has unique ports to avoid conflicts: + +| Version | Web UI | RDP | Version | Web UI | RDP | +|---------|--------|-----|---------|--------|-----| +| win11 | 8011 | 3311 | win2025 | 8025 | 3325 | +| win10 | 8010 | 3310 | win2022 | 8022 | 3322 | +| win81 | 8008 | 3308 | win2019 | 8019 | 3319 | +| win7 | 8007 | 3307 | win2016 | 8016 | 3316 | +| vista | 8006 | 3306 | win2012 | 8112 | 3212 | +| winxp | 8005 | 3305 | win2008 | 8108 | 3208 | +| win2k | 8000 | 3300 | win2003 | 8003 | 3303 | +| tiny11 | 8111 | 3111 | tiny10 | 8110 | 3110 | + +--- + +## Prerequisites + +### Required + +1. **Docker** - Container runtime +2. **Docker Compose** - Container orchestration (plugin or standalone) +3. **KVM** - Hardware virtualization for near-native performance + +### Check Prerequisites + +Run the built-in check: + +```bash +./winctl.sh check +``` + +Example output: +``` +Prerequisites Check +──────────────────────────────────────────────────────────── +[OK] Docker is available +[OK] Docker Compose plugin is available +[OK] KVM is available +[OK] TUN device is available +[OK] Memory OK: 16GB available (8GB needed) +[OK] Disk space OK: 500GB available (128GB needed) + +[OK] All critical prerequisites passed! + Architecture: amd64 + LAN IP: 192.168.1.100 +``` + +On ARM64, the output also shows: +``` + Architecture: arm64 + ARM64 image: dockurr/windows-arm + Supported: win11 win11e win11l win10 win10e win10l + LAN IP: 192.168.1.100 +``` + +### Fix Common Issues + +**KVM not accessible:** +```bash +sudo usermod -aG kvm $USER +newgrp kvm # or log out and back in +``` + +**Docker not running:** +```bash +sudo systemctl start docker +``` + +--- + +## Quick Start + +### 1. Start a Windows VM + +```bash +# Start Windows 11 +./winctl.sh start win11 + +# Or use interactive menu +./winctl.sh start +``` + +### 2. Access the VM + +After starting, you'll see connection details: + +``` +Connection Details: + → Web Viewer: http://localhost:8011 + → RDP: localhost:3311 + → LAN Web: http://192.168.1.100:8011 + → LAN RDP: 192.168.1.100:3311 +``` + +- **Web Viewer**: Open in browser for quick access +- **RDP**: Use any RDP client for better performance +- **LAN URLs**: Shown automatically when a LAN IP is detected — use these to access from other devices on your network + +### 3. Check Status + +```bash +./winctl.sh status +``` + +### 4. Stop the VM + +```bash +./winctl.sh stop win11 +``` + +--- + +## Commands Reference + +### start + +Start one or more containers. + +```bash +# Start single version +./winctl.sh start win11 + +# Start multiple versions +./winctl.sh start win11 win10 winxp + +# Interactive menu (no version specified) +./winctl.sh start +``` + +**What it does:** +1. Checks prerequisites (Docker, KVM) +2. Detects architecture and blocks unsupported versions on ARM64 +3. Verifies ports are not already in use +4. Creates data directory if missing +5. Checks available resources +6. Starts the container +7. Shows connection details (including LAN URLs) + +--- + +### stop + +Stop containers with a 2-minute grace period for clean shutdown. + +```bash +# Stop single version +./winctl.sh stop win11 + +# Stop multiple versions +./winctl.sh stop win11 win10 + +# Stop all running containers +./winctl.sh stop all + +# Interactive menu +./winctl.sh stop +``` + +**Note:** You'll be asked to confirm before stopping. + +--- + +### restart + +Restart containers. + +```bash +./winctl.sh restart win11 +``` + +--- + +### status + +Show status of containers. + +```bash +# All containers +./winctl.sh status + +# Specific versions +./winctl.sh status win11 win10 +``` + +Example output: +``` + VERSION NAME STATUS WEB RDP + ────────────────────────────────────────────────────────────────── + win11 Windows 11 Pro running 8011 3311 + win10 Windows 10 Pro stopped 8010 3310 + winxp Windows XP Professional not created 8005 3305 + + LAN IP: 192.168.1.100 — use http://192.168.1.100: for remote access +``` + +--- + +### logs + +View container logs. + +```bash +# View logs +./winctl.sh logs win11 + +# Follow logs in real-time +./winctl.sh logs win11 -f +``` + +Press `Ctrl+C` to stop following logs. + +--- + +### shell + +Open an interactive bash shell inside the container. + +```bash +./winctl.sh shell win11 +``` + +Useful for debugging or accessing container internals. + +--- + +### stats + +Show real-time resource usage (CPU, memory, network). + +```bash +# All running containers +./winctl.sh stats + +# Specific containers +./winctl.sh stats win11 win10 +``` + +Press `Ctrl+C` to exit. + +--- + +### build + +Build the Docker image locally from source. + +```bash +./winctl.sh build +``` + +--- + +### rebuild + +Destroy and recreate containers. Data in `/storage` is preserved. + +```bash +./winctl.sh rebuild win11 +``` + +**Warning:** You must type `yes` to confirm (destructive operation). + +--- + +### list + +List available Windows versions. + +```bash +# All versions +./winctl.sh list + +# By category +./winctl.sh list desktop +./winctl.sh list legacy +./winctl.sh list server +./winctl.sh list tiny +``` + +Example output: +``` +Available Windows Versions +──────────────────────────────────────────────────────────── + + DESKTOP + ────────────────────────────────────────────────── + win11 Windows 11 Pro (8G RAM) + win10 Windows 10 Pro (8G RAM) [running] + win7 Windows 7 Ultimate (2G RAM) +``` + +On ARM64, unsupported versions show an `[x86 only]` tag: +``` + win7 Windows 7 Ultimate (2G RAM) [x86 only] +``` + +--- + +### inspect + +Show detailed information about a version. + +```bash +./winctl.sh inspect win11 +``` + +Example output: +``` +Container Details: win11 +──────────────────────────────────────────────────────────── + + Version: win11 + Name: Windows 11 Pro + Category: desktop + Status: running + Web Port: 8011 + RDP Port: 3311 + Resources: modern + Compose: compose/desktop/win11.yml + Web URL: http://localhost:8011 + RDP: localhost:3311 + LAN Web: http://192.168.1.100:8011 + LAN RDP: 192.168.1.100:3311 +``` + +--- + +### monitor + +Real-time dashboard showing all containers. + +```bash +# Default 5-second refresh +./winctl.sh monitor + +# Custom refresh interval (10 seconds) +./winctl.sh monitor 10 +``` + +Press `Ctrl+C` to exit. + +--- + +### check + +Run prerequisites check. + +```bash +./winctl.sh check +``` + +--- + +### refresh + +Force refresh the status cache. + +```bash +./winctl.sh refresh +``` + +The cache is stored at `~/.cache/winctl/status.json` and auto-refreshes when: +- Cache is older than 7 days +- Cached data becomes stale +- After start/stop/restart/rebuild operations + +--- + +### open + +Open the web viewer in your default browser. + +```bash +./winctl.sh open win11 +``` + +If the container is not running, you'll be prompted to start it first. + +--- + +### pull + +Pull the latest Docker image. + +```bash +./winctl.sh pull +``` + +Automatically selects `dockurr/windows` or `dockurr/windows-arm` based on detected architecture. Shows whether the image was updated or already up to date. + +--- + +### disk + +Show disk usage per VM data directory. + +```bash +# All VMs +./winctl.sh disk + +# Specific versions +./winctl.sh disk win11 win10 +``` + +Example output: +``` +Disk Usage +──────────────────────────────────────────────────────────── + + VERSION SIZE STATUS + ──────────────────────────────────── + win11 45.2G running + win10 32.1G stopped + ──────────────────────────────────── + Total: 77.3G + + Snapshots: 12.5G (2 snapshots) + win11 12.5G (2 snapshots) +``` + +--- + +### snapshot + +Back up a VM's data directory. + +```bash +# Auto-named with timestamp +./winctl.sh snapshot win11 + +# Custom name +./winctl.sh snapshot win11 before-update +``` + +The snapshot is saved to `snapshots///`. The container is stopped during the copy and restarted automatically. + +--- + +### restore + +Restore a VM's data directory from a snapshot. + +```bash +# Interactive snapshot selection +./winctl.sh restore win11 + +# Restore specific snapshot +./winctl.sh restore win11 before-update +``` + +If no snapshot name is given, a list of available snapshots is shown for selection. Requires typing `yes` to confirm (destructive: replaces current data). + +--- + +### clean + +Remove stopped containers and optionally purge their data directories. + +```bash +# Remove stopped containers only +./winctl.sh clean + +# Also delete data directories for stopped containers +./winctl.sh clean --data +``` + +Requires typing `yes` to confirm. Shows freed disk space on completion. Stopped instances are automatically unregistered and their compose files removed. + +--- + +## Snapshots & Restore + +`winctl.sh` supports snapshot and restore for VM data directories, stored under `snapshots/`. + +### Creating a Snapshot + +```bash +# Snapshot with auto-generated timestamp name +./winctl.sh snapshot win11 + +# Snapshot with custom name +./winctl.sh snapshot win11 before-update +``` + +The container is stopped during the copy to ensure data consistency, then restarted automatically. + +### Listing Snapshots + +```bash +# Via disk command +./winctl.sh disk + +# Or browse directly +ls snapshots/win11/ +``` + +### Restoring a Snapshot + +```bash +# Interactive selection +./winctl.sh restore win11 + +# Direct restore +./winctl.sh restore win11 before-update +``` + +**Warning:** Restore replaces all current data for the version. The container is stopped during restore and restarted automatically. + +### Snapshot Directory Structure + +``` +snapshots/ +├── win11/ +│ ├── 20260129-143022/ # Auto-named +│ └── before-update/ # Custom-named +└── win10/ + └── 20260128-091500/ +``` + +--- + +## Multi-Instance Support + +Run multiple instances of the same Windows version with auto-managed ports and a JSON registry. + +### Creating an Instance + +```bash +# Create winxp-1 with auto-allocated ports +./winctl.sh start winxp --new + +# Create winxp-lab with a custom name +./winctl.sh start winxp --new lab + +# Create winxp-lab and clone data from base winxp +./winctl.sh start winxp --new lab --clone +``` + +The `--new` flag: +1. Allocates unique ports (web: 9000+, RDP: 4000+) +2. Generates a compose file in `instances/.yml` +3. Creates a data directory at `data//` +4. Registers the instance in `instances/registry.json` +5. Starts the container + +### Managing Instances + +Instances work transparently with all existing commands: + +```bash +# Stop an instance +./winctl.sh stop winxp-lab + +# Restart an instance +./winctl.sh restart winxp-lab + +# View logs +./winctl.sh logs winxp-lab -f + +# Open shell +./winctl.sh shell winxp-lab + +# Inspect details +./winctl.sh inspect winxp-lab + +# Open web viewer +./winctl.sh open winxp-lab + +# Snapshot and restore +./winctl.sh snapshot winxp-lab before-update +./winctl.sh restore winxp-lab before-update +``` + +### Listing Instances + +```bash +# List all instances +./winctl.sh instances + +# Filter by base version +./winctl.sh instances winxp +``` + +Example output: +``` +Instances +──────────────────────────────────────────────────────────── + + INSTANCE BASE STATUS WEB RDP CREATED + ────────────────────────────────────────────────────────────────────────────── + winxp-1 winxp running 9000 4000 2026-01-30 + winxp-lab winxp stopped 9001 4001 2026-01-30 +``` + +### Destroying an Instance + +```bash +./winctl.sh destroy winxp-lab +``` + +This will: +1. Stop and remove the container +2. Delete the compose file +3. Prompt to delete the data directory +4. Remove the instance from the registry + +### How It Works + +- **Port allocation**: Web ports start at 9000, RDP at 4000, auto-incrementing to avoid conflicts +- **Naming**: Instances are named `-` (e.g., `winxp-1`, `winxp-lab`) +- **Registry**: All instances are tracked in `instances/registry.json` +- **Compose files**: Generated in `instances/.yml` with relative paths to env files and data +- **No collisions**: Base versions never contain hyphens; instances always do + +### Instance Directory Structure + +``` +instances/ +├── registry.json # Instance registry +├── winxp-1.yml # Generated compose file +└── winxp-lab.yml # Generated compose file + +data/ +├── winxp/ # Base version data +├── winxp-1/ # Instance data +└── winxp-lab/ # Instance data (cloned from base) +``` + +--- + +## ISO Cache + +Windows ISOs are large (3-6 GB) and re-downloaded every time a new container is created for the same version. The ISO cache saves downloaded ISOs so new instances can skip the download. + +### How It Works + +1. Download the original ISO: `./winctl.sh cache download winxp` +2. Create new instances — cached ISOs are auto-restored: `./winctl.sh start winxp --new` + +When creating a new instance with `--new`, winctl checks `cache//` for ISOs and copies them into the new instance's data directory before starting the container. The container finds the ISO locally and processes it (extracts, injects drivers, builds answer file) without needing to re-download. + +### Downloading an ISO + +```bash +# Download original ISOs to the cache +./winctl.sh cache download winxp +./winctl.sh cache download win11 +``` + +This uses the container's download logic to fetch the original, unprocessed ISO directly to the cache. This is the recommended way to populate the cache. + +### Saving from an Existing VM + +```bash +# Cache unprocessed ISOs from a VM's data directory +./winctl.sh cache save winxp +``` + +> **Note:** After a VM completes its first boot, the container rebuilds the ISO with injected drivers. These rebuilt ISOs are automatically skipped by `cache save` because they cannot be re-processed. Use `cache download` instead. + +### Listing Cached ISOs + +```bash +./winctl.sh cache list +``` + +Shows all cached ISOs grouped by version with file sizes and a total. + +### Removing Cached ISOs + +```bash +# Remove cached ISOs for a specific version +./winctl.sh cache rm winxp + +# Remove all cached ISOs +./winctl.sh cache flush +``` + +Both commands require typing `yes` to confirm. + +### Auto-Restore + +When creating a new instance with `--new` (without `--clone`), winctl automatically checks the cache: + +```bash +# If cache/winxp/ has an ISO, it is copied to data/winxp-1/ before start +./winctl.sh start winxp --new +``` + +The original ISO is copied as-is. The container processes it locally (extract, inject drivers, build answer file) without needing to re-download. This is skipped when using `--clone`, since cloning copies all data from the base version including any ISOs. + +### Auto-Cache on Stop + +To automatically cache ISOs whenever a container is stopped, add `AUTO_CACHE=Y` to your `.env` file: + +```bash +# .env +AUTO_CACHE=Y +``` + +When enabled, `winctl.sh stop` will silently cache any unprocessed ISOs found in the stopped container's data directory. Rebuilt ISOs (from the container's install pipeline) and already-cached ISOs are skipped. + +### Cache Directory Structure + +``` +cache/ +├── winxp/ +│ └── winxpx86.iso +├── win11/ +│ └── win11x64.iso +└── win10/ + └── win10x64.iso +``` + +> **Note:** The cache must contain original (unprocessed) ISOs. Use `cache download` to populate it. Rebuilt ISOs from `data/` directories are skipped by `cache save` because the container cannot re-extract them. + +--- + +## Configuration + +### Environment Files + +Two pre-configured environment files control VM resources: + +| File | RAM | CPU | Disk | Used By | +|------|-----|-----|------|---------| +| `.env.modern` | 8G | 4 | 128G | Win 10/11, Server 2016+ | +| `.env.legacy` | 2G | 2 | 32G | Win 7/8, Vista, XP, 2000, Server 2003-2012, Tiny | + +### Customizing Resources + +Edit `.env.modern` or `.env.legacy`: + +```bash +# Resources +RAM_SIZE=8G +CPU_CORES=4 +DISK_SIZE=128G + +# Credentials +USERNAME=docker +PASSWORD=admin + +# Display +WIDTH=1280 +HEIGHT=720 + +# Other +LANGUAGE=en +REGION=en-US +KEYBOARD=en-US +DHCP=N +SAMBA=Y +RESTART_POLICY=on-failure +DEBUG=N +``` + +### Available Settings + +| Setting | Description | Default | +|---------|-------------|---------| +| `RAM_SIZE` | Memory allocation | 8G/2G | +| `CPU_CORES` | CPU cores | 4/2 | +| `DISK_SIZE` | Virtual disk size | 128G/32G | +| `USERNAME` | Windows username | docker | +| `PASSWORD` | Windows password | admin | +| `LANGUAGE` | Installation language | en | +| `REGION` | Region setting | en-US | +| `KEYBOARD` | Keyboard layout | en-US | +| `WIDTH` | Display width | 1280 | +| `HEIGHT` | Display height | 720 | +| `DHCP` | Use DHCP networking | N | +| `SAMBA` | Enable file sharing | Y | +| `RESTART_POLICY` | Container restart policy | on-failure | +| `DEBUG` | Debug mode | N | +| `WINDOWS_IMAGE` | Docker image | dockurr/windows | +| `AUTO_CACHE` | Auto-cache ISOs on stop (in `.env`) | N | + +### Restart Policy Options + +| Value | Description | +|-------|-------------| +| `no` | Never restart automatically | +| `on-failure` | Restart only if container exits with error (default) | +| `always` | Always restart regardless of exit status | +| `unless-stopped` | Always restart unless manually stopped | + +**Note:** With `on-failure` (default), shutting down Windows from inside will stop the container. With `unless-stopped` or `always`, the container will restart after Windows shutdown. + +--- + +## ARM64 Setup + +If you're running on an ARM64 system (e.g., Apple Silicon Mac, Ampere server), follow these steps: + +### 1. Set the Docker image + +Edit `.env.modern` (and `.env.legacy` if needed): + +```bash +WINDOWS_IMAGE=dockurr/windows-arm +``` + +### 2. Check your setup + +```bash +./winctl.sh check +``` + +Verify the output shows `Architecture: arm64` and lists supported versions. + +### 3. Start a supported version + +Only Windows 10 and 11 variants work on ARM64: + +```bash +./winctl.sh start win11 # Works +./winctl.sh start win10 # Works +./winctl.sh start winxp # Blocked with error +``` + +### 4. View compatible versions + +```bash +./winctl.sh list +``` + +Unsupported versions are tagged `[x86 only]` on ARM64 systems. + +### Supported ARM64 Versions + +| Version | Name | +|---------|------| +| win11 | Windows 11 Pro | +| win11e | Windows 11 Enterprise | +| win11l | Windows 11 LTSC | +| win10 | Windows 10 Pro | +| win10e | Windows 10 Enterprise | +| win10l | Windows 10 LTSC | + +All other versions (Win 8.1, 7, Vista, XP, 2000, all Server editions, Tiny) are x86 only. + +--- + +## Interactive Menus + +When you don't specify a version, `winctl.sh` shows interactive menus. + +### Category Selection + +``` +Select Category +──────────────────────────────────────────────────────────── + + 1) Desktop (Win 11, 10, 8.1, 7) + 2) Legacy (Vista, XP, 2000) + 3) Server (2025, 2022, 2019, 2016, 2012, 2008, 2003) + 4) Tiny (Tiny11, Tiny10) + 5) All versions + 6) Select individual versions + + Select [1-6]: +``` + +### Version Selection + +``` +Select Version(s) +──────────────────────────────────────────────────────────── + + 1) win11 Windows 11 Pro [running] + 2) win11e Windows 11 Enterprise + 3) win11l Windows 11 LTSC + 4) win10 Windows 10 Pro [stopped] + + a) Select all + q) Cancel + + Select (numbers separated by spaces, or 'a' for all): +``` + +- Enter numbers separated by spaces: `1 3 4` +- Enter `a` for all versions +- Enter `q` to cancel + +--- + +## Common Scenarios + +### Scenario 1: Set Up a Development Environment + +```bash +# Start Windows 10 for development +./winctl.sh start win10 + +# Access via web browser +# Open http://localhost:8010 + +# Or connect via RDP for better performance +# Use RDP client to connect to localhost:3310 +``` + +### Scenario 2: Test Software on Multiple Windows Versions + +```bash +# Start multiple versions +./winctl.sh start win11 win10 win7 + +# Check they're all running +./winctl.sh status + +# Access each via their ports: +# - Win11: http://localhost:8011 +# - Win10: http://localhost:8010 +# - Win7: http://localhost:8007 + +# Stop all when done +./winctl.sh stop win11 win10 win7 +``` + +### Scenario 3: Run Legacy Software on Windows XP + +```bash +# Start Windows XP +./winctl.sh start winxp + +# Access via http://localhost:8005 +# Login: docker / admin + +# Transfer files via the Shared folder on desktop +``` + +### Scenario 4: Monitor Resource Usage + +```bash +# See real-time stats for all running VMs +./winctl.sh stats + +# Or use the dashboard +./winctl.sh monitor +``` + +### Scenario 5: Increase Resources for a VM + +1. Stop the container: + ```bash + ./winctl.sh stop win11 + ``` + +2. Edit `.env.modern`: + ```bash + RAM_SIZE=16G + CPU_CORES=8 + ``` + +3. Start again: + ```bash + ./winctl.sh start win11 + ``` + +### Scenario 6: Running on ARM64 + +```bash +# Set the ARM64 image (one-time setup) +# Edit .env.modern and set: WINDOWS_IMAGE=dockurr/windows-arm + +# Check architecture is detected +./winctl.sh check + +# List versions to see what's available +./winctl.sh list + +# Start a supported version +./winctl.sh start win11 +``` + +### Scenario 7: Fresh Start (Reset VM) + +```bash +# This destroys the container but keeps data +./winctl.sh rebuild win11 + +# For a complete reset, also delete the data: +rm -rf data/win11/* +./winctl.sh start win11 +``` + +### Scenario 8: Snapshot Before a Risky Change + +```bash +# Create a snapshot before installing something +./winctl.sh snapshot win11 before-update + +# Do your work... +# If something goes wrong, restore: +./winctl.sh restore win11 before-update +``` + +### Scenario 9: Clean Up Disk Space + +```bash +# Check disk usage +./winctl.sh disk + +# Remove stopped containers +./winctl.sh clean + +# Remove stopped containers AND their data +./winctl.sh clean --data +``` + +### Scenario 10: Quick Access from Browser + +```bash +# Open web viewer directly in your browser +./winctl.sh open win11 + +# Or pull latest image before starting +./winctl.sh pull +./winctl.sh start win11 +``` + +### Scenario 11: Access from Another Device on LAN + +```bash +# Check your LAN IP +./winctl.sh check + +# Start a VM — LAN URLs are shown automatically +./winctl.sh start win11 +# → LAN Web: http://192.168.1.100:8011 +# → LAN RDP: 192.168.1.100:3311 + +# Use the LAN URL from any device on the same network +``` + +--- + +## Troubleshooting + +### Container Won't Start + +**Check prerequisites:** +```bash +./winctl.sh check +``` + +**Check logs:** +```bash +./winctl.sh logs win11 +``` + +**Common issues:** +- KVM not accessible → Add user to kvm group +- Port already in use → `start` auto-detects port conflicts; stop the conflicting service or container +- Not enough disk space → Run `./winctl.sh disk` to check usage, or free up space + +### Slow Performance + +- Ensure KVM is working (hardware virtualization) +- Increase RAM_SIZE and CPU_CORES in env file +- Use RDP instead of web viewer for better performance + +### Can't Connect via RDP + +1. Wait for Windows to fully boot (check web viewer first) +2. RDP might be disabled in Windows → Enable via web viewer +3. Check firewall settings in Windows + +### Web Viewer Not Loading + +```bash +# Check if container is running +./winctl.sh status win11 + +# Check container logs +./winctl.sh logs win11 + +# Restart the container +./winctl.sh restart win11 +``` + +### Cache Issues + +Force refresh the status cache: +```bash +./winctl.sh refresh +``` + +--- + +## Tips & Tricks + +### 1. Use Aliases + +Add to your `~/.bashrc`: +```bash +alias wctl='./winctl.sh' +alias wstart='./winctl.sh start' +alias wstop='./winctl.sh stop' +alias wstatus='./winctl.sh status' +``` + +### 2. Quick Access Bookmarks + +Bookmark your commonly used VMs: +- Windows 11: http://localhost:8011 +- Windows 10: http://localhost:8010 + +### 3. File Sharing + +Each VM has a "Shared" folder on the desktop that maps to the host. Use this to transfer files. + +### 4. Snapshots + +Use the built-in snapshot and restore commands: +```bash +./winctl.sh snapshot win11 my-backup +./winctl.sh restore win11 my-backup +``` + +Snapshots are stored in `snapshots///`. + +### 5. Running Multiple VMs + +Check your available resources before starting multiple VMs: +```bash +# Each modern VM needs 8GB RAM +# Each legacy VM needs 2GB RAM + +# Example: Running win11 + win10 + winxp = 8+8+2 = 18GB RAM needed +``` + +### 6. Headless Operation + +For servers, you can start VMs and access only via RDP: +```bash +./winctl.sh start win2022 +# Connect via RDP to localhost:3322 +``` + +--- + +## File Structure + +``` +. +├── winctl.sh # Management script +├── .env.modern # Modern systems config (8G RAM) +├── .env.legacy # Legacy systems config (2G RAM) +├── compose/ +│ ├── desktop/ # Win 11, 10, 8.1, 7 +│ ├── legacy/ # Vista, XP, 2000 +│ ├── server/ # Server 2003-2025 +│ └── tiny/ # Tiny10, Tiny11 +├── instances/ +│ ├── registry.json # Instance registry +│ ├── winxp-1.yml # Generated compose files +│ └── winxp-lab.yml +├── data/ +│ ├── win11/ # Win11 VM storage +│ ├── win10/ # Win10 VM storage +│ ├── winxp-1/ # Instance VM storage +│ ├── winxp-lab/ # Instance VM storage +│ └── ... # Other VM storage +├── snapshots/ +│ ├── win11/ # Win11 snapshots +│ │ ├── 20260129-143022/ +│ │ └── before-update/ +│ └── ... # Other version snapshots +├── cache/ +│ ├── winxp/ # Cached winxp ISOs +│ ├── win11/ # Cached win11 ISOs +│ └── ... # Other cached ISOs +└── ~/.cache/winctl/ + └── status.json # Status cache +``` + +--- + +## Getting Help + +```bash +# Show commands + interactive topic menu +./winctl.sh help + +# Jump to a specific topic +./winctl.sh help commands # Full command reference +./winctl.sh help instances # Multi-instance support +./winctl.sh help cache # ISO cache management +./winctl.sh help examples # Usage examples +./winctl.sh help config # Environment settings +./winctl.sh help all # Show everything + +# Check system requirements +./winctl.sh check + +# List all versions +./winctl.sh list +``` + +When run interactively, `help` shows a numbered menu to browse topics. When piped or run in a script, it prints the command summary only. + +For issues, visit: https://github.com/dockur/windows/issues diff --git a/compose.yml b/compose.yml index e5b6257..c7ab26e 100644 --- a/compose.yml +++ b/compose.yml @@ -1,9 +1,17 @@ +# Default compose file - Windows 11 Pro +# For more versions, see compose/ folder +# Configure settings in .env file + services: windows: image: dockurr/windows container_name: windows + env_file: .env environment: VERSION: "11" + RAM_SIZE: ${MODERN_RAM_SIZE:-8G} + CPU_CORES: ${MODERN_CPU_CORES:-4} + DISK_SIZE: ${MODERN_DISK_SIZE:-128G} devices: - /dev/kvm - /dev/net/tun @@ -14,6 +22,6 @@ services: - 3389:3389/tcp - 3389:3389/udp volumes: - - ./windows:/storage - restart: always + - ./data/win11:/storage + restart: unless-stopped stop_grace_period: 2m diff --git a/compose/all.yml b/compose/all.yml new file mode 100644 index 0000000..e3b68dc --- /dev/null +++ b/compose/all.yml @@ -0,0 +1,6 @@ +--- +include: + - desktop.yml + - legacy.yml + - server.yml + - tiny.yml diff --git a/compose/desktop.yml b/compose/desktop.yml new file mode 100644 index 0000000..69ec6d8 --- /dev/null +++ b/compose/desktop.yml @@ -0,0 +1,6 @@ +--- +include: + - desktop/win11.yml + - desktop/win10.yml + - desktop/win8.yml + - desktop/win7.yml diff --git a/compose/desktop/win10.yml b/compose/desktop/win10.yml new file mode 100644 index 0000000..6dd374c --- /dev/null +++ b/compose/desktop/win10.yml @@ -0,0 +1,61 @@ +--- +services: + win10: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win10 + env_file: ../../.env.modern + environment: + VERSION: "10" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8010:8006 + - 3310:3389/tcp + - 3310:3389/udp + volumes: + - ../../data/win10:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m + + win10e: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win10e + env_file: ../../.env.modern + environment: + VERSION: "10e" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8014:8006 + - 3314:3389/tcp + - 3314:3389/udp + volumes: + - ../../data/win10e:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m + + win10l: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win10l + env_file: ../../.env.modern + environment: + VERSION: "10l" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8015:8006 + - 3315:3389/tcp + - 3315:3389/udp + volumes: + - ../../data/win10l:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/desktop/win11.yml b/compose/desktop/win11.yml new file mode 100644 index 0000000..96944a0 --- /dev/null +++ b/compose/desktop/win11.yml @@ -0,0 +1,61 @@ +--- +services: + win11: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win11 + env_file: ../../.env.modern + environment: + VERSION: "11" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8011:8006 + - 3311:3389/tcp + - 3311:3389/udp + volumes: + - ../../data/win11:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m + + win11e: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win11e + env_file: ../../.env.modern + environment: + VERSION: "11e" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8012:8006 + - 3312:3389/tcp + - 3312:3389/udp + volumes: + - ../../data/win11e:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m + + win11l: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win11l + env_file: ../../.env.modern + environment: + VERSION: "11l" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8013:8006 + - 3313:3389/tcp + - 3313:3389/udp + volumes: + - ../../data/win11l:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/desktop/win7.yml b/compose/desktop/win7.yml new file mode 100644 index 0000000..e53277f --- /dev/null +++ b/compose/desktop/win7.yml @@ -0,0 +1,41 @@ +--- +services: + win7: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win7 + env_file: ../../.env.legacy + environment: + VERSION: "7u" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8007:8006 + - 3307:3389/tcp + - 3307:3389/udp + volumes: + - ../../data/win7:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m + + win7e: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win7e + env_file: ../../.env.legacy + environment: + VERSION: "7e" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8071:8006 + - 3371:3389/tcp + - 3371:3389/udp + volumes: + - ../../data/win7e:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/desktop/win8.yml b/compose/desktop/win8.yml new file mode 100644 index 0000000..a24a79b --- /dev/null +++ b/compose/desktop/win8.yml @@ -0,0 +1,41 @@ +--- +services: + win81: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win81 + env_file: ../../.env.legacy + environment: + VERSION: "8" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8008:8006 + - 3308:3389/tcp + - 3308:3389/udp + volumes: + - ../../data/win81:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m + + win81e: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win81e + env_file: ../../.env.legacy + environment: + VERSION: "8e" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8081:8006 + - 3381:3389/tcp + - 3381:3389/udp + volumes: + - ../../data/win81e:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/legacy.yml b/compose/legacy.yml new file mode 100644 index 0000000..1df181e --- /dev/null +++ b/compose/legacy.yml @@ -0,0 +1,5 @@ +--- +include: + - legacy/vista.yml + - legacy/winxp.yml + - legacy/win2k.yml diff --git a/compose/legacy/vista.yml b/compose/legacy/vista.yml new file mode 100644 index 0000000..5c148d6 --- /dev/null +++ b/compose/legacy/vista.yml @@ -0,0 +1,21 @@ +--- +services: + vista: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: vista + env_file: ../../.env.legacy + environment: + VERSION: "vu" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8006:8006 + - 3306:3389/tcp + - 3306:3389/udp + volumes: + - ../../data/vista:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/legacy/win2k.yml b/compose/legacy/win2k.yml new file mode 100644 index 0000000..154b927 --- /dev/null +++ b/compose/legacy/win2k.yml @@ -0,0 +1,21 @@ +--- +services: + win2k: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2k + env_file: ../../.env.legacy + environment: + VERSION: "2k" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8000:8006 + - 3300:3389/tcp + - 3300:3389/udp + volumes: + - ../../data/win2k:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/legacy/winxp.yml b/compose/legacy/winxp.yml new file mode 100644 index 0000000..00db6df --- /dev/null +++ b/compose/legacy/winxp.yml @@ -0,0 +1,21 @@ +--- +services: + winxp: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: winxp + env_file: ../../.env.legacy + environment: + VERSION: "xp" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8005:8006 + - 3305:3389/tcp + - 3305:3389/udp + volumes: + - ../../data/winxp:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/server.yml b/compose/server.yml new file mode 100644 index 0000000..4cf21c4 --- /dev/null +++ b/compose/server.yml @@ -0,0 +1,9 @@ +--- +include: + - server/win2025.yml + - server/win2022.yml + - server/win2019.yml + - server/win2016.yml + - server/win2012.yml + - server/win2008.yml + - server/win2003.yml diff --git a/compose/server/win2003.yml b/compose/server/win2003.yml new file mode 100644 index 0000000..ac9ac14 --- /dev/null +++ b/compose/server/win2003.yml @@ -0,0 +1,21 @@ +--- +services: + win2003: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2003 + env_file: ../../.env.legacy + environment: + VERSION: "2003" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8003:8006 + - 3303:3389/tcp + - 3303:3389/udp + volumes: + - ../../data/win2003:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/server/win2008.yml b/compose/server/win2008.yml new file mode 100644 index 0000000..3104c0d --- /dev/null +++ b/compose/server/win2008.yml @@ -0,0 +1,21 @@ +--- +services: + win2008: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2008 + env_file: ../../.env.legacy + environment: + VERSION: "2008" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8108:8006 + - 3208:3389/tcp + - 3208:3389/udp + volumes: + - ../../data/win2008:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/server/win2012.yml b/compose/server/win2012.yml new file mode 100644 index 0000000..7adc7d1 --- /dev/null +++ b/compose/server/win2012.yml @@ -0,0 +1,21 @@ +--- +services: + win2012: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2012 + env_file: ../../.env.legacy + environment: + VERSION: "2012" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8112:8006 + - 3212:3389/tcp + - 3212:3389/udp + volumes: + - ../../data/win2012:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/server/win2016.yml b/compose/server/win2016.yml new file mode 100644 index 0000000..6d899be --- /dev/null +++ b/compose/server/win2016.yml @@ -0,0 +1,21 @@ +--- +services: + win2016: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2016 + env_file: ../../.env.modern + environment: + VERSION: "2016" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8016:8006 + - 3316:3389/tcp + - 3316:3389/udp + volumes: + - ../../data/win2016:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/server/win2019.yml b/compose/server/win2019.yml new file mode 100644 index 0000000..805e1f2 --- /dev/null +++ b/compose/server/win2019.yml @@ -0,0 +1,21 @@ +--- +services: + win2019: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2019 + env_file: ../../.env.modern + environment: + VERSION: "2019" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8019:8006 + - 3319:3389/tcp + - 3319:3389/udp + volumes: + - ../../data/win2019:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/server/win2022.yml b/compose/server/win2022.yml new file mode 100644 index 0000000..79b21cc --- /dev/null +++ b/compose/server/win2022.yml @@ -0,0 +1,21 @@ +--- +services: + win2022: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2022 + env_file: ../../.env.modern + environment: + VERSION: "2022" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8022:8006 + - 3322:3389/tcp + - 3322:3389/udp + volumes: + - ../../data/win2022:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/server/win2025.yml b/compose/server/win2025.yml new file mode 100644 index 0000000..cbd8a25 --- /dev/null +++ b/compose/server/win2025.yml @@ -0,0 +1,21 @@ +--- +services: + win2025: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: win2025 + env_file: ../../.env.modern + environment: + VERSION: "2025" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8025:8006 + - 3325:3389/tcp + - 3325:3389/udp + volumes: + - ../../data/win2025:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/tiny.yml b/compose/tiny.yml new file mode 100644 index 0000000..c7930ce --- /dev/null +++ b/compose/tiny.yml @@ -0,0 +1,4 @@ +--- +include: + - tiny/tiny11.yml + - tiny/tiny10.yml diff --git a/compose/tiny/tiny10.yml b/compose/tiny/tiny10.yml new file mode 100644 index 0000000..38773ec --- /dev/null +++ b/compose/tiny/tiny10.yml @@ -0,0 +1,21 @@ +--- +services: + tiny10: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: tiny10 + env_file: ../../.env.legacy + environment: + VERSION: "tiny10" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8110:8006 + - 3110:3389/tcp + - 3110:3389/udp + volumes: + - ../../data/tiny10:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/compose/tiny/tiny11.yml b/compose/tiny/tiny11.yml new file mode 100644 index 0000000..8d3f59a --- /dev/null +++ b/compose/tiny/tiny11.yml @@ -0,0 +1,21 @@ +--- +services: + tiny11: + image: ${WINDOWS_IMAGE:-dockurr/windows} + container_name: tiny11 + env_file: ../../.env.legacy + environment: + VERSION: "tiny11" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - 8111:8006 + - 3111:3389/tcp + - 3111:3389/udp + volumes: + - ../../data/tiny11:/storage + restart: ${RESTART_POLICY:-on-failure} + stop_grace_period: 2m diff --git a/data/tiny10/.gitkeep b/data/tiny10/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/tiny11/.gitkeep b/data/tiny11/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/vista/.gitkeep b/data/vista/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win10/.gitkeep b/data/win10/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win10e/.gitkeep b/data/win10e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win10l/.gitkeep b/data/win10l/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win11/.gitkeep b/data/win11/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win11e/.gitkeep b/data/win11e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win11l/.gitkeep b/data/win11l/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2003/.gitkeep b/data/win2003/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2008/.gitkeep b/data/win2008/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2012/.gitkeep b/data/win2012/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2016/.gitkeep b/data/win2016/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2019/.gitkeep b/data/win2019/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2022/.gitkeep b/data/win2022/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2025/.gitkeep b/data/win2025/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win2k/.gitkeep b/data/win2k/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win7/.gitkeep b/data/win7/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win7e/.gitkeep b/data/win7e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win81/.gitkeep b/data/win81/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/win81e/.gitkeep b/data/win81e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/winxp/.gitkeep b/data/winxp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/readme.md b/readme.md index adbb6bf..143a891 100644 --- a/readme.md +++ b/readme.md @@ -70,6 +70,153 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/windows/refs/heads/mas [![Download WinBoat](https://github.com/dockur/windows/raw/master/.github/winboat.png)](https://winboat.app) +## Multi-Version Setup 🗂️ + +This repository includes pre-configured compose files for all Windows versions with optimized resource profiles. + +### Management Script (winctl.sh) + +Use `winctl.sh` for easy container management: + +```bash +# Check prerequisites and detected architecture +./winctl.sh check + +# Start a container (interactive menu if no version specified) +./winctl.sh start win11 +./winctl.sh start # Shows interactive menu + +# View status of all containers +./winctl.sh status + +# Stop containers (with confirmation) +./winctl.sh stop win11 +./winctl.sh stop all # Stop all running + +# View logs +./winctl.sh logs win11 -f + +# List all available versions (shows [x86 only] on ARM64) +./winctl.sh list +./winctl.sh list desktop # Filter by category + +# Real-time monitoring dashboard +./winctl.sh monitor + +# Rebuild container (preserves data) +./winctl.sh rebuild win11 + +# Open web viewer in browser +./winctl.sh open win11 + +# Pull latest Docker image +./winctl.sh pull + +# Show disk usage per VM +./winctl.sh disk + +# Snapshot and restore VM data +./winctl.sh snapshot win11 before-update +./winctl.sh restore win11 before-update + +# Clean up stopped containers +./winctl.sh clean + +# Multi-instance support +./winctl.sh start winxp --new # Create winxp-1 with auto ports +./winctl.sh start winxp --new lab # Create winxp-lab +./winctl.sh start winxp --new lab --clone # Clone data from base +./winctl.sh instances # List all instances +./winctl.sh destroy winxp-lab # Remove instance + +# ISO cache (skip re-downloads for new instances) +./winctl.sh cache download winxp # Download original ISO to cache +./winctl.sh cache list # Show cached ISOs +./winctl.sh start winxp --new # New instance uses cached ISO +./winctl.sh cache rm winxp # Remove cached winxp ISO +./winctl.sh cache flush # Clear all cached ISOs + +# Full help (includes ARM64 info) +./winctl.sh help +``` + +### Quick Start (Manual) + +```bash +# Run specific version +docker compose -f compose/desktop/win11.yml up win11 +docker compose -f compose/legacy/winxp.yml up winxp +``` + +### Configuration + +Two pre-configured env files with optimized defaults: + +| File | RAM | CPU | Disk | Used By | +|------|-----|-----|------|---------| +| `.env.modern` | 8G | 4 | 128G | Win 10/11, Server 2016+ | +| `.env.legacy` | 2G | 2 | 32G | Win 7/8, Vista, XP, 2000, Server 2003-2012, Tiny | + +Edit these files to customize: + +```bash +# .env.modern or .env.legacy +RAM_SIZE=8G +CPU_CORES=4 +DISK_SIZE=128G +USERNAME=docker +PASSWORD=admin +LANGUAGE=en +RESTART_POLICY=on-failure # Options: no, on-failure, always, unless-stopped +``` + +> 📖 See [WINCTL_GUIDE.md](WINCTL_GUIDE.md) for complete documentation, usage scenarios, and troubleshooting. + +### Folder Structure + +``` +.env.modern # Modern system defaults (8G RAM) +.env.legacy # Legacy system defaults (2G RAM) +compose/ +├── desktop/ # Win 11, 10, 8.1, 7 +├── legacy/ # Vista, XP, 2000 +├── server/ # Server 2003-2025 +└── tiny/ # Tiny10, Tiny11 + +instances/ # Generated instance files +├── registry.json # Instance registry +└── *.yml # Instance compose files + +data/ # VM storage (per-version folders) +├── win11/ +├── winxp/ +├── winxp-1/ # Instance data +└── ... + +snapshots/ # VM data snapshots +├── win11/ +└── ... + +cache/ # ISO cache (skip re-downloads) +├── winxp/ +└── ... +``` + +### Port Reference + +| Version | Web UI | RDP | +|---------|--------|-----| +| win11 | 8011 | 3311 | +| win10 | 8010 | 3310 | +| win81 | 8008 | 3308 | +| win7 | 8007 | 3307 | +| vista | 8006 | 3306 | +| winxp | 8005 | 3305 | +| win2k | 8000 | 3300 | +| win2025 | 8025 | 3325 | +| win2022 | 8022 | 3322 | +| tiny11 | 8111 | 3111 | + ## FAQ 💬 ### How do I use it? @@ -122,6 +269,30 @@ kubectl apply -f https://raw.githubusercontent.com/dockur/windows/refs/heads/mas > [!TIP] > To install ARM64 versions of Windows use [dockur/windows-arm](https://github.com/dockur/windows-arm/). +### How do I run on ARM64? + + The `winctl.sh` script auto-detects your CPU architecture. On ARM64 systems (e.g., Apple Silicon, Ampere), only Windows 10 and 11 variants are supported: + + | **Value** | **Version** | + |---|---| + | `11` | Windows 11 Pro | + | `11l` | Windows 11 LTSC | + | `11e` | Windows 11 Enterprise | + | `10` | Windows 10 Pro | + | `10l` | Windows 10 LTSC | + | `10e` | Windows 10 Enterprise | + + To configure, set the ARM64 image in your `.env.modern` or `.env.legacy` file: + + ```bash + WINDOWS_IMAGE=dockurr/windows-arm + ``` + + The script will automatically: + - Block unsupported versions with a clear error on `start` + - Show `[x86 only]` tags next to unsupported versions on `list` + - Display your detected architecture on `check` + ### How do I change the storage location? To change the storage location, include the following bind mount in your compose file: diff --git a/snapshots/.gitkeep b/snapshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/winctl.sh b/winctl.sh new file mode 100755 index 0000000..7b8fa9d --- /dev/null +++ b/winctl.sh @@ -0,0 +1,3310 @@ +#!/usr/bin/env bash +# +# winctl.sh - Windows Docker Container Management Script +# Manage Windows Docker containers with ease +# +# Usage: ./winctl.sh [options] +# +set -Eeuo pipefail + +# ============================================================================== +# METADATA +# ============================================================================== + +readonly SCRIPT_VERSION="1.0.0" +readonly SCRIPT_NAME="winctl" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR + +# Cache settings +readonly CACHE_DIR="${HOME}/.cache/winctl" +readonly CACHE_FILE="${CACHE_DIR}/status.json" +readonly CACHE_MAX_AGE=$((7 * 24 * 60 * 60)) # 7 days in seconds + +# ============================================================================== +# COLORS & TERMINAL DETECTION +# ============================================================================== + +if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then + readonly RED=$'\033[0;31m' + readonly GREEN=$'\033[0;32m' + readonly YELLOW=$'\033[0;33m' + readonly BLUE=$'\033[0;34m' + readonly MAGENTA=$'\033[0;35m' + readonly CYAN=$'\033[0;36m' + readonly WHITE=$'\033[0;37m' + readonly BOLD=$'\033[1m' + readonly DIM=$'\033[2m' + readonly RESET=$'\033[0m' +else + readonly RED='' + readonly GREEN='' + readonly YELLOW='' + readonly BLUE='' + readonly MAGENTA='' + readonly CYAN='' + readonly WHITE='' + readonly BOLD='' + readonly DIM='' + readonly RESET='' +fi + +# ============================================================================== +# VERSION DATA +# ============================================================================== + +# All supported versions +readonly ALL_VERSIONS=( + win11 win11e win11l win10 win10e win10l + win81 win81e win7 win7e + vista winxp win2k + win2025 win2022 win2019 win2016 win2012 win2008 win2003 + tiny11 tiny10 +) + +# Versions supported on ARM64 +readonly ARM_VERSIONS=( + win11 win11e win11l win10 win10e win10l +) + +# Port mappings (web) +declare -A VERSION_PORTS_WEB=( + ["win11"]=8011 ["win11e"]=8012 ["win11l"]=8013 + ["win10"]=8010 ["win10e"]=8014 ["win10l"]=8015 + ["win81"]=8008 ["win81e"]=8081 + ["win7"]=8007 ["win7e"]=8071 + ["vista"]=8006 ["winxp"]=8005 ["win2k"]=8000 + ["win2025"]=8025 ["win2022"]=8022 ["win2019"]=8019 ["win2016"]=8016 + ["win2012"]=8112 ["win2008"]=8108 ["win2003"]=8003 + ["tiny11"]=8111 ["tiny10"]=8110 +) + +# Port mappings (RDP) +declare -A VERSION_PORTS_RDP=( + ["win11"]=3311 ["win11e"]=3312 ["win11l"]=3313 + ["win10"]=3310 ["win10e"]=3314 ["win10l"]=3315 + ["win81"]=3308 ["win81e"]=3381 + ["win7"]=3307 ["win7e"]=3371 + ["vista"]=3306 ["winxp"]=3305 ["win2k"]=3300 + ["win2025"]=3325 ["win2022"]=3322 ["win2019"]=3319 ["win2016"]=3316 + ["win2012"]=3212 ["win2008"]=3208 ["win2003"]=3303 + ["tiny11"]=3111 ["tiny10"]=3110 +) + +# Categories +declare -A VERSION_CATEGORIES=( + ["win11"]="desktop" ["win11e"]="desktop" ["win11l"]="desktop" + ["win10"]="desktop" ["win10e"]="desktop" ["win10l"]="desktop" + ["win81"]="desktop" ["win81e"]="desktop" + ["win7"]="desktop" ["win7e"]="desktop" + ["vista"]="legacy" ["winxp"]="legacy" ["win2k"]="legacy" + ["win2025"]="server" ["win2022"]="server" ["win2019"]="server" ["win2016"]="server" + ["win2012"]="server" ["win2008"]="server" ["win2003"]="server" + ["tiny11"]="tiny" ["tiny10"]="tiny" +) + +# Compose files +declare -A VERSION_COMPOSE_FILES=( + ["win11"]="compose/desktop/win11.yml" ["win11e"]="compose/desktop/win11.yml" ["win11l"]="compose/desktop/win11.yml" + ["win10"]="compose/desktop/win10.yml" ["win10e"]="compose/desktop/win10.yml" ["win10l"]="compose/desktop/win10.yml" + ["win81"]="compose/desktop/win8.yml" ["win81e"]="compose/desktop/win8.yml" + ["win7"]="compose/desktop/win7.yml" ["win7e"]="compose/desktop/win7.yml" + ["vista"]="compose/legacy/vista.yml" ["winxp"]="compose/legacy/winxp.yml" ["win2k"]="compose/legacy/win2k.yml" + ["win2025"]="compose/server/win2025.yml" ["win2022"]="compose/server/win2022.yml" + ["win2019"]="compose/server/win2019.yml" ["win2016"]="compose/server/win2016.yml" + ["win2012"]="compose/server/win2012.yml" ["win2008"]="compose/server/win2008.yml" ["win2003"]="compose/server/win2003.yml" + ["tiny11"]="compose/tiny/tiny11.yml" ["tiny10"]="compose/tiny/tiny10.yml" +) + +# Display names +declare -A VERSION_DISPLAY_NAMES=( + ["win11"]="Windows 11 Pro" ["win11e"]="Windows 11 Enterprise" ["win11l"]="Windows 11 LTSC" + ["win10"]="Windows 10 Pro" ["win10e"]="Windows 10 Enterprise" ["win10l"]="Windows 10 LTSC" + ["win81"]="Windows 8.1 Pro" ["win81e"]="Windows 8.1 Enterprise" + ["win7"]="Windows 7 Ultimate" ["win7e"]="Windows 7 Enterprise" + ["vista"]="Windows Vista Ultimate" ["winxp"]="Windows XP Professional" ["win2k"]="Windows 2000 Professional" + ["win2025"]="Windows Server 2025" ["win2022"]="Windows Server 2022" + ["win2019"]="Windows Server 2019" ["win2016"]="Windows Server 2016" + ["win2012"]="Windows Server 2012 R2" ["win2008"]="Windows Server 2008 R2" ["win2003"]="Windows Server 2003" + ["tiny11"]="Tiny11" ["tiny10"]="Tiny10" +) + +# Resource type (modern = high resources, legacy = low resources) +declare -A VERSION_RESOURCE_TYPE=( + ["win11"]="modern" ["win11e"]="modern" ["win11l"]="modern" + ["win10"]="modern" ["win10e"]="modern" ["win10l"]="modern" + ["win81"]="legacy" ["win81e"]="legacy" + ["win7"]="legacy" ["win7e"]="legacy" + ["vista"]="legacy" ["winxp"]="legacy" ["win2k"]="legacy" + ["win2025"]="modern" ["win2022"]="modern" ["win2019"]="modern" ["win2016"]="modern" + ["win2012"]="legacy" ["win2008"]="legacy" ["win2003"]="legacy" + ["tiny11"]="legacy" ["tiny10"]="legacy" +) + +# VERSION env values (maps base version to compose VERSION environment variable) +declare -A VERSION_ENV_VALUES=( + ["win11"]="11" ["win11e"]="11e" ["win11l"]="11l" + ["win10"]="10" ["win10e"]="10e" ["win10l"]="10l" + ["win81"]="8" ["win81e"]="8e" + ["win7"]="7u" ["win7e"]="7e" + ["vista"]="vu" ["winxp"]="xp" ["win2k"]="2k" + ["win2025"]="2025" ["win2022"]="2022" ["win2019"]="2019" ["win2016"]="2016" + ["win2012"]="2012" ["win2008"]="2008" ["win2003"]="2003" + ["tiny11"]="tiny11" ["tiny10"]="tiny10" +) + +# VERSION env files (maps base version to env file path) +declare -A VERSION_ENV_FILES=( + ["win11"]=".env.modern" ["win11e"]=".env.modern" ["win11l"]=".env.modern" + ["win10"]=".env.modern" ["win10e"]=".env.modern" ["win10l"]=".env.modern" + ["win81"]=".env.legacy" ["win81e"]=".env.legacy" + ["win7"]=".env.legacy" ["win7e"]=".env.legacy" + ["vista"]=".env.legacy" ["winxp"]=".env.legacy" ["win2k"]=".env.legacy" + ["win2025"]=".env.modern" ["win2022"]=".env.modern" + ["win2019"]=".env.modern" ["win2016"]=".env.modern" + ["win2012"]=".env.legacy" ["win2008"]=".env.legacy" ["win2003"]=".env.legacy" + ["tiny11"]=".env.legacy" ["tiny10"]=".env.legacy" +) + +# Instance constants +readonly INSTANCE_DIR="${SCRIPT_DIR}/instances" + +# ISO cache directory +readonly ISO_CACHE_DIR="${SCRIPT_DIR}/cache" +readonly INSTANCE_REGISTRY="${INSTANCE_DIR}/registry.json" +readonly INSTANCE_WEB_PORT_BASE=9000 +readonly INSTANCE_RDP_PORT_BASE=4000 +readonly INSTANCE_PORT_RANGE=999 + +# Resource requirements +readonly MODERN_RAM_GB=8 +readonly MODERN_DISK_GB=128 +readonly LEGACY_RAM_GB=2 +readonly LEGACY_DISK_GB=32 + +# Winctl settings (loaded from .env) +AUTO_CACHE="N" +if [[ -f "$SCRIPT_DIR/.env" ]]; then + _val=$(grep -E '^AUTO_CACHE=' "$SCRIPT_DIR/.env" 2>/dev/null | tail -1 | cut -d'=' -f2- || true) + [[ -n "$_val" ]] && AUTO_CACHE="$_val" + unset _val +fi + +# ============================================================================== +# OUTPUT HELPERS +# ============================================================================== + +info() { + printf '%s\n' "${BLUE}[INFO]${RESET} $*" +} + +success() { + printf '%s\n' "${GREEN}[OK]${RESET} $*" +} + +warn() { + printf '%s\n' "${YELLOW}[WARN]${RESET} $*" +} + +error() { + printf '%s\n' "${RED}[ERROR]${RESET} $*" >&2 +} + +die() { + error "$@" + exit 1 +} + +header() { + printf '\n' + printf '%s\n' "${BOLD}${CYAN}$*${RESET}" + printf '%s\n' "${DIM}$(printf '─%.0s' {1..60})${RESET}" +} + +# Print a formatted table row +table_row() { + local version="$1" + local name="$2" + local status="$3" + local web="$4" + local rdp="$5" + + local status_color + case "$status" in + running) status_color="${GREEN}" ;; + stopped|exited) status_color="${RED}" ;; + *) status_color="${YELLOW}" ;; + esac + + printf " %s%-12s%s %-26s %s%-10s%s %-8s %-8s\n" \ + "${BOLD}" "$version" "${RESET}" "$name" "$status_color" "$status" "${RESET}" "$web" "$rdp" +} + +table_header() { + printf '\n' + printf " %s%-12s %-26s %-10s %-8s %-8s%s\n" \ + "${BOLD}${DIM}" "VERSION" "NAME" "STATUS" "WEB" "RDP" "${RESET}" + printf '%s\n' " ${DIM}$(printf '─%.0s' {1..66})${RESET}" +} + +# ============================================================================== +# ARCHITECTURE DETECTION +# ============================================================================== + +DETECTED_ARCH="" + +detect_arch() { + if [[ -n "$DETECTED_ARCH" ]]; then + return + fi + local machine + machine=$(uname -m) + case "$machine" in + x86_64|amd64) DETECTED_ARCH="amd64" ;; + aarch64|arm64) DETECTED_ARCH="arm64" ;; + *) DETECTED_ARCH="amd64" ;; + esac +} + +is_arm_supported() { + local version="$1" + local v + for v in "${ARM_VERSIONS[@]}"; do + if [[ "$v" == "$version" ]]; then + return 0 + fi + done + return 1 +} + +# ============================================================================== +# LAN IP DETECTION +# ============================================================================== + +LAN_IP="" + +detect_lan_ip() { + if [[ -n "$LAN_IP" ]]; then return; fi + # Try hostname -I first (Linux), then ipconfig getifaddr (macOS) + LAN_IP=$(hostname -I 2>/dev/null | awk '{print $1}') + if [[ -z "$LAN_IP" ]]; then + LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || true) + fi +} + +# ============================================================================== +# PREREQUISITES CHECKS +# ============================================================================== + +check_docker() { + if ! command -v docker &>/dev/null; then + error "Docker is not installed" + printf '%s\n' " Install Docker: https://docs.docker.com/get-docker/" + return 1 + fi + + if ! docker info &>/dev/null; then + error "Docker daemon is not running" + printf '%s\n' " Start Docker: sudo systemctl start docker" + return 1 + fi + + success "Docker is available" + return 0 +} + +check_compose() { + if docker compose version &>/dev/null; then + success "Docker Compose plugin is available" + return 0 + elif command -v docker-compose &>/dev/null; then + success "Docker Compose standalone is available" + return 0 + else + error "Docker Compose is not installed" + printf '%s\n' " Install: https://docs.docker.com/compose/install/" + return 1 + fi +} + +check_kvm() { + if [[ ! -e /dev/kvm ]]; then + error "KVM device not found (/dev/kvm)" + printf '%s\n' " Enable virtualization in BIOS or check nested virtualization" + return 1 + fi + + if [[ ! -r /dev/kvm ]] || [[ ! -w /dev/kvm ]]; then + error "KVM device not accessible" + printf '%s\n' " Fix: sudo usermod -aG kvm \$USER && newgrp kvm" + return 1 + fi + + success "KVM is available" + return 0 +} + +check_tun() { + if [[ ! -e /dev/net/tun ]]; then + warn "TUN device not found (/dev/net/tun) - networking may be limited" + return 1 + fi + + success "TUN device is available" + return 0 +} + +check_memory() { + local required_gb="${1:-$MODERN_RAM_GB}" + local available_kb + available_kb=$(grep MemAvailable /proc/meminfo | awk '{print $2}') + local available_gb=$((available_kb / 1024 / 1024)) + + if ((available_gb < required_gb)); then + warn "Low memory: ${available_gb}GB available (${required_gb}GB recommended)" + return 1 + fi + + success "Memory OK: ${available_gb}GB available (${required_gb}GB needed)" + return 0 +} + +check_disk() { + local required_gb="${1:-$MODERN_DISK_GB}" + local available_kb + available_kb=$(df "$SCRIPT_DIR" | tail -1 | awk '{print $4}') + local available_gb=$((available_kb / 1024 / 1024)) + + if ((available_gb < required_gb)); then + warn "Low disk space: ${available_gb}GB available (${required_gb}GB recommended)" + return 1 + fi + + success "Disk space OK: ${available_gb}GB available (${required_gb}GB needed)" + return 0 +} + +run_all_checks() { + header "Prerequisites Check" + + local failed=0 + + check_docker || ((failed++)) + check_compose || ((failed++)) + check_kvm || ((failed++)) + check_tun || true # Warning only + check_memory || true # Warning only + check_disk || true # Warning only + + printf '\n' + if ((failed > 0)); then + error "Some critical checks failed. Please fix the issues above." + return 1 + else + success "All critical prerequisites passed!" + return 0 + fi +} + +# ============================================================================== +# DOCKER HELPERS +# ============================================================================== + +# Get the compose command (plugin vs standalone) +compose_cmd() { + if docker compose version &>/dev/null; then + echo "docker compose" + else + echo "docker-compose" + fi +} + +# ============================================================================== +# STATUS CACHE (JSON file-based with auto-refresh) +# ============================================================================== + +# In-memory cache (loaded from JSON) +declare -A _STATUS_CACHE=() +_STATUS_CACHE_VALID=false +_STATUS_CACHE_TIMESTAMP=0 + +# Ensure cache directory exists +ensure_cache_dir() { + [[ -d "$CACHE_DIR" ]] || mkdir -p "$CACHE_DIR" +} + +# Get cache file age in seconds (returns large number if file doesn't exist) +get_cache_age() { + if [[ -f "$CACHE_FILE" ]]; then + local file_time current_time + file_time=$(stat -c %Y "$CACHE_FILE" 2>/dev/null || stat -f %m "$CACHE_FILE" 2>/dev/null || echo 0) + current_time=$(date +%s) + echo $((current_time - file_time)) + else + echo 999999999 + fi +} + +# Check if cache needs refresh (age > max age) +cache_needs_refresh() { + local age + age=$(get_cache_age) + ((age > CACHE_MAX_AGE)) +} + +# Write status cache to JSON file +write_cache_file() { + ensure_cache_dir + local timestamp + timestamp=$(date +%s) + + # Build JSON manually (no jq dependency) + { + echo "{" + echo " \"timestamp\": $timestamp," + echo " \"containers\": {" + local first=true + for name in "${!_STATUS_CACHE[@]}"; do + if [[ "$first" == "true" ]]; then + first=false + else + echo "," + fi + printf ' "%s": "%s"' "$name" "${_STATUS_CACHE[$name]}" + done + echo "" + echo " }" + echo "}" + } > "$CACHE_FILE" +} + +# Read status cache from JSON file +read_cache_file() { + if [[ ! -f "$CACHE_FILE" ]]; then + return 1 + fi + + _STATUS_CACHE=() + _STATUS_CACHE_TIMESTAMP=0 + + # Parse JSON manually (no jq dependency) + local in_containers=false + while IFS= read -r line; do + # Extract timestamp + if [[ "$line" =~ \"timestamp\":[[:space:]]*([0-9]+) ]]; then + _STATUS_CACHE_TIMESTAMP="${BASH_REMATCH[1]}" + fi + # Track when we're in containers section + if [[ "$line" =~ \"containers\" ]]; then + in_containers=true + continue + fi + # Parse container entries + if [[ "$in_containers" == "true" && "$line" =~ \"([^\"]+)\":[[:space:]]*\"([^\"]+)\" ]]; then + local name="${BASH_REMATCH[1]}" + local state="${BASH_REMATCH[2]}" + _STATUS_CACHE["$name"]="$state" + fi + done < "$CACHE_FILE" + + return 0 +} + +# Validate cache by spot-checking a running container still exists +validate_cache() { + # If cache shows a container as running, verify it still exists + for name in "${!_STATUS_CACHE[@]}"; do + if [[ "${_STATUS_CACHE[$name]}" == "running" ]]; then + # Quick check if this container exists + if ! docker ps -q --filter "name=^${name}$" 2>/dev/null | grep -q .; then + return 1 # Cache is stale + fi + return 0 # Found a valid running container + fi + done + return 0 # No running containers to validate +} + +# Refresh the status cache from Docker and save to file +refresh_status_cache() { + local force="${1:-false}" + + # Try to load from file cache first (unless forced) + if [[ "$force" != "true" && "$_STATUS_CACHE_VALID" != "true" ]]; then + if read_cache_file; then + # Check if cache is still valid (not too old) + if ! cache_needs_refresh; then + # Validate cache data + if validate_cache; then + _STATUS_CACHE_VALID=true + return 0 + fi + fi + fi + fi + + # Fetch fresh data from Docker + _STATUS_CACHE=() + local line + while IFS= read -r line; do + if [[ -n "$line" ]]; then + local name state + name="${line%%:*}" + state="${line#*:}" + _STATUS_CACHE["$name"]="$state" + fi + done < <(docker ps -a --format '{{.Names}}:{{.State}}' 2>/dev/null) + _STATUS_CACHE_VALID=true + + # Save to file + write_cache_file +} + +# Force refresh the cache (called after start/stop/restart operations) +invalidate_cache() { + _STATUS_CACHE_VALID=false + refresh_status_cache true +} + +# Check if a container is running +is_running() { + local version="$1" + if [[ "$_STATUS_CACHE_VALID" == "true" ]]; then + [[ "${_STATUS_CACHE[$version]:-}" == "running" ]] + else + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${version}$" + fi +} + +# Check if a container exists (running or stopped) +container_exists() { + local version="$1" + if [[ "$_STATUS_CACHE_VALID" == "true" ]]; then + [[ -n "${_STATUS_CACHE[$version]:-}" ]] + else + docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${version}$" + fi +} + +# Get container status +get_status() { + local version="$1" + if [[ "$_STATUS_CACHE_VALID" == "true" ]]; then + echo "${_STATUS_CACHE[$version]:-not created}" + else + local status + status=$(docker ps -a --filter "name=^${version}$" --format '{{.State}}' 2>/dev/null) + echo "${status:-not created}" + fi +} + +# ============================================================================== +# INSTANCE REGISTRY (JSON file-based) +# ============================================================================== + +# In-memory registry: _REGISTRY_INSTANCES["name"]="base|suffix|web_port|rdp_port|created" +declare -A _REGISTRY_INSTANCES=() +_REGISTRY_LOADED=false + +# Ensure instance directory exists +ensure_instance_dir() { + [[ -d "$INSTANCE_DIR" ]] || mkdir -p "$INSTANCE_DIR" +} + +# Load registry from JSON file into memory +load_registry() { + if [[ "$_REGISTRY_LOADED" == "true" ]]; then + return 0 + fi + + _REGISTRY_INSTANCES=() + + if [[ ! -f "$INSTANCE_REGISTRY" ]]; then + _REGISTRY_LOADED=true + return 0 + fi + + local current_name="" current_base="" current_suffix="" + local current_web="" current_rdp="" current_created="" + local in_instances=false in_entry=false + + while IFS= read -r line; do + if [[ "$line" =~ \"instances\" ]]; then + in_instances=true + continue + fi + if [[ "$in_instances" == "true" && "$in_entry" == "false" ]]; then + # Look for entry key like "winxp-lab": { + if [[ "$line" =~ \"([^\"]+)\":[[:space:]]*\{ ]]; then + current_name="${BASH_REMATCH[1]}" + in_entry=true + current_base="" current_suffix="" current_web="" current_rdp="" current_created="" + continue + fi + fi + if [[ "$in_entry" == "true" ]]; then + if [[ "$line" =~ \"base\":[[:space:]]*\"([^\"]+)\" ]]; then + current_base="${BASH_REMATCH[1]}" + elif [[ "$line" =~ \"suffix\":[[:space:]]*\"([^\"]+)\" ]]; then + current_suffix="${BASH_REMATCH[1]}" + elif [[ "$line" =~ \"web_port\":[[:space:]]*([0-9]+) ]]; then + current_web="${BASH_REMATCH[1]}" + elif [[ "$line" =~ \"rdp_port\":[[:space:]]*([0-9]+) ]]; then + current_rdp="${BASH_REMATCH[1]}" + elif [[ "$line" =~ \"created\":[[:space:]]*\"([^\"]+)\" ]]; then + current_created="${BASH_REMATCH[1]}" + fi + # End of entry + if [[ "$line" =~ \} ]]; then + if [[ -n "$current_name" ]]; then + _REGISTRY_INSTANCES["$current_name"]="${current_base}|${current_suffix}|${current_web}|${current_rdp}|${current_created}" + fi + in_entry=false + current_name="" + fi + fi + done < "$INSTANCE_REGISTRY" + + _REGISTRY_LOADED=true +} + +# Write in-memory registry to JSON file (atomic: write to .tmp then mv) +write_registry() { + ensure_instance_dir + + local tmp_file="${INSTANCE_REGISTRY}.tmp" + { + echo "{" + echo " \"version\": 1," + echo " \"instances\": {" + local first=true + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local entry="${_REGISTRY_INSTANCES[$name]}" + local base suffix web_port rdp_port created + IFS='|' read -r base suffix web_port rdp_port created <<< "$entry" + + if [[ "$first" == "true" ]]; then + first=false + else + echo "," + fi + printf ' "%s": {\n' "$name" + printf ' "base": "%s",\n' "$base" + printf ' "suffix": "%s",\n' "$suffix" + printf ' "web_port": %s,\n' "$web_port" + printf ' "rdp_port": %s,\n' "$rdp_port" + printf ' "created": "%s"\n' "$created" + printf ' }' + done + echo "" + echo " }" + echo "}" + } > "$tmp_file" + mv "$tmp_file" "$INSTANCE_REGISTRY" +} + +# Register a new instance +register_instance() { + local name="$1" base="$2" suffix="$3" web_port="$4" rdp_port="$5" + local created + created=$(date -u +%Y-%m-%dT%H:%M:%SZ) + _REGISTRY_INSTANCES["$name"]="${base}|${suffix}|${web_port}|${rdp_port}|${created}" + write_registry +} + +# Unregister an instance +unregister_instance() { + local name="$1" + unset '_REGISTRY_INSTANCES['"$name"']' + write_registry +} + +# Get a field from a registry entry +# Fields: 1=base, 2=suffix, 3=web_port, 4=rdp_port, 5=created +registry_get_field() { + local name="$1" field="$2" + local entry="${_REGISTRY_INSTANCES[$name]:-}" + if [[ -z "$entry" ]]; then + return 1 + fi + local idx + case "$field" in + base) idx=1 ;; + suffix) idx=2 ;; + web_port) idx=3 ;; + rdp_port) idx=4 ;; + created) idx=5 ;; + *) return 1 ;; + esac + echo "$entry" | cut -d'|' -f"$idx" +} + +# Check if a name is a registered instance +is_instance() { + local name="$1" + load_registry + [[ -n "${_REGISTRY_INSTANCES[$name]:-}" ]] +} + +# ============================================================================== +# DOCKER HELPERS (continued) +# ============================================================================== + +# Get compose file path for version or instance +get_compose_file() { + local target="$1" + + # Check if it's an instance first + if is_instance "$target"; then + echo "$INSTANCE_DIR/${target}.yml" + return 0 + fi + + local file="${VERSION_COMPOSE_FILES[$target]:-}" + if [[ -z "$file" ]]; then + die "Unknown version or instance: $target" + fi + echo "$SCRIPT_DIR/$file" +} + +# ============================================================================== +# RESOLUTION LAYER +# ============================================================================== + +# Resolved target globals (set by resolve_target) +RESOLVED_TYPE="" # "base" or "instance" +RESOLVED_NAME="" # e.g. "win11" or "winxp-lab" +RESOLVED_BASE="" # base version, e.g. "winxp" +RESOLVED_WEB_PORT="" +RESOLVED_RDP_PORT="" +RESOLVED_DISPLAY_NAME="" +RESOLVED_COMPOSE="" + +# Resolve a target name to its type, ports, display name, and compose file +resolve_target() { + local target="$1" + + # Reset globals + RESOLVED_TYPE="" RESOLVED_NAME="" RESOLVED_BASE="" + RESOLVED_WEB_PORT="" RESOLVED_RDP_PORT="" + RESOLVED_DISPLAY_NAME="" RESOLVED_COMPOSE="" + + # Check if it's a base version + if [[ -n "${VERSION_COMPOSE_FILES[$target]:-}" ]]; then + RESOLVED_TYPE="base" + RESOLVED_NAME="$target" + RESOLVED_BASE="$target" + RESOLVED_WEB_PORT="${VERSION_PORTS_WEB[$target]}" + RESOLVED_RDP_PORT="${VERSION_PORTS_RDP[$target]}" + RESOLVED_DISPLAY_NAME="${VERSION_DISPLAY_NAMES[$target]}" + RESOLVED_COMPOSE=$(get_compose_file "$target") + return 0 + fi + + # Check if it's a registered instance + load_registry + if [[ -n "${_REGISTRY_INSTANCES[$target]:-}" ]]; then + local base + base=$(registry_get_field "$target" "base") + RESOLVED_TYPE="instance" + RESOLVED_NAME="$target" + RESOLVED_BASE="$base" + RESOLVED_WEB_PORT=$(registry_get_field "$target" "web_port") + RESOLVED_RDP_PORT=$(registry_get_field "$target" "rdp_port") + RESOLVED_DISPLAY_NAME="${VERSION_DISPLAY_NAMES[$base]} ($target)" + RESOLVED_COMPOSE="$INSTANCE_DIR/${target}.yml" + return 0 + fi + + return 1 +} + +# Validate a target (base version or instance) +validate_target() { + local target="$1" + if ! resolve_target "$target"; then + error "Unknown version or instance: $target" + echo " Run '${SCRIPT_NAME} list' to see available versions" + echo " Run '${SCRIPT_NAME} instances' to see instances" + return 1 + fi + return 0 +} + +# Validate base version only (backward compat wrapper) +validate_version() { + local version="$1" + if [[ -z "${VERSION_COMPOSE_FILES[$version]:-}" ]]; then + error "Unknown version: $version" + echo " Run '${SCRIPT_NAME} list' to see available versions" + return 1 + fi + return 0 +} + +# Run compose command for a version or instance +run_compose() { + local target="$1" + shift + local compose_file + compose_file=$(get_compose_file "$target") + + cd "$SCRIPT_DIR" + if is_instance "$target"; then + $(compose_cmd) -p "$target" -f "$compose_file" "$@" + else + $(compose_cmd) -f "$compose_file" "$@" + fi +} + +# ============================================================================== +# INTERACTIVE MENU +# ============================================================================== + +# Get versions by category +get_versions_by_category() { + local category="$1" + local versions=() + for v in "${ALL_VERSIONS[@]}"; do + if [[ "${VERSION_CATEGORIES[$v]}" == "$category" ]]; then + versions+=("$v") + fi + done + echo "${versions[*]}" +} + +# Show category menu (prompts to stderr, result to stdout) +select_category() { + { + header "Select Category" + printf '\n' + printf ' %b) Desktop (Win 11, 10, 8.1, 7)\n' "${BOLD}1${RESET}" + printf ' %b) Legacy (Vista, XP, 2000)\n' "${BOLD}2${RESET}" + printf ' %b) Server (2025, 2022, 2019, 2016, 2012, 2008, 2003)\n' "${BOLD}3${RESET}" + printf ' %b) Tiny (Tiny11, Tiny10)\n' "${BOLD}4${RESET}" + printf ' %b) All versions\n' "${BOLD}5${RESET}" + printf ' %b) Select individual versions\n' "${BOLD}6${RESET}" + printf '\n' + printf ' Select [1-6]: ' + } >&2 + + local choice + read -r choice &2 + + local input + read -r input = 1 && num <= ${#versions[@]})); then + selected+=("${versions[$((num-1))]}") + fi + done + + if [[ ${#selected[@]} -eq 0 ]]; then + return 1 + fi + + echo "${selected[*]}" +} + +# Interactive version selection +interactive_select() { + local category + category=$(select_category) + + if [[ -z "$category" ]]; then + error "Invalid selection" + return 1 + fi + + local selected + if ! selected=$(select_versions "$category"); then + error "No versions selected" + return 1 + fi + + echo "$selected" +} + +# ============================================================================== +# COMMANDS +# ============================================================================== + +cmd_start() { + local args=("$@") + local versions=() + local new_flag=false + local clone_flag=false + local instance_name="" + + # Parse flags + local i=0 + while ((i < ${#args[@]})); do + case "${args[$i]}" in + --new) + new_flag=true + # Next non-flag arg is optional instance name + if ((i + 1 < ${#args[@]})) && [[ "${args[$((i+1))]}" != --* ]]; then + ((i++)) || true + instance_name="${args[$i]}" + fi + ;; + --clone) + clone_flag=true + ;; + *) + versions+=("${args[$i]}") + ;; + esac + ((i++)) || true + done + + # Route to cmd_new if --new flag is set + if [[ "$new_flag" == "true" ]]; then + if [[ ${#versions[@]} -ne 1 ]]; then + die "Usage: ${SCRIPT_NAME} start --new [name] [--clone]" + fi + cmd_new "${versions[0]}" "$instance_name" "$clone_flag" + return + fi + + # Interactive selection if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + local selected + if ! selected=$(interactive_select); then + exit 1 + fi + IFS=' ' read -ra versions <<< "$selected" + fi + + # Validate all targets first + for v in "${versions[@]}"; do + validate_target "$v" || exit 1 + done + + # Check ARM compatibility (only for base versions) + detect_arch + if [[ "$DETECTED_ARCH" == "arm64" ]]; then + for v in "${versions[@]}"; do + resolve_target "$v" + if ! is_arm_supported "$RESOLVED_BASE"; then + die "${RESOLVED_DISPLAY_NAME} is not supported on ARM64. Supported: ${ARM_VERSIONS[*]}" + fi + done + fi + + # Run prerequisite checks + check_docker || exit 1 + check_kvm || exit 1 + + for v in "${versions[@]}"; do + resolve_target "$v" + header "Starting ${RESOLVED_DISPLAY_NAME} ($v)" + + # Check resources + local resource_type="${VERSION_RESOURCE_TYPE[$RESOLVED_BASE]}" + if [[ "$resource_type" == "modern" ]]; then + check_memory "$MODERN_RAM_GB" || true + check_disk "$MODERN_DISK_GB" || true + else + check_memory "$LEGACY_RAM_GB" || true + check_disk "$LEGACY_DISK_GB" || true + fi + + # Ensure data directory exists + local data_dir="$SCRIPT_DIR/data/$v" + if [[ ! -d "$data_dir" ]]; then + info "Creating data directory: data/$v" + mkdir -p "$data_dir" + fi + + # Pre-populate from ISO cache if no ISO exists in data dir + local existing_isos + existing_isos=$(find "$data_dir" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + if [[ -z "$existing_isos" ]]; then + local cache_src="$ISO_CACHE_DIR/$RESOLVED_BASE" + if [[ -d "$cache_src" ]]; then + local cached_iso + cached_iso=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f -print -quit 2>/dev/null || true) + if [[ -n "$cached_iso" ]]; then + local iso_name + iso_name=$(basename "$cached_iso") + info "Restoring cached ISO: $iso_name..." + cp "$cached_iso" "$data_dir/$iso_name" + success "ISO restored from cache (skipping download)" + fi + fi + fi + + # Check ports are available + if ! check_port "$RESOLVED_WEB_PORT"; then + error "Web port $RESOLVED_WEB_PORT is already in use" + continue + fi + if ! check_port "$RESOLVED_RDP_PORT"; then + error "RDP port $RESOLVED_RDP_PORT is already in use" + continue + fi + + if is_running "$v"; then + info "$v is already running" + else + info "Starting $v..." + if run_compose "$v" up -d "$v"; then + success "$v started successfully" + else + error "Failed to start $v" + continue + fi + fi + + # Show connection info + detect_lan_ip + printf '\n' + printf '%s\n' " ${BOLD}Connection Details:${RESET}" + printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${RESOLVED_WEB_PORT}${RESET}" + printf '%s\n' " → RDP: ${CYAN}localhost:${RESOLVED_RDP_PORT}${RESET}" + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " → LAN Web: ${CYAN}http://${LAN_IP}:${RESOLVED_WEB_PORT}${RESET}" + printf '%s\n' " → LAN RDP: ${CYAN}${LAN_IP}:${RESOLVED_RDP_PORT}${RESET}" + fi + printf '\n' + done + + # Refresh cache after state changes + invalidate_cache +} + +cmd_stop() { + local versions=("$@") + + # Stop all running containers (base + instances) + if [[ ${#versions[@]} -eq 1 && "${versions[0]}" == "all" ]]; then + versions=() + refresh_status_cache + for v in "${ALL_VERSIONS[@]}"; do + local status + status=$(get_status "$v") + if [[ "$status" == "running" ]]; then + versions+=("$v") + fi + done + # Also check instances + load_registry + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local status + status=$(get_status "$name") + if [[ "$status" == "running" ]]; then + versions+=("$name") + fi + done + if [[ ${#versions[@]} -eq 0 ]]; then + info "No running containers found" + return 0 + fi + fi + + # Interactive selection if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + local selected + if ! selected=$(interactive_select); then + exit 1 + fi + IFS=' ' read -ra versions <<< "$selected" + fi + + # Validate all targets first + for v in "${versions[@]}"; do + validate_target "$v" || exit 1 + done + + # Show confirmation + header "Stopping Containers" + printf '\n' + printf '%s\n' " The following containers will be stopped:" + for v in "${versions[@]}"; do + resolve_target "$v" + local status + if is_running "$v"; then + status="${GREEN}running${RESET}" + else + status="${YELLOW}not running${RESET}" + fi + printf '%s\n' " • $v (${RESOLVED_DISPLAY_NAME}) - $status" + done + printf '\n' + printf '%s' " Continue? [y/N]: " + + local confirm + read -r confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + info "Canceled" + return 0 + fi + + for v in "${versions[@]}"; do + if ! is_running "$v" && ! container_exists "$v"; then + info "$v is not running" + continue + fi + + info "Stopping $v (grace period: 2 minutes)..." + if run_compose "$v" stop "$v"; then + success "$v stopped" + else + error "Failed to stop $v" + fi + done + + # Auto-cache ISOs if enabled + if [[ "${AUTO_CACHE^^}" == "Y" ]]; then + for v in "${versions[@]}"; do + auto_cache_save "$v" + done + fi + + # Refresh cache after state changes + invalidate_cache +} + +cmd_restart() { + local versions=("$@") + + # Interactive selection if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + local selected + if ! selected=$(interactive_select); then + exit 1 + fi + IFS=' ' read -ra versions <<< "$selected" + fi + + # Validate all targets first + for v in "${versions[@]}"; do + validate_target "$v" || exit 1 + done + + for v in "${versions[@]}"; do + resolve_target "$v" + header "Restarting ${RESOLVED_DISPLAY_NAME} ($v)" + + info "Restarting $v..." + if run_compose "$v" restart "$v"; then + success "$v restarted" + detect_lan_ip + printf '\n' + printf '%s\n' " ${BOLD}Connection Details:${RESET}" + printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${RESOLVED_WEB_PORT}${RESET}" + printf '%s\n' " → RDP: ${CYAN}localhost:${RESOLVED_RDP_PORT}${RESET}" + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " → LAN Web: ${CYAN}http://${LAN_IP}:${RESOLVED_WEB_PORT}${RESET}" + printf '%s\n' " → LAN RDP: ${CYAN}${LAN_IP}:${RESOLVED_RDP_PORT}${RESET}" + fi + printf '\n' + else + error "Failed to restart $v" + fi + done + + # Refresh cache after state changes + invalidate_cache +} + +cmd_status() { + local versions=("$@") + + # Show all if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + versions=("${ALL_VERSIONS[@]}") + fi + + table_header + + for v in "${versions[@]}"; do + if validate_version "$v" 2>/dev/null; then + local status + status=$(get_status "$v") + table_row "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" "${VERSION_PORTS_WEB[$v]}" "${VERSION_PORTS_RDP[$v]}" + elif validate_target "$v" 2>/dev/null; then + resolve_target "$v" + local status + status=$(get_status "$v") + table_row "$v" "$RESOLVED_DISPLAY_NAME" "$status" "$RESOLVED_WEB_PORT" "$RESOLVED_RDP_PORT" + fi + done + + # Show instances section if showing all + load_registry + if [[ ${#_REGISTRY_INSTANCES[@]} -gt 0 && ${#versions[@]} -eq ${#ALL_VERSIONS[@]} ]]; then + printf '\n' + printf " %s%-12s %-26s %-10s %-8s %-8s%s\n" \ + "${BOLD}${DIM}" "INSTANCE" "NAME" "STATUS" "WEB" "RDP" "${RESET}" + printf '%s\n' " ${DIM}$(printf '─%.0s' {1..66})${RESET}" + + for name in "${!_REGISTRY_INSTANCES[@]}"; do + resolve_target "$name" + local status + status=$(get_status "$name") + table_row "$name" "$RESOLVED_DISPLAY_NAME" "$status" "$RESOLVED_WEB_PORT" "$RESOLVED_RDP_PORT" + done + fi + printf '\n' + + detect_lan_ip + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " ${DIM}LAN IP: ${LAN_IP} — use http://${LAN_IP}: for remote access${RESET}" + printf '\n' + fi +} + +cmd_logs() { + local version="${1:-}" + local follow="${2:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} logs [-f]" + fi + + validate_target "$version" || exit 1 + + local args=() + if [[ "$follow" == "-f" ]]; then + args+=("--follow") + fi + + info "Showing logs for $version..." + run_compose "$version" logs "${args[@]}" "$version" +} + +cmd_shell() { + local version="${1:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} shell " + fi + + validate_target "$version" || exit 1 + + if ! is_running "$version"; then + die "$version is not running" + fi + + info "Opening shell in $version..." + docker exec -it "$version" /bin/bash +} + +cmd_stats() { + local versions=("$@") + + # Get running containers if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + local running=() + for v in "${ALL_VERSIONS[@]}"; do + if is_running "$v"; then + running+=("$v") + fi + done + # Also check instances + load_registry + for name in "${!_REGISTRY_INSTANCES[@]}"; do + if is_running "$name"; then + running+=("$name") + fi + done + if [[ ${#running[@]} -eq 0 ]]; then + die "No containers are running" + fi + versions=("${running[@]}") + fi + + # Validate targets + local valid_running=() + for v in "${versions[@]}"; do + if validate_target "$v" 2>/dev/null && is_running "$v"; then + valid_running+=("$v") + fi + done + + if [[ ${#valid_running[@]} -eq 0 ]]; then + die "None of the specified containers are running" + fi + + info "Showing stats for: ${valid_running[*]}" + docker stats "${valid_running[@]}" +} + +cmd_build() { + header "Building Docker Image" + + check_docker || exit 1 + + info "Building dockurr/windows image locally..." + cd "$SCRIPT_DIR" + + if docker build -t dockurr/windows .; then + success "Image built successfully" + else + die "Build failed" + fi +} + +cmd_rebuild() { + local versions=("$@") + + # Interactive selection if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + local selected + if ! selected=$(interactive_select); then + exit 1 + fi + IFS=' ' read -ra versions <<< "$selected" + fi + + # Validate all targets first + for v in "${versions[@]}"; do + validate_target "$v" || exit 1 + done + + # Show warning + header "Rebuild Containers" + printf '\n' + printf '%s\n' " ${RED}${BOLD}WARNING: This will destroy and recreate the following containers.${RESET}" + printf '%s\n' " ${RED}Data in /storage volumes will be preserved.${RESET}" + printf '\n' + for v in "${versions[@]}"; do + resolve_target "$v" + printf '%s\n' " • $v (${RESOLVED_DISPLAY_NAME})" + done + printf '\n' + printf '%s' " Type 'yes' to confirm: " + + local confirm + read -r confirm + if [[ "$confirm" != "yes" ]]; then + info "Canceled" + return 0 + fi + + for v in "${versions[@]}"; do + resolve_target "$v" + header "Rebuilding $v" + + # Ensure data directory exists + local data_dir="$SCRIPT_DIR/data/$v" + if [[ ! -d "$data_dir" ]]; then + info "Creating data directory: data/$v" + mkdir -p "$data_dir" + fi + + info "Stopping and removing $v..." + run_compose "$v" down "$v" 2>/dev/null || true + + info "Recreating $v..." + if run_compose "$v" up -d "$v"; then + success "$v rebuilt successfully" + detect_lan_ip + printf '\n' + printf '%s\n' " ${BOLD}Connection Details:${RESET}" + printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${RESOLVED_WEB_PORT}${RESET}" + printf '%s\n' " → RDP: ${CYAN}localhost:${RESOLVED_RDP_PORT}${RESET}" + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " → LAN Web: ${CYAN}http://${LAN_IP}:${RESOLVED_WEB_PORT}${RESET}" + printf '%s\n' " → LAN RDP: ${CYAN}${LAN_IP}:${RESOLVED_RDP_PORT}${RESET}" + fi + printf '\n' + else + error "Failed to rebuild $v" + fi + done + + # Refresh cache after state changes + invalidate_cache +} + +cmd_list() { + local category="${1:-all}" + + detect_arch + header "Available Windows Versions" + + local categories=() + case "$category" in + desktop) categories=("desktop") ;; + legacy) categories=("legacy") ;; + server) categories=("server") ;; + tiny) categories=("tiny") ;; + all) categories=("desktop" "legacy" "server" "tiny") ;; + *) + die "Unknown category: $category. Use: desktop, legacy, server, tiny, or all" + ;; + esac + + for cat in "${categories[@]}"; do + printf '\n' + local cat_upper + cat_upper=$(echo "$cat" | tr '[:lower:]' '[:upper:]') + printf '%s\n' " ${BOLD}${cat_upper}${RESET}" + printf '%s\n' " ${DIM}$(printf '─%.0s' {1..50})${RESET}" + + for v in "${ALL_VERSIONS[@]}"; do + if [[ "${VERSION_CATEGORIES[$v]}" == "$cat" ]]; then + local status="" + if is_running "$v"; then + status="${GREEN}[running]${RESET}" + elif container_exists "$v"; then + status="${YELLOW}[stopped]${RESET}" + fi + local resource_tag + if [[ "${VERSION_RESOURCE_TYPE[$v]}" == "modern" ]]; then + resource_tag="${CYAN}(8G RAM)${RESET}" + else + resource_tag="${DIM}(2G RAM)${RESET}" + fi + local arch_tag="" + if [[ "$DETECTED_ARCH" == "arm64" ]] && ! is_arm_supported "$v"; then + arch_tag="${RED}[x86 only]${RESET}" + fi + printf " %-10s %-28s %s %s %s\n" "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$resource_tag" "$arch_tag" "$status" + fi + done + done + + # Show instances section + load_registry + if [[ ${#_REGISTRY_INSTANCES[@]} -gt 0 ]]; then + printf '\n' + printf '%s\n' " ${BOLD}INSTANCES${RESET}" + printf '%s\n' " ${DIM}$(printf '─%.0s' {1..50})${RESET}" + + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local base status_tag + base=$(registry_get_field "$name" "base") + status_tag="" + if is_running "$name"; then + status_tag="${GREEN}[running]${RESET}" + elif container_exists "$name"; then + status_tag="${YELLOW}[stopped]${RESET}" + fi + local resource_tag + if [[ "${VERSION_RESOURCE_TYPE[$base]}" == "modern" ]]; then + resource_tag="${CYAN}(8G RAM)${RESET}" + else + resource_tag="${DIM}(2G RAM)${RESET}" + fi + printf " %-20s %-18s %s %s\n" "$name" "${VERSION_DISPLAY_NAMES[$base]}" "$resource_tag" "$status_tag" + done + fi + printf '\n' +} + +cmd_inspect() { + local version="${1:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} inspect " + fi + + validate_target "$version" || exit 1 + + resolve_target "$version" + + header "Container Details: $version" + printf '\n' + printf '%s\n' " ${BOLD}Version:${RESET} $version" + printf '%s\n' " ${BOLD}Name:${RESET} ${RESOLVED_DISPLAY_NAME}" + printf '%s\n' " ${BOLD}Type:${RESET} ${RESOLVED_TYPE}" + if [[ "$RESOLVED_TYPE" == "instance" ]]; then + printf '%s\n' " ${BOLD}Base:${RESET} ${RESOLVED_BASE}" + local suffix created + suffix=$(registry_get_field "$version" "suffix") + created=$(registry_get_field "$version" "created") + printf '%s\n' " ${BOLD}Suffix:${RESET} ${suffix}" + printf '%s\n' " ${BOLD}Created:${RESET} ${created}" + fi + printf '%s\n' " ${BOLD}Category:${RESET} ${VERSION_CATEGORIES[$RESOLVED_BASE]}" + printf '%s\n' " ${BOLD}Status:${RESET} $(get_status "$version")" + printf '%s\n' " ${BOLD}Web Port:${RESET} ${RESOLVED_WEB_PORT}" + printf '%s\n' " ${BOLD}RDP Port:${RESET} ${RESOLVED_RDP_PORT}" + printf '%s\n' " ${BOLD}Resources:${RESET} ${VERSION_RESOURCE_TYPE[$RESOLVED_BASE]}" + if [[ "$RESOLVED_TYPE" == "base" ]]; then + printf '%s\n' " ${BOLD}Compose:${RESET} ${VERSION_COMPOSE_FILES[$version]}" + else + printf '%s\n' " ${BOLD}Compose:${RESET} instances/${version}.yml" + fi + printf '%s\n' " ${BOLD}Web URL:${RESET} http://localhost:${RESOLVED_WEB_PORT}" + printf '%s\n' " ${BOLD}RDP:${RESET} localhost:${RESOLVED_RDP_PORT}" + detect_lan_ip + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " ${BOLD}LAN Web:${RESET} http://${LAN_IP}:${RESOLVED_WEB_PORT}" + printf '%s\n' " ${BOLD}LAN RDP:${RESET} ${LAN_IP}:${RESOLVED_RDP_PORT}" + fi + printf '\n' + + if container_exists "$version"; then + printf '%s\n' " ${BOLD}Docker Info:${RESET}" + docker inspect "$version" --format ' + Image: {{.Config.Image}} + Created: {{.Created}} + IP Address: {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} + Mounts: {{range .Mounts}}{{.Source}} -> {{.Destination}} + {{end}}' 2>/dev/null || true + fi + printf '\n' +} + +cmd_monitor() { + local interval="${1:-5}" + + if ! [[ "$interval" =~ ^[0-9]+$ ]]; then + die "Interval must be a number (seconds)" + fi + + header "Real-time Monitor (refresh: ${interval}s)" + printf '%s\n' " Press Ctrl+C to exit" + printf '\n' + + while true; do + # Refresh cache for accurate status + invalidate_cache + + clear + printf '%s\n' "${BOLD}${CYAN}Windows Container Monitor${RESET} - $(date '+%Y-%m-%d %H:%M:%S')" + printf '%s\n' "${DIM}$(printf '─%.0s' {1..70})${RESET}" + + local running_count=0 + local stopped_count=0 + local total_count=0 + + table_header + + for v in "${ALL_VERSIONS[@]}"; do + local status + status=$(get_status "$v") + if [[ "$status" != "not created" ]]; then + ((++total_count)) + if [[ "$status" == "running" ]]; then + ((++running_count)) + else + ((++stopped_count)) + fi + table_row "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" "${VERSION_PORTS_WEB[$v]}" "${VERSION_PORTS_RDP[$v]}" + fi + done + + # Also show instances + load_registry + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local status + status=$(get_status "$name") + if [[ "$status" != "not created" ]]; then + resolve_target "$name" + ((++total_count)) + if [[ "$status" == "running" ]]; then + ((++running_count)) + else + ((++stopped_count)) + fi + table_row "$name" "$RESOLVED_DISPLAY_NAME" "$status" "$RESOLVED_WEB_PORT" "$RESOLVED_RDP_PORT" + fi + done + + if [[ $total_count -eq 0 ]]; then + printf '%s\n' " ${DIM}No containers found${RESET}" + fi + + printf '\n' + printf '%s\n' " ${BOLD}Summary:${RESET} ${GREEN}$running_count running${RESET}, ${RED}$stopped_count stopped${RESET}, $total_count total" + printf '\n' + printf '%s\n' " ${DIM}Refreshing in ${interval}s... (Ctrl+C to exit)${RESET}" + + sleep "$interval" + done +} + +cmd_check() { + detect_arch + run_all_checks + printf '%s\n' " ${BOLD}Architecture:${RESET} ${DETECTED_ARCH}" + if [[ "$DETECTED_ARCH" == "arm64" ]]; then + printf '%s\n' " ${BOLD}ARM64 image:${RESET} dockurr/windows-arm" + printf '%s\n' " ${BOLD}Supported:${RESET} ${ARM_VERSIONS[*]}" + fi + detect_lan_ip + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " ${BOLD}LAN IP:${RESET} ${LAN_IP}" + fi + printf '\n' +} + +cmd_refresh() { + header "Refreshing Status Cache" + + info "Fetching container statuses from Docker..." + refresh_status_cache true + + local count=${#_STATUS_CACHE[@]} + success "Cache refreshed (${count} containers found)" + + # Show cache info + local age + age=$(get_cache_age) + printf '\n' + printf '%s\n' " ${BOLD}Cache Info:${RESET}" + printf '%s\n' " → File: ${CYAN}${CACHE_FILE}${RESET}" + printf '%s\n' " → Age: ${age} seconds" + printf '%s\n' " → Max Age: ${CACHE_MAX_AGE} seconds (7 days)" + printf '\n' + + # Show summary + local cnt_running=0 cnt_stopped=0 cnt_other=0 + for state in "${_STATUS_CACHE[@]}"; do + case "$state" in + running) ((cnt_running++)) || true ;; + exited) ((cnt_stopped++)) || true ;; + *) ((cnt_other++)) || true ;; + esac + done + printf '%s\n' " ${BOLD}Containers:${RESET} ${GREEN}${cnt_running} running${RESET}, ${RED}${cnt_stopped} stopped${RESET}, ${DIM}${cnt_other} other${RESET}" + printf '\n' +} + +# ============================================================================== +# PORT CHECK HELPER +# ============================================================================== + +check_port() { + local port="$1" + if ss -tlnp 2>/dev/null | grep -q ":${port} "; then + return 1 # port in use + fi + return 0 +} + +# ============================================================================== +# INSTANCE PORT ALLOCATION & COMPOSE GENERATION +# ============================================================================== + +# Allocate ports for a new instance, echoes "web_port rdp_port" +allocate_instance_ports() { + load_registry + + # Collect all used ports + local -A used_ports=() + for v in "${ALL_VERSIONS[@]}"; do + used_ports["${VERSION_PORTS_WEB[$v]}"]=1 + used_ports["${VERSION_PORTS_RDP[$v]}"]=1 + done + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local wp rp + wp=$(registry_get_field "$name" "web_port") + rp=$(registry_get_field "$name" "rdp_port") + used_ports["$wp"]=1 + used_ports["$rp"]=1 + done + + # Find free web port + local web_port="" + local max_web=$((INSTANCE_WEB_PORT_BASE + INSTANCE_PORT_RANGE)) + local p + for ((p=INSTANCE_WEB_PORT_BASE; p<=max_web; p++)); do + if [[ -z "${used_ports[$p]:-}" ]] && check_port "$p"; then + web_port=$p + break + fi + done + if [[ -z "$web_port" ]]; then + die "No free web ports in range ${INSTANCE_WEB_PORT_BASE}-${max_web}" + fi + + # Find free RDP port + local rdp_port="" + local max_rdp=$((INSTANCE_RDP_PORT_BASE + INSTANCE_PORT_RANGE)) + for ((p=INSTANCE_RDP_PORT_BASE; p<=max_rdp; p++)); do + if [[ -z "${used_ports[$p]:-}" ]] && check_port "$p"; then + rdp_port=$p + break + fi + done + if [[ -z "$rdp_port" ]]; then + die "No free RDP ports in range ${INSTANCE_RDP_PORT_BASE}-${max_rdp}" + fi + + echo "$web_port $rdp_port" +} + +# Get the next numeric suffix for a base version +next_instance_suffix() { + local base="$1" + load_registry + + local max=0 + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local entry_base + entry_base=$(registry_get_field "$name" "base") + if [[ "$entry_base" == "$base" ]]; then + local entry_suffix + entry_suffix=$(registry_get_field "$name" "suffix") + if [[ "$entry_suffix" =~ ^[0-9]+$ ]] && ((entry_suffix > max)); then + max=$entry_suffix + fi + fi + done + echo $((max + 1)) +} + +# Generate a compose file for an instance +generate_instance_compose() { + local name="$1" base="$2" web_port="$3" rdp_port="$4" + + ensure_instance_dir + + local env_file="${VERSION_ENV_FILES[$base]}" + local version_val="${VERSION_ENV_VALUES[$base]}" + local compose_file="$INSTANCE_DIR/${name}.yml" + + cat > "$compose_file" << YAML +--- +services: + ${name}: + image: \${WINDOWS_IMAGE:-dockurr/windows} + container_name: ${name} + env_file: ../${env_file} + environment: + VERSION: "${version_val}" + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + ports: + - ${web_port}:8006 + - ${rdp_port}:3389/tcp + - ${rdp_port}:3389/udp + volumes: + - ../data/${name}:/storage + restart: \${RESTART_POLICY:-on-failure} + stop_grace_period: 2m +YAML +} + +# ============================================================================== +# SNAPSHOT COMMAND +# ============================================================================== + +cmd_snapshot() { + local version="${1:-}" + local name="${2:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} snapshot [name]" + fi + + validate_target "$version" || exit 1 + resolve_target "$version" + + # Skip if container was never created + refresh_status_cache + if ! container_exists "$RESOLVED_NAME"; then + die "$RESOLVED_NAME was never created — nothing to snapshot" + fi + + local data_dir="$SCRIPT_DIR/data/$RESOLVED_NAME" + if [[ ! -d "$data_dir" ]] || [[ -z "$(ls -A "$data_dir" 2>/dev/null)" ]]; then + die "No data found for $RESOLVED_NAME (data/$RESOLVED_NAME/ is empty or missing)" + fi + + # Default name: timestamp + if [[ -z "$name" ]]; then + name=$(date +%Y%m%d-%H%M%S) + fi + + local snap_dir="$SCRIPT_DIR/snapshots/$RESOLVED_NAME/$name" + if [[ -d "$snap_dir" ]]; then + die "Snapshot already exists: snapshots/$RESOLVED_NAME/$name" + fi + + header "Snapshot: ${RESOLVED_DISPLAY_NAME} ($RESOLVED_NAME)" + + # Stop container if running (remember to restart) + local was_running=false + if is_running "$RESOLVED_NAME"; then + was_running=true + info "Stopping $RESOLVED_NAME for snapshot..." + run_compose "$RESOLVED_NAME" stop "$RESOLVED_NAME" + fi + + info "Creating snapshot: snapshots/$RESOLVED_NAME/$name" + mkdir -p "$snap_dir" + if cp -a "$data_dir/." "$snap_dir/"; then + local size + size=$(du -sh "$snap_dir" | awk '{print $1}') + success "Snapshot created successfully" + printf '\n' + printf '%s\n' " ${BOLD}Path:${RESET} snapshots/$RESOLVED_NAME/$name" + printf '%s\n' " ${BOLD}Size:${RESET} $size" + printf '\n' + else + error "Failed to create snapshot" + # Clean up partial snapshot + rm -rf "$snap_dir" + fi + + # Restart if was running + if [[ "$was_running" == "true" ]]; then + info "Restarting $RESOLVED_NAME..." + run_compose "$RESOLVED_NAME" up -d "$RESOLVED_NAME" + invalidate_cache + fi +} + +# ============================================================================== +# RESTORE COMMAND +# ============================================================================== + +cmd_restore() { + local version="${1:-}" + local name="${2:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} restore [name]" + fi + + validate_target "$version" || exit 1 + resolve_target "$version" + + # Skip if container was never created + refresh_status_cache + if ! container_exists "$RESOLVED_NAME"; then + die "$RESOLVED_NAME was never created — nothing to restore" + fi + + local snap_base="$SCRIPT_DIR/snapshots/$RESOLVED_NAME" + if [[ ! -d "$snap_base" ]]; then + die "No snapshots found for $RESOLVED_NAME" + fi + + # If no name: list available snapshots and let user pick + if [[ -z "$name" ]]; then + local snapshots=() + while IFS= read -r d; do + snapshots+=("$(basename "$d")") + done < <(find "$snap_base" -mindepth 1 -maxdepth 1 -type d | sort) + + if [[ ${#snapshots[@]} -eq 0 ]]; then + die "No snapshots found for $RESOLVED_NAME" + fi + + header "Available Snapshots for $RESOLVED_NAME" + printf '\n' + local i=1 + for s in "${snapshots[@]}"; do + local size + size=$(du -sh "$snap_base/$s" | awk '{print $1}') + printf ' %s) %-24s %s\n' "$i" "$s" "${DIM}($size)${RESET}" + ((i++)) + done + printf '\n' + printf ' Select snapshot [1-%d]: ' "${#snapshots[@]}" + + local choice + read -r choice + if [[ ! "$choice" =~ ^[0-9]+$ ]] || ((choice < 1 || choice > ${#snapshots[@]})); then + die "Invalid selection" + fi + name="${snapshots[$((choice-1))]}" + fi + + local snap_dir="$snap_base/$name" + if [[ ! -d "$snap_dir" ]]; then + die "Snapshot not found: snapshots/$RESOLVED_NAME/$name" + fi + + header "Restore: ${RESOLVED_DISPLAY_NAME} ($RESOLVED_NAME)" + printf '\n' + printf '%s\n' " ${RED}${BOLD}WARNING: This will replace current data for $RESOLVED_NAME.${RESET}" + printf '%s\n' " ${RED}Restoring from: snapshots/$RESOLVED_NAME/$name${RESET}" + printf '\n' + printf '%s' " Type 'yes' to confirm: " + + local confirm + read -r confirm + if [[ "$confirm" != "yes" ]]; then + info "Canceled" + return 0 + fi + + # Stop container if running (remember to restart) + local was_running=false + if is_running "$RESOLVED_NAME"; then + was_running=true + info "Stopping $RESOLVED_NAME for restore..." + run_compose "$RESOLVED_NAME" stop "$RESOLVED_NAME" + fi + + local data_dir="$SCRIPT_DIR/data/$RESOLVED_NAME" + info "Restoring data from snapshot..." + mkdir -p "$data_dir" + rm -rf "${data_dir:?}/"* + if cp -a "$snap_dir/." "$data_dir/"; then + success "Data restored successfully from snapshots/$RESOLVED_NAME/$name" + else + error "Failed to restore data" + fi + + # Restart if was running + if [[ "$was_running" == "true" ]]; then + info "Restarting $RESOLVED_NAME..." + run_compose "$RESOLVED_NAME" up -d "$RESOLVED_NAME" + invalidate_cache + fi +} + +# ============================================================================== +# PULL COMMAND +# ============================================================================== + +cmd_pull() { + local versions=("$@") + + detect_arch + local image="dockurr/windows" + if [[ "$DETECTED_ARCH" == "arm64" ]]; then + image="dockurr/windows-arm" + fi + + header "Pull Docker Image" + + info "Image: $image" + + # Get current digest before pull + local digest_before + digest_before=$(docker inspect --format='{{index .RepoDigests 0}}' "$image" 2>/dev/null || echo "none") + + info "Pulling latest image..." + if docker pull "$image"; then + local digest_after + digest_after=$(docker inspect --format='{{index .RepoDigests 0}}' "$image" 2>/dev/null || echo "none") + if [[ "$digest_before" == "$digest_after" ]]; then + success "Image is already up to date" + else + success "Image updated" + fi + else + error "Failed to pull image" + fi + printf '\n' +} + +# ============================================================================== +# DISK COMMAND +# ============================================================================== + +cmd_disk() { + local versions=("$@") + + header "Disk Usage" + + local data_base="$SCRIPT_DIR/data" + if [[ ! -d "$data_base" ]]; then + info "No data directory found" + return 0 + fi + + # If no versions specified, scan all data directories + if [[ ${#versions[@]} -eq 0 ]]; then + for v in "${ALL_VERSIONS[@]}"; do + if [[ -d "$data_base/$v" ]]; then + versions+=("$v") + fi + done + # Also include instance data dirs + load_registry + for name in "${!_REGISTRY_INSTANCES[@]}"; do + if [[ -d "$data_base/$name" ]]; then + versions+=("$name") + fi + done + fi + + if [[ ${#versions[@]} -eq 0 ]]; then + info "No VM data directories found" + return 0 + fi + + # Refresh cache for status info + refresh_status_cache + + printf '\n' + printf " %s%-20s %-12s %-10s%s\n" "${BOLD}${DIM}" "VERSION" "SIZE" "STATUS" "${RESET}" + printf '%s\n' " ${DIM}$(printf '─%.0s' {1..44})${RESET}" + + for v in "${versions[@]}"; do + if [[ -d "$data_base/$v" ]]; then + local size status + size=$(du -sh "$data_base/$v" 2>/dev/null | awk '{print $1}') + status=$(get_status "$v") + local status_color + case "$status" in + running) status_color="${GREEN}" ;; + stopped|exited) status_color="${RED}" ;; + *) status_color="${YELLOW}" ;; + esac + printf " %-20s %-12s %s%-10s%s\n" "$v" "$size" "$status_color" "$status" "${RESET}" + fi + done + + printf '%s\n' " ${DIM}$(printf '─%.0s' {1..44})${RESET}" + + # Total data usage + local total + total=$(du -sh "$data_base" 2>/dev/null | awk '{print $1}') + printf " %-20s %s\n" "Total:" "$total" + + # Snapshots usage + local snap_base="$SCRIPT_DIR/snapshots" + if [[ -d "$snap_base" ]]; then + local snap_total snap_count + snap_total=$(du -sh "$snap_base" 2>/dev/null | awk '{print $1}') + snap_count=$(find "$snap_base" -mindepth 2 -maxdepth 2 -type d 2>/dev/null | wc -l) + printf '\n' + printf " Snapshots: %s (%d snapshot%s)\n" "$snap_total" "$snap_count" "$( ((snap_count != 1)) && echo "s" )" + + # Per-version snapshot breakdown + for v in "${ALL_VERSIONS[@]}"; do + if [[ -d "$snap_base/$v" ]]; then + local vsnap_size vsnap_count + vsnap_size=$(du -sh "$snap_base/$v" 2>/dev/null | awk '{print $1}') + vsnap_count=$(find "$snap_base/$v" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) + printf " %-10s %s (%d snapshot%s)\n" "$v" "$vsnap_size" "$vsnap_count" "$( ((vsnap_count != 1)) && echo "s" )" + fi + done + # Instance snapshot breakdown + load_registry + for name in "${!_REGISTRY_INSTANCES[@]}"; do + if [[ -d "$snap_base/$name" ]]; then + local vsnap_size vsnap_count + vsnap_size=$(du -sh "$snap_base/$name" 2>/dev/null | awk '{print $1}') + vsnap_count=$(find "$snap_base/$name" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l) + printf " %-18s %s (%d snapshot%s)\n" "$name" "$vsnap_size" "$vsnap_count" "$( ((vsnap_count != 1)) && echo "s" )" + fi + done + fi + printf '\n' +} + +# ============================================================================== +# CLEAN COMMAND +# ============================================================================== + +cmd_clean() { + local purge_data=false + if [[ "${1:-}" == "--data" ]]; then + purge_data=true + fi + + header "Clean Stopped Containers" + + refresh_status_cache + + # Find stopped containers (base + instances) + local stopped=() + for v in "${ALL_VERSIONS[@]}"; do + local status + status=$(get_status "$v") + if [[ "$status" == "exited" || "$status" == "stopped" ]]; then + stopped+=("$v") + fi + done + load_registry + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local status + status=$(get_status "$name") + if [[ "$status" == "exited" || "$status" == "stopped" ]]; then + stopped+=("$name") + fi + done + + if [[ ${#stopped[@]} -eq 0 ]]; then + info "No stopped containers found" + return 0 + fi + + printf '\n' + printf '%s\n' " The following stopped containers will be removed:" + for v in "${stopped[@]}"; do + resolve_target "$v" + printf '%s\n' " • $v (${RESOLVED_DISPLAY_NAME})" + done + + if [[ "$purge_data" == "true" ]]; then + printf '\n' + printf '%s\n' " ${RED}${BOLD}Data directories will also be deleted:${RESET}" + for v in "${stopped[@]}"; do + if [[ -d "$SCRIPT_DIR/data/$v" ]]; then + local size + size=$(du -sh "$SCRIPT_DIR/data/$v" 2>/dev/null | awk '{print $1}') + printf '%s\n' " ${RED}• data/$v/ ($size)${RESET}" + fi + done + fi + + printf '\n' + printf '%s' " Type 'yes' to confirm: " + + local confirm + read -r confirm + if [[ "$confirm" != "yes" ]]; then + info "Canceled" + return 0 + fi + + local freed_before + freed_before=$(df "$SCRIPT_DIR" | tail -1 | awk '{print $4}') + + for v in "${stopped[@]}"; do + info "Removing $v..." + run_compose "$v" down "$v" 2>/dev/null || true + if [[ "$purge_data" == "true" && -d "$SCRIPT_DIR/data/$v" ]]; then + rm -rf "${SCRIPT_DIR:?}/data/$v" + info "Deleted data/$v/" + fi + # Unregister instances and remove their compose files + if is_instance "$v"; then + rm -f "$INSTANCE_DIR/${v}.yml" + unregister_instance "$v" + info "Unregistered instance $v" + fi + done + + local freed_after + freed_after=$(df "$SCRIPT_DIR" | tail -1 | awk '{print $4}') + local freed_kb=$((freed_after - freed_before)) + local freed_mb=$((freed_kb / 1024)) + + printf '\n' + success "Cleaned ${#stopped[@]} container(s)" + if ((freed_mb > 0)); then + printf '%s\n' " ${BOLD}Freed:${RESET} ${freed_mb}MB" + fi + printf '\n' + + invalidate_cache +} + +# ============================================================================== +# OPEN COMMAND +# ============================================================================== + +cmd_open() { + local version="${1:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} open " + fi + + validate_target "$version" || exit 1 + resolve_target "$version" + + # Start container if not running + if ! is_running "$version"; then + printf '%s' "${YELLOW}[WARN]${RESET} $version is not running. Start it? [y/N]: " + local confirm + read -r confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + cmd_start "$version" + else + die "$version is not running" + fi + fi + + local url="http://localhost:${RESOLVED_WEB_PORT}" + + # Detect browser opener + local opener="" + if command -v xdg-open &>/dev/null; then + opener="xdg-open" + elif command -v open &>/dev/null; then + opener="open" + else + info "Could not detect browser opener" + info "Open manually: $url" + return 0 + fi + + info "Opening $url ..." + "$opener" "$url" &>/dev/null & + + detect_lan_ip + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " ${DIM}LAN: http://${LAN_IP}:${RESOLVED_WEB_PORT}${RESET}" + fi +} + +# ============================================================================== +# INSTANCE COMMANDS +# ============================================================================== + +cmd_new() { + local version="$1" + local suffix="${2:-}" + local clone="${3:-false}" + + validate_version "$version" || exit 1 + + # Determine instance name + if [[ -z "$suffix" ]]; then + suffix=$(next_instance_suffix "$version") + fi + local instance_name="${version}-${suffix}" + + # Check not already registered + load_registry + if [[ -n "${_REGISTRY_INSTANCES[$instance_name]:-}" ]]; then + die "Instance '$instance_name' already exists" + fi + + # Check ARM compatibility + detect_arch + if [[ "$DETECTED_ARCH" == "arm64" ]]; then + if ! is_arm_supported "$version"; then + die "${VERSION_DISPLAY_NAMES[$version]} ($version) is not supported on ARM64. Supported: ${ARM_VERSIONS[*]}" + fi + fi + + # Run prerequisite checks + check_docker || exit 1 + check_kvm || exit 1 + + header "Creating Instance: $instance_name" + + # Allocate ports + local ports + ports=$(allocate_instance_ports) + local web_port rdp_port + read -r web_port rdp_port <<< "$ports" + + info "Allocated ports — Web: $web_port, RDP: $rdp_port" + + # Generate compose file + generate_instance_compose "$instance_name" "$version" "$web_port" "$rdp_port" + info "Generated compose file: instances/${instance_name}.yml" + + # Create data directory + local data_dir="$SCRIPT_DIR/data/$instance_name" + mkdir -p "$data_dir" + + # Clone data from base if requested + if [[ "$clone" == "true" ]]; then + local base_data="$SCRIPT_DIR/data/$version" + if [[ ! -d "$base_data" ]] || [[ -z "$(ls -A "$base_data" 2>/dev/null)" ]]; then + warn "No data found for base $version to clone (data/$version/ is empty or missing)" + else + local was_running=false + if is_running "$version"; then + was_running=true + info "Stopping base $version for cloning..." + run_compose "$version" stop "$version" + fi + + info "Cloning data from $version to $instance_name..." + if cp -a "$base_data/." "$data_dir/"; then + success "Data cloned successfully" + else + error "Failed to clone data" + fi + + if [[ "$was_running" == "true" ]]; then + info "Restarting base $version..." + run_compose "$version" up -d "$version" + fi + fi + else + # Pre-populate from ISO cache if available (skip if cloning) + local cache_src="$ISO_CACHE_DIR/$version" + if [[ -d "$cache_src" ]]; then + local cached_iso + cached_iso=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f -print -quit 2>/dev/null || true) + if [[ -n "$cached_iso" ]]; then + local iso_name + iso_name=$(basename "$cached_iso") + info "Restoring cached ISO: $iso_name..." + cp "$cached_iso" "$data_dir/$iso_name" + success "ISO restored from cache (skipping download)" + fi + fi + fi + + # Register instance + register_instance "$instance_name" "$version" "$suffix" "$web_port" "$rdp_port" + success "Instance registered" + + # Check resources + local resource_type="${VERSION_RESOURCE_TYPE[$version]}" + if [[ "$resource_type" == "modern" ]]; then + check_memory "$MODERN_RAM_GB" || true + check_disk "$MODERN_DISK_GB" || true + else + check_memory "$LEGACY_RAM_GB" || true + check_disk "$LEGACY_DISK_GB" || true + fi + + # Check ports are available + if ! check_port "$web_port"; then + error "Web port $web_port is already in use" + return 1 + fi + if ! check_port "$rdp_port"; then + error "RDP port $rdp_port is already in use" + return 1 + fi + + # Start the instance + info "Starting $instance_name..." + if run_compose "$instance_name" up -d "$instance_name"; then + success "$instance_name started successfully" + else + error "Failed to start $instance_name" + return 1 + fi + + # Show connection info + detect_lan_ip + printf '\n' + printf '%s\n' " ${BOLD}Instance:${RESET} $instance_name" + printf '%s\n' " ${BOLD}Base:${RESET} ${VERSION_DISPLAY_NAMES[$version]}" + printf '\n' + printf '%s\n' " ${BOLD}Connection Details:${RESET}" + printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${web_port}${RESET}" + printf '%s\n' " → RDP: ${CYAN}localhost:${rdp_port}${RESET}" + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " → LAN Web: ${CYAN}http://${LAN_IP}:${web_port}${RESET}" + printf '%s\n' " → LAN RDP: ${CYAN}${LAN_IP}:${rdp_port}${RESET}" + fi + printf '\n' + + invalidate_cache +} + +cmd_destroy() { + local instance="${1:-}" + + if [[ -z "$instance" ]]; then + die "Usage: ${SCRIPT_NAME} destroy " + fi + + load_registry + if [[ -z "${_REGISTRY_INSTANCES[$instance]:-}" ]]; then + die "'$instance' is not a registered instance. Use '${SCRIPT_NAME} instances' to list instances." + fi + + local base + base=$(registry_get_field "$instance" "base") + + header "Destroy Instance: $instance" + printf '\n' + printf '%s\n' " ${RED}${BOLD}WARNING: This will permanently remove instance '$instance'.${RESET}" + printf '%s\n' " ${RED}Base version: ${VERSION_DISPLAY_NAMES[$base]}${RESET}" + printf '\n' + printf '%s' " Type 'yes' to confirm: " + + local confirm + read -r confirm + if [[ "$confirm" != "yes" ]]; then + info "Canceled" + return 0 + fi + + # Stop and remove container + info "Stopping and removing $instance..." + run_compose "$instance" down "$instance" 2>/dev/null || true + + # Delete compose file + local compose_file="$INSTANCE_DIR/${instance}.yml" + if [[ -f "$compose_file" ]]; then + rm -f "$compose_file" + info "Removed compose file" + fi + + # Prompt to delete data directory + local data_dir="$SCRIPT_DIR/data/$instance" + if [[ -d "$data_dir" ]]; then + local size + size=$(du -sh "$data_dir" 2>/dev/null | awk '{print $1}') + printf '\n' + printf '%s' " Delete data directory data/$instance/ ($size)? [y/N]: " + local del_data + read -r del_data + if [[ "$del_data" =~ ^[Yy]$ ]]; then + rm -rf "$data_dir" + info "Deleted data/$instance/" + else + info "Data directory preserved at data/$instance/" + fi + fi + + # Unregister + unregister_instance "$instance" + success "Instance '$instance' destroyed" + + invalidate_cache +} + +cmd_instances() { + local filter_base="${1:-}" + + load_registry + + if [[ ${#_REGISTRY_INSTANCES[@]} -eq 0 ]]; then + info "No instances registered" + printf '%s\n' " Create one with: ${SCRIPT_NAME} start --new [name]" + return 0 + fi + + header "Instances" + + # Refresh cache for status info + refresh_status_cache + + printf '\n' + printf " %s%-20s %-10s %-10s %-8s %-8s %-20s%s\n" \ + "${BOLD}${DIM}" "INSTANCE" "BASE" "STATUS" "WEB" "RDP" "CREATED" "${RESET}" + printf '%s\n' " ${DIM}$(printf '─%.0s' {1..78})${RESET}" + + for name in "${!_REGISTRY_INSTANCES[@]}"; do + local base web_port rdp_port created + base=$(registry_get_field "$name" "base") + web_port=$(registry_get_field "$name" "web_port") + rdp_port=$(registry_get_field "$name" "rdp_port") + created=$(registry_get_field "$name" "created") + + # Filter by base version if specified + if [[ -n "$filter_base" && "$base" != "$filter_base" ]]; then + continue + fi + + local status + status=$(get_status "$name") + + local status_color + case "$status" in + running) status_color="${GREEN}" ;; + stopped|exited) status_color="${RED}" ;; + *) status_color="${YELLOW}" ;; + esac + + # Warn if compose file is missing + local compose_file="$INSTANCE_DIR/${name}.yml" + local orphan="" + if [[ ! -f "$compose_file" ]]; then + orphan=" ${RED}[orphaned]${RESET}" + fi + + # Format created date (show date part only) + local created_short="${created%%T*}" + + printf " %-20s %-10s %s%-10s%s %-8s %-8s %-20s%b\n" \ + "$name" "$base" "$status_color" "$status" "${RESET}" "$web_port" "$rdp_port" "$created_short" "$orphan" + done + printf '\n' + + detect_lan_ip + if [[ -n "$LAN_IP" ]]; then + printf '%s\n' " ${DIM}LAN IP: ${LAN_IP} — use http://${LAN_IP}: for remote access${RESET}" + printf '\n' + fi +} + +# ============================================================================== +# ISO CACHE +# ============================================================================== + +# Check if an ISO has been rebuilt by the container (magic byte 0x16 at offset 0). +# Rebuilt ISOs cannot be re-processed by the container's install pipeline (7z +# fails on the duplicate boot catalog entry created by genisoimage). +_is_rebuilt_iso() { + local iso="$1" + local magic + magic=$(dd if="$iso" bs=1 count=1 status=none 2>/dev/null | od -A n -t x1 -v | tr -d ' \n') + [[ "$magic" == "16" ]] +} + +# Non-interactive cache save — silently skips rebuilt ISOs and already-cached files. +# Used by cmd_stop() when AUTO_CACHE=Y. +auto_cache_save() { + local target="$1" + resolve_target "$target" || return 0 + + local data_dir="$SCRIPT_DIR/data/$RESOLVED_NAME" + local iso_files + iso_files=$(find "$data_dir" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + [[ -z "$iso_files" ]] && return 0 + + local cache_dest="$ISO_CACHE_DIR/$RESOLVED_BASE" + mkdir -p "$cache_dest" + + while IFS= read -r iso; do + local filename + filename=$(basename "$iso") + # Skip rebuilt ISOs — they can't be re-processed by the container + _is_rebuilt_iso "$iso" && continue + if [[ ! -f "$cache_dest/$filename" ]]; then + cp "$iso" "$cache_dest/$filename" + info "Auto-cached ISO: $filename" + fi + done <<< "$iso_files" +} + +cmd_cache() { + local subcmd="${1:-}" + shift || true + + case "$subcmd" in + save) cmd_cache_save "$@" ;; + download) cmd_cache_download "$@" ;; + list) cmd_cache_list ;; + rm) cmd_cache_rm "$@" ;; + flush) cmd_cache_flush ;; + "") + error "Missing subcommand" + printf '%s\n' " Usage: ${SCRIPT_NAME} cache " + exit 1 + ;; + *) + error "Unknown cache subcommand: $subcmd" + printf '%s\n' " Usage: ${SCRIPT_NAME} cache " + exit 1 + ;; + esac +} + +cmd_cache_save() { + local target="${1:-}" + + if [[ -z "$target" ]]; then + die "Usage: ${SCRIPT_NAME} cache save " + fi + + validate_target "$target" || exit 1 + resolve_target "$target" + + # Check container was created (same guard as snapshot) + refresh_status_cache + if ! container_exists "$RESOLVED_NAME"; then + die "$RESOLVED_NAME was never created — nothing to cache" + fi + + local data_dir="$SCRIPT_DIR/data/$RESOLVED_NAME" + local iso_files + iso_files=$(find "$data_dir" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + + if [[ -z "$iso_files" ]]; then + die "No ISO files found in data/$RESOLVED_NAME/" + fi + + local cache_dest="$ISO_CACHE_DIR/$RESOLVED_BASE" + mkdir -p "$cache_dest" + + header "Cache Save: ${RESOLVED_DISPLAY_NAME}" + + local count=0 + local rebuilt=false + while IFS= read -r iso; do + local filename + filename=$(basename "$iso") + if _is_rebuilt_iso "$iso"; then + warn "Skipping $filename — rebuilt ISO (cannot be re-processed)" + rebuilt=true + continue + fi + if [[ -f "$cache_dest/$filename" ]]; then + info "Already cached: $filename" + else + info "Caching $filename..." + cp "$iso" "$cache_dest/$filename" + success "Cached $filename" + fi + local size + size=$(du -h "$cache_dest/$filename" | awk '{print $1}') + printf '%s\n' " Size: $size" + ((count++)) + done <<< "$iso_files" + + printf '\n' + if (( count > 0 )); then + success "Cached $count ISO file(s) to cache/$RESOLVED_BASE/" + fi + if [[ "$rebuilt" == true ]]; then + warn "Some ISOs were skipped because they were rebuilt by the container." + info "Use '${SCRIPT_NAME} cache download $RESOLVED_BASE' to download the original ISO." + fi + printf '\n' +} + +cmd_cache_download() { + local target="${1:-}" + if [[ -z "$target" ]]; then + error "Missing version" + printf '%s\n' " Usage: ${SCRIPT_NAME} cache download " + exit 1 + fi + + # Resolve the base version name + local base="$target" + if [[ -v VERSION_ENV_VALUES[$target] ]]; then + base="$target" + else + # Try to find a matching version key + local found="" + for k in "${!VERSION_DISPLAY_NAMES[@]}"; do + if [[ "$k" == "$target" ]]; then + found="$k" + break + fi + done + [[ -n "$found" ]] && base="$found" + fi + + # Use the container to resolve the download URL and filename + local windows_image + local resource_type="${VERSION_RESOURCE_TYPE[$base]:-modern}" + if [[ "$resource_type" == "legacy" ]]; then + windows_image=$(grep -E '^WINDOWS_IMAGE=' "$SCRIPT_DIR/.env.legacy" 2>/dev/null | tail -1 | cut -d'=' -f2- || true) + else + windows_image=$(grep -E '^WINDOWS_IMAGE=' "$SCRIPT_DIR/.env.modern" 2>/dev/null | tail -1 | cut -d'=' -f2- || true) + fi + windows_image="${windows_image:-dockurr/windows}" + + local version_env="${VERSION_ENV_VALUES[$base]:-$base}" + + header "Cache Download: ${VERSION_DISPLAY_NAMES[$base]:-$base}" + + info "Resolving download URL..." + local iso_filename + iso_filename=$(docker run --rm --entrypoint="" -e "VERSION=$version_env" "$windows_image" bash -c ' + set +eu + APP="Windows" + source /run/utils.sh 2>/dev/null + source /run/define.sh 2>/dev/null + parseVersion 2>/dev/null + echo "${VERSION//\//}.iso" + ' 2>/dev/null || true) + + if [[ -z "$iso_filename" ]]; then + error "Could not resolve ISO filename for $base" + exit 1 + fi + + local cache_dest="$ISO_CACHE_DIR/$base" + mkdir -p "$cache_dest" + + if [[ -f "$cache_dest/$iso_filename" ]] && ! _is_rebuilt_iso "$cache_dest/$iso_filename"; then + info "Already cached: $iso_filename" + local size + size=$(du -h "$cache_dest/$iso_filename" | awk '{print $1}') + printf '%s\n' " Size: $size" + printf '\n' + return 0 + fi + + # Run the container briefly to download the ISO, then copy from its tmp dir + local tmp_dir + tmp_dir=$(mktemp -d) + + info "Downloading original ISO (this may take a while)..." + info "Using container to download ${VERSION_DISPLAY_NAMES[$base]:-$base}..." + + # Run container with storage mounted, let it download, then grab from tmp + # The container downloads to /storage/tmp/.iso, so we watch for it + local container_name="winctl-download-$$" + docker run -d --rm --entrypoint="" \ + --name "$container_name" \ + -e "VERSION=$version_env" \ + -v "$tmp_dir:/storage" \ + "$windows_image" bash -c ' + set +eu + APP="Windows" + STORAGE="/storage" + source /run/utils.sh 2>/dev/null + source /run/define.sh 2>/dev/null + source /run/mido.sh 2>/dev/null + source /run/install.sh 2>/dev/null + parseVersion 2>/dev/null + BOOT="$STORAGE/${VERSION//\//}.iso" + TMP="$STORAGE/tmp" + mkdir -p "$TMP" + ISO=$(basename "$BOOT") + ISO="$TMP/$ISO" + if [ -f "$BOOT" ] && [ -s "$BOOT" ]; then + mv -f "$BOOT" "$ISO" + fi + if [ ! -s "$ISO" ] || [ ! -f "$ISO" ]; then + downloadImage "$ISO" "$VERSION" "en" || exit 1 + fi + # Signal completion — move ISO out of tmp to storage root + cp "$ISO" "$BOOT" + ' > /dev/null 2>&1 + + # Wait for the download container to finish + docker wait "$container_name" > /dev/null 2>&1 + + # Check if the ISO was downloaded + local downloaded_iso + downloaded_iso=$(find "$tmp_dir" -maxdepth 1 -name '*.iso' -type f -print -quit 2>/dev/null || true) + + if [[ -z "$downloaded_iso" ]] || [[ ! -s "$downloaded_iso" ]]; then + rm -rf "$tmp_dir" + error "Failed to download ISO for $base" + exit 1 + fi + + local dl_name + dl_name=$(basename "$downloaded_iso") + cp "$downloaded_iso" "$cache_dest/$dl_name" + rm -rf "$tmp_dir" + + local size + size=$(du -h "$cache_dest/$dl_name" | awk '{print $1}') + + printf '\n' + success "Downloaded and cached: $dl_name ($size)" + printf '\n' +} + +cmd_cache_list() { + if [[ ! -d "$ISO_CACHE_DIR" ]] || [[ -z "$(ls -A "$ISO_CACHE_DIR" 2>/dev/null)" ]]; then + info "No cached ISOs" + printf '%s\n' " Cache ISOs with: ${SCRIPT_NAME} cache save " + return 0 + fi + + header "ISO Cache" + + local total_size=0 + local found=false + + for dir in "$ISO_CACHE_DIR"/*/; do + [[ -d "$dir" ]] || continue + local base + base=$(basename "$dir") + local display="${VERSION_DISPLAY_NAMES[$base]:-$base}" + + local iso_files + iso_files=$(find "$dir" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + if [[ -z "$iso_files" ]]; then + continue + fi + + found=true + printf '\n' + printf ' %s%s%s (%s)\n' "${BOLD}" "$base" "${RESET}" "$display" + + while IFS= read -r iso; do + local filename size size_bytes + filename=$(basename "$iso") + size=$(du -h "$iso" | awk '{print $1}') + size_bytes=$(du -b "$iso" | awk '{print $1}') + total_size=$((total_size + size_bytes)) + printf ' %s %s\n' "$size" "$filename" + done <<< "$iso_files" + done + + if [[ "$found" == "false" ]]; then + info "No cached ISOs" + return 0 + fi + + # Format total size + local total_human + if ((total_size >= 1073741824)); then + total_human="$(awk "BEGIN {printf \"%.1fG\", $total_size / 1073741824}")" + elif ((total_size >= 1048576)); then + total_human="$(awk "BEGIN {printf \"%.1fM\", $total_size / 1048576}")" + else + total_human="${total_size}B" + fi + + printf '\n' + printf ' %s%s%s %s\n' "${DIM}" "$(printf '─%.0s' {1..40})" "${RESET}" "" + printf ' %sTotal: %s%s\n' "${BOLD}" "$total_human" "${RESET}" + printf '\n' +} + +cmd_cache_rm() { + local version="${1:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} cache rm " + fi + + # Validate it's a known base version + if [[ -z "${VERSION_COMPOSE_FILES[$version]:-}" ]]; then + die "Unknown version: $version. Run '${SCRIPT_NAME} list' to see available versions." + fi + + local cache_dir="$ISO_CACHE_DIR/$version" + if [[ ! -d "$cache_dir" ]] || [[ -z "$(ls -A "$cache_dir" 2>/dev/null)" ]]; then + die "No cached ISOs for $version" + fi + + local display="${VERSION_DISPLAY_NAMES[$version]:-$version}" + + header "Remove Cached ISOs: $display" + + # Show what will be removed + printf '\n' + local iso_files + iso_files=$(find "$cache_dir" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + while IFS= read -r iso; do + local filename size + filename=$(basename "$iso") + size=$(du -h "$iso" | awk '{print $1}') + printf ' %s %s\n' "$size" "$filename" + done <<< "$iso_files" + + local dir_size + dir_size=$(du -sh "$cache_dir" | awk '{print $1}') + printf '\n' + printf '%s\n' " ${RED}${BOLD}This will remove $dir_size of cached ISOs for $version.${RESET}" + printf '\n' + printf '%s' " Type 'yes' to confirm: " + + local confirm + read -r confirm + if [[ "$confirm" != "yes" ]]; then + info "Canceled" + return 0 + fi + + rm -rf "$cache_dir" + success "Removed cached ISOs for $version" +} + +cmd_cache_flush() { + if [[ ! -d "$ISO_CACHE_DIR" ]] || [[ -z "$(ls -A "$ISO_CACHE_DIR" 2>/dev/null)" ]]; then + info "Cache is already empty" + return 0 + fi + + header "Flush ISO Cache" + + local total_size + total_size=$(du -sh "$ISO_CACHE_DIR" | awk '{print $1}') + + printf '\n' + printf '%s\n' " ${RED}${BOLD}This will remove all cached ISOs ($total_size).${RESET}" + printf '\n' + printf '%s' " Type 'yes' to confirm: " + + local confirm + read -r confirm + if [[ "$confirm" != "yes" ]]; then + info "Canceled" + return 0 + fi + + rm -rf "${ISO_CACHE_DIR:?}/"* + success "ISO cache flushed" +} + +# ============================================================================== +# HELP +# ============================================================================== + +# Print a help row: _help_row "cmd args" "description" +# Uses fixed-width column so descriptions align regardless of ANSI bold codes. +_help_row() { + local cmd="$1" desc="$2" + printf ' %b%-24s%b%s\n' "${BOLD}" "$cmd" "${RESET}" "$desc" +} + +show_usage() { + local topic="${1:-}" + + case "$topic" in + commands) _help_topic_commands ;; + instances) _help_topic_instances ;; + cache) _help_topic_cache ;; + examples) _help_topic_examples ;; + config) _help_topic_config ;; + all) _help_all ;; + "") _help_summary ;; + *) + error "Unknown help topic: $topic" + printf '%s\n' " Available topics: commands, instances, cache, examples, config, all" + exit 1 + ;; + esac +} + +_help_summary() { + printf '%b\n' "${BOLD}${SCRIPT_NAME}${RESET} v${SCRIPT_VERSION} - Windows Docker Container Management" + printf '\n' + printf '%b\n' "${BOLD}USAGE${RESET}" + printf ' %s [options]\n' "${SCRIPT_NAME}" + printf '\n' + printf '%b\n' "${BOLD}COMMANDS${RESET}" + _help_row "start [version...]" "Start container(s), interactive if no version" + _help_row "stop [version...|all]" "Stop container(s) or all running" + _help_row "restart [version...]" "Restart container(s)" + _help_row "status [version...]" "Show status of container(s)" + _help_row "logs [-f]" "View container logs (-f to follow)" + _help_row "shell " "Open bash shell in container" + _help_row "stats [version...]" "Show real-time resource usage" + _help_row "build" "Build Docker image locally" + _help_row "rebuild [version...]" "Destroy and recreate container(s)" + _help_row "list [category]" "List versions (desktop/legacy/server/tiny/all)" + _help_row "inspect " "Show detailed container info" + _help_row "monitor [interval]" "Real-time dashboard (default: 5s refresh)" + _help_row "check" "Run prerequisites check" + _help_row "refresh" "Force refresh status cache" + _help_row "open " "Open web viewer in browser" + _help_row "pull" "Pull latest Docker image" + _help_row "disk [version...]" "Show disk usage per VM" + _help_row "snapshot [name]" "Back up VM data directory" + _help_row "restore [name]" "Restore VM data from snapshot" + _help_row "clean [--data]" "Remove stopped containers" + _help_row "destroy " "Permanently remove an instance" + _help_row "instances [base]" "List all registered instances" + _help_row "cache " "Manage ISO cache (download/save/list/rm/flush)" + _help_row "help [topic]" "Show help (topics below, or 'all')" + printf '\n' + printf '%b\n' "${BOLD}QUICK START${RESET}" + printf ' %s start win11 # Start Windows 11\n' "${SCRIPT_NAME}" + printf ' %s status # Show all containers\n' "${SCRIPT_NAME}" + printf ' %s stop win11 # Stop with confirmation\n' "${SCRIPT_NAME}" + printf '\n' + + # Interactive menu only when running directly in a terminal + if _is_interactive; then + _help_interactive_menu + else + printf ' Topics: commands, instances, cache, examples, config, all\n' + printf '\n' + fi +} + +# Check if the script is running interactively (not piped, not inside +# another script, not in a CI/batch environment). +_is_interactive() { + [[ -t 0 ]] && [[ -t 1 ]] || return 1 + [[ "${TERM:-dumb}" != "dumb" ]] || return 1 + [[ -z "${CI:-}" ]] && [[ -z "${BATCH:-}" ]] && [[ -z "${NONINTERACTIVE:-}" ]] || return 1 + return 0 +} + +_help_interactive_menu() { + while true; do + printf '%b\n' "${BOLD}MORE HELP${RESET}" + printf ' 1) Commands Full command reference\n' + printf ' 2) Instances Multi-instance support\n' + printf ' 3) Cache ISO cache management\n' + printf ' 4) Examples Usage examples\n' + printf ' 5) Config Environment settings\n' + printf ' 6) All Show everything\n' + printf '\n' + printf '%s' " Select [1-6] or Enter to exit: " + + local choice + read -r choice + + case "$choice" in + 1) printf '\n'; _help_topic_commands ;; + 2) printf '\n'; _help_topic_instances ;; + 3) printf '\n'; _help_topic_cache ;; + 4) printf '\n'; _help_topic_examples ;; + 5) printf '\n'; _help_topic_config ;; + 6) printf '\n'; _help_all; return 0 ;; + "") return 0 ;; + *) warn "Invalid choice: $choice"; printf '\n' ;; + esac + done +} + +_help_topic_commands() { + printf '%b\n' "${BOLD}COMMANDS${RESET}" + printf '\n' + _help_row "start [version...]" "Start container(s), interactive if no version" + _help_row "stop [version...|all]" "Stop container(s) or all running" + _help_row "restart [version...]" "Restart container(s)" + _help_row "status [version...]" "Show status of container(s)" + _help_row "logs [-f]" "View container logs (-f to follow)" + _help_row "shell " "Open bash shell in container" + _help_row "stats [version...]" "Show real-time resource usage" + _help_row "build" "Build Docker image locally" + _help_row "rebuild [version...]" "Destroy and recreate container(s)" + _help_row "list [category]" "List versions (desktop/legacy/server/tiny/all)" + _help_row "inspect " "Show detailed container info" + _help_row "monitor [interval]" "Real-time dashboard (default: 5s refresh)" + _help_row "check" "Run prerequisites check" + _help_row "refresh" "Force refresh status cache" + _help_row "open " "Open web viewer in browser" + _help_row "pull" "Pull latest Docker image" + _help_row "disk [version...]" "Show disk usage per VM" + _help_row "snapshot [name]" "Back up VM data directory" + _help_row "restore [name]" "Restore VM data from snapshot" + _help_row "clean [--data]" "Remove stopped containers" + _help_row "destroy " "Permanently remove an instance" + _help_row "instances [base]" "List all registered instances" + _help_row "cache " "Manage ISO cache (download/save/list/rm/flush)" + _help_row "help [topic]" "Show help (topics: commands, instances, cache, examples, config, all)" + printf '\n' + printf '%b\n' "${BOLD}CATEGORIES${RESET}" + printf ' desktop Win 11/10/8.1/7 (Pro, Enterprise, LTSC variants)\n' + printf ' legacy Vista, XP, 2000\n' + printf ' server Server 2025/2022/2019/2016/2012/2008/2003\n' + printf ' tiny Tiny11, Tiny10\n' + printf '\n' + printf '%b\n' "${BOLD}PORTS${RESET}" + printf ' Each version has unique ports for Web UI and RDP access.\n' + printf ' Instances auto-allocate ports from 9000+ (web) and 4000+ (RDP).\n' + printf " Run '%s list' to see port mappings.\n" "${SCRIPT_NAME}" + printf '\n' + printf '%b\n' "${BOLD}ARM64 SUPPORT${RESET}" + printf ' Auto-detected via uname. Only Win 10/11 variants supported on ARM64.\n' + printf ' Set WINDOWS_IMAGE=dockurr/windows-arm in .env.modern or .env.legacy.\n' + printf " Run '%s check' to see detected architecture.\n" "${SCRIPT_NAME}" + printf '\n' +} + +_help_topic_instances() { + printf '%b\n' "${BOLD}INSTANCE FLAGS (used with start)${RESET}" + printf '\n' + _help_row "--new" "Create a new instance of a version" + _help_row "--new [name]" "Name the instance (default: auto-numbered)" + _help_row "--clone" "Clone data from base version to new instance" + printf '\n' + printf '%b\n' "${BOLD}INSTANCE EXAMPLES${RESET}" + printf '\n' + printf ' %s start winxp --new # Create winxp-1 with auto ports\n' "${SCRIPT_NAME}" + printf ' %s start winxp --new lab # Create winxp-lab\n' "${SCRIPT_NAME}" + printf ' %s start winxp --new lab --clone # Clone base data\n' "${SCRIPT_NAME}" + printf ' %s stop winxp-lab # Stop instance\n' "${SCRIPT_NAME}" + printf ' %s instances # List all instances\n' "${SCRIPT_NAME}" + printf ' %s destroy winxp-lab # Remove instance\n' "${SCRIPT_NAME}" + printf '\n' +} + +_help_topic_cache() { + printf '%b\n' "${BOLD}CACHE COMMANDS${RESET}" + printf '\n' + _help_row "cache download " "Download original ISO to cache" + _help_row "cache save " "Cache ISOs from a VM data directory" + _help_row "cache list" "Show all cached ISOs with sizes" + _help_row "cache rm " "Remove cached ISOs for a version" + _help_row "cache flush" "Clear all cached ISOs" + printf '\n' + printf '%b\n' "${BOLD}CACHE EXAMPLES${RESET}" + printf '\n' + printf ' %s cache download winxp # Download original XP ISO to cache\n' "${SCRIPT_NAME}" + printf ' %s cache list # Show cached ISOs\n' "${SCRIPT_NAME}" + printf ' %s start winxp --new # New instance uses cached ISO\n' "${SCRIPT_NAME}" + printf ' %s cache rm winxp # Remove cached winxp ISO\n' "${SCRIPT_NAME}" + printf ' %s cache flush # Clear all cached ISOs\n' "${SCRIPT_NAME}" + printf '\n' + printf '%b\n' "${BOLD}HOW IT WORKS${RESET}" + printf '\n' + printf ' The cache stores original (unprocessed) ISOs. When creating a new\n' + printf ' instance, the cached ISO is copied to the data directory. The container\n' + printf ' processes it locally (extract, inject drivers, answer file) without\n' + printf ' needing to re-download.\n' + printf '\n' + printf ' Use "cache download" to pre-download ISOs. "cache save" only works\n' + printf ' if the data directory has an unprocessed ISO (skips rebuilt ones).\n' + printf '\n' +} + +_help_topic_examples() { + printf '%b\n' "${BOLD}EXAMPLES${RESET}" + printf '\n' + printf ' %s start # Interactive menu\n' "${SCRIPT_NAME}" + printf ' %s start win11 # Start Windows 11\n' "${SCRIPT_NAME}" + printf ' %s start win11 win10 # Start multiple\n' "${SCRIPT_NAME}" + printf ' %s stop win11 # Stop with confirmation\n' "${SCRIPT_NAME}" + printf ' %s stop all # Stop all running\n' "${SCRIPT_NAME}" + printf ' %s status # Show all containers\n' "${SCRIPT_NAME}" + printf ' %s logs win11 -f # Follow logs\n' "${SCRIPT_NAME}" + printf ' %s list desktop # List desktop versions\n' "${SCRIPT_NAME}" + printf ' %s monitor 10 # Dashboard with 10s refresh\n' "${SCRIPT_NAME}" + printf ' %s rebuild win11 # Recreate container\n' "${SCRIPT_NAME}" + printf ' %s open win11 # Open web viewer in browser\n' "${SCRIPT_NAME}" + printf ' %s pull # Pull latest image\n' "${SCRIPT_NAME}" + printf ' %s disk # Show disk usage\n' "${SCRIPT_NAME}" + printf ' %s snapshot win11 # Back up VM data\n' "${SCRIPT_NAME}" + printf ' %s restore win11 # Restore from snapshot\n' "${SCRIPT_NAME}" + printf ' %s clean # Remove stopped containers\n' "${SCRIPT_NAME}" + printf '\n' + printf '%b\n' "${BOLD}INSTANCE EXAMPLES${RESET}" + printf '\n' + printf ' %s start winxp --new # Create winxp-1 with auto ports\n' "${SCRIPT_NAME}" + printf ' %s start winxp --new lab # Create winxp-lab\n' "${SCRIPT_NAME}" + printf ' %s start winxp --new lab --clone # Clone base data\n' "${SCRIPT_NAME}" + printf ' %s stop winxp-lab # Stop instance\n' "${SCRIPT_NAME}" + printf ' %s instances # List all instances\n' "${SCRIPT_NAME}" + printf ' %s destroy winxp-lab # Remove instance\n' "${SCRIPT_NAME}" + printf '\n' + printf '%b\n' "${BOLD}CACHE EXAMPLES${RESET}" + printf '\n' + printf ' %s cache save winxp # Cache ISO after first download\n' "${SCRIPT_NAME}" + printf ' %s cache list # Show cached ISOs\n' "${SCRIPT_NAME}" + printf ' %s cache rm winxp # Remove cached winxp ISO\n' "${SCRIPT_NAME}" + printf ' %s cache flush # Clear all cached ISOs\n' "${SCRIPT_NAME}" + printf '\n' +} + +_help_topic_config() { + printf '%b\n' "${BOLD}CONFIGURATION${RESET}" + printf '\n' + printf ' Two env files control per-VM resources (used by compose files):\n' + printf '\n' + _help_row ".env.modern" "8G RAM, 4 CPU, 128G disk — Win 10/11, Server 2016+" + _help_row ".env.legacy" "2G RAM, 2 CPU, 32G disk — Win 7/8, Vista, XP, 2000, Tiny" + printf '\n' + printf ' Global winctl settings (in .env):\n' + printf '\n' + _help_row "AUTO_CACHE=Y|N" "Auto-cache ISOs on stop (default: N)" + printf '\n' + printf '%b\n' "${BOLD}VM SETTINGS (in .env.modern / .env.legacy)${RESET}" + printf '\n' + _help_row "RAM_SIZE" "Memory allocation (e.g. 8G)" + _help_row "CPU_CORES" "CPU cores (e.g. 4)" + _help_row "DISK_SIZE" "Virtual disk size (e.g. 128G)" + _help_row "USERNAME" "Windows username (default: Docker)" + _help_row "PASSWORD" "Windows password (default: admin)" + _help_row "LANGUAGE" "Installation language (default: en)" + _help_row "REGION" "Region setting (default: en-US)" + _help_row "KEYBOARD" "Keyboard layout (default: en-US)" + _help_row "WIDTH" "Display width (default: 1280)" + _help_row "HEIGHT" "Display height (default: 720)" + _help_row "DHCP" "Use DHCP networking (default: N)" + _help_row "SAMBA" "Enable file sharing (default: Y)" + _help_row "RESTART_POLICY" "Container restart policy (default: on-failure)" + _help_row "DEBUG" "Debug mode (default: N)" + _help_row "WINDOWS_IMAGE" "Docker image (default: dockurr/windows)" + printf '\n' +} + +_help_all() { + _help_summary + _help_topic_commands + _help_topic_instances + _help_topic_cache + _help_topic_examples + _help_topic_config +} + +# ============================================================================== +# MAIN +# ============================================================================== + +main() { + # Change to script directory + cd "$SCRIPT_DIR" + + local command="${1:-}" + shift || true + + case "$command" in + start) cmd_start "$@" ;; + stop) cmd_stop "$@" ;; + restart) cmd_restart "$@" ;; + status) cmd_status "$@" ;; + logs) cmd_logs "$@" ;; + shell) cmd_shell "$@" ;; + stats) cmd_stats "$@" ;; + build) cmd_build "$@" ;; + rebuild) cmd_rebuild "$@" ;; + list) cmd_list "$@" ;; + inspect) cmd_inspect "$@" ;; + monitor) cmd_monitor "$@" ;; + check) cmd_check "$@" ;; + refresh) cmd_refresh "$@" ;; + open) cmd_open "$@" ;; + pull) cmd_pull "$@" ;; + disk) cmd_disk "$@" ;; + snapshot) cmd_snapshot "$@" ;; + restore) cmd_restore "$@" ;; + clean) cmd_clean "$@" ;; + destroy) cmd_destroy "$@" ;; + instances) cmd_instances "$@" ;; + cache) cmd_cache "$@" ;; + help|--help|-h) + show_usage "$@" + ;; + "") + show_usage + exit 1 + ;; + *) + error "Unknown command: $command" + printf '%s\n' "Run '${SCRIPT_NAME} help' for usage information" + exit 1 + ;; + esac +} + +main "$@"