From acdeb62db2872970735d02aed4827b5fc62c5118 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 22:29:45 +0000 Subject: [PATCH 01/33] docs: Add CLAUDE.md for Claude Code guidance Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3fe3e19 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# 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` + +## 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 | + +## 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 From c1d3f8e886bef781a67010661f73ef49e301e30d Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 22:37:10 +0000 Subject: [PATCH 02/33] feat: Add multi-version compose structure with organized data folders - Add compose/ folder with modular compose files for all Windows versions - Organize by category: desktop, legacy, server, tiny - Create data/ subfolders for each version's storage - Update .gitignore to track folder structure via .gitkeep files Co-Authored-By: Claude Opus 4.5 --- .gitignore | 6 +++++- compose.yml | 24 ++++++++++++++--------- compose/all.yml | 5 +++++ compose/base.yml | 9 +++++++++ compose/desktop.yml | 5 +++++ compose/desktop/win10.yml | 39 ++++++++++++++++++++++++++++++++++++++ compose/desktop/win11.yml | 39 ++++++++++++++++++++++++++++++++++++++ compose/desktop/win7.yml | 27 ++++++++++++++++++++++++++ compose/desktop/win8.yml | 27 ++++++++++++++++++++++++++ compose/legacy.yml | 4 ++++ compose/legacy/vista.yml | 15 +++++++++++++++ compose/legacy/win2k.yml | 15 +++++++++++++++ compose/legacy/winxp.yml | 15 +++++++++++++++ compose/server.yml | 8 ++++++++ compose/server/win2003.yml | 15 +++++++++++++++ compose/server/win2008.yml | 15 +++++++++++++++ compose/server/win2012.yml | 15 +++++++++++++++ compose/server/win2016.yml | 15 +++++++++++++++ compose/server/win2019.yml | 15 +++++++++++++++ compose/server/win2022.yml | 15 +++++++++++++++ compose/server/win2025.yml | 15 +++++++++++++++ compose/tiny.yml | 3 +++ compose/tiny/tiny10.yml | 15 +++++++++++++++ compose/tiny/tiny11.yml | 15 +++++++++++++++ data/tiny10/.gitkeep | 0 data/tiny11/.gitkeep | 0 data/vista/.gitkeep | 0 data/win10/.gitkeep | 0 data/win10e/.gitkeep | 0 data/win10l/.gitkeep | 0 data/win11/.gitkeep | 0 data/win11e/.gitkeep | 0 data/win11l/.gitkeep | 0 data/win2003/.gitkeep | 0 data/win2008/.gitkeep | 0 data/win2012/.gitkeep | 0 data/win2016/.gitkeep | 0 data/win2019/.gitkeep | 0 data/win2022/.gitkeep | 0 data/win2025/.gitkeep | 0 data/win2k/.gitkeep | 0 data/win7/.gitkeep | 0 data/win7e/.gitkeep | 0 data/win81/.gitkeep | 0 data/win81e/.gitkeep | 0 data/winxp/.gitkeep | 0 46 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 compose/all.yml create mode 100644 compose/base.yml create mode 100644 compose/desktop.yml create mode 100644 compose/desktop/win10.yml create mode 100644 compose/desktop/win11.yml create mode 100644 compose/desktop/win7.yml create mode 100644 compose/desktop/win8.yml create mode 100644 compose/legacy.yml create mode 100644 compose/legacy/vista.yml create mode 100644 compose/legacy/win2k.yml create mode 100644 compose/legacy/winxp.yml create mode 100644 compose/server.yml create mode 100644 compose/server/win2003.yml create mode 100644 compose/server/win2008.yml create mode 100644 compose/server/win2012.yml create mode 100644 compose/server/win2016.yml create mode 100644 compose/server/win2019.yml create mode 100644 compose/server/win2022.yml create mode 100644 compose/server/win2025.yml create mode 100644 compose/tiny.yml create mode 100644 compose/tiny/tiny10.yml create mode 100644 compose/tiny/tiny11.yml create mode 100644 data/tiny10/.gitkeep create mode 100644 data/tiny11/.gitkeep create mode 100644 data/vista/.gitkeep create mode 100644 data/win10/.gitkeep create mode 100644 data/win10e/.gitkeep create mode 100644 data/win10l/.gitkeep create mode 100644 data/win11/.gitkeep create mode 100644 data/win11e/.gitkeep create mode 100644 data/win11l/.gitkeep create mode 100644 data/win2003/.gitkeep create mode 100644 data/win2008/.gitkeep create mode 100644 data/win2012/.gitkeep create mode 100644 data/win2016/.gitkeep create mode 100644 data/win2019/.gitkeep create mode 100644 data/win2022/.gitkeep create mode 100644 data/win2025/.gitkeep create mode 100644 data/win2k/.gitkeep create mode 100644 data/win7/.gitkeep create mode 100644 data/win7e/.gitkeep create mode 100644 data/win81/.gitkeep create mode 100644 data/win81e/.gitkeep create mode 100644 data/winxp/.gitkeep diff --git a/.gitignore b/.gitignore index 8b13789..ce65657 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ - +# Ignore data folder contents but keep structure +data/* +!data/*/ +data/*/** +!data/*/.gitkeep diff --git a/compose.yml b/compose.yml index e5b6257..593f605 100644 --- a/compose.yml +++ b/compose.yml @@ -1,19 +1,25 @@ +# Default compose file - Windows 11 Pro +# For more versions, see compose/ folder + +x-common: &common + image: dockurr/windows + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + stop_grace_period: 2m + services: windows: - image: dockurr/windows + <<: *common container_name: windows environment: VERSION: "11" - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN ports: - 8006:8006 - 3389:3389/tcp - 3389:3389/udp volumes: - - ./windows:/storage - restart: always - stop_grace_period: 2m + - ./data/win11:/storage diff --git a/compose/all.yml b/compose/all.yml new file mode 100644 index 0000000..74180b5 --- /dev/null +++ b/compose/all.yml @@ -0,0 +1,5 @@ +include: + - desktop.yml + - legacy.yml + - server.yml + - tiny.yml diff --git a/compose/base.yml b/compose/base.yml new file mode 100644 index 0000000..4f9096e --- /dev/null +++ b/compose/base.yml @@ -0,0 +1,9 @@ +x-common: &common + image: dockurr/windows + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + stop_grace_period: 2m diff --git a/compose/desktop.yml b/compose/desktop.yml new file mode 100644 index 0000000..b933560 --- /dev/null +++ b/compose/desktop.yml @@ -0,0 +1,5 @@ +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..a48e255 --- /dev/null +++ b/compose/desktop/win10.yml @@ -0,0 +1,39 @@ +include: + - ../base.yml + +services: + win10: + <<: *common + container_name: win10 + environment: + VERSION: "10" + ports: + - 8010:8006 + - 3310:3389/tcp + - 3310:3389/udp + volumes: + - ../../data/win10:/storage + + win10e: + <<: *common + container_name: win10e + environment: + VERSION: "10e" + ports: + - 8014:8006 + - 3314:3389/tcp + - 3314:3389/udp + volumes: + - ../../data/win10e:/storage + + win10l: + <<: *common + container_name: win10l + environment: + VERSION: "10l" + ports: + - 8015:8006 + - 3315:3389/tcp + - 3315:3389/udp + volumes: + - ../../data/win10l:/storage diff --git a/compose/desktop/win11.yml b/compose/desktop/win11.yml new file mode 100644 index 0000000..49e3f6d --- /dev/null +++ b/compose/desktop/win11.yml @@ -0,0 +1,39 @@ +include: + - ../base.yml + +services: + win11: + <<: *common + container_name: win11 + environment: + VERSION: "11" + ports: + - 8011:8006 + - 3311:3389/tcp + - 3311:3389/udp + volumes: + - ../../data/win11:/storage + + win11e: + <<: *common + container_name: win11e + environment: + VERSION: "11e" + ports: + - 8012:8006 + - 3312:3389/tcp + - 3312:3389/udp + volumes: + - ../../data/win11e:/storage + + win11l: + <<: *common + container_name: win11l + environment: + VERSION: "11l" + ports: + - 8013:8006 + - 3313:3389/tcp + - 3313:3389/udp + volumes: + - ../../data/win11l:/storage diff --git a/compose/desktop/win7.yml b/compose/desktop/win7.yml new file mode 100644 index 0000000..468ea66 --- /dev/null +++ b/compose/desktop/win7.yml @@ -0,0 +1,27 @@ +include: + - ../base.yml + +services: + win7: + <<: *common + container_name: win7 + environment: + VERSION: "7u" + ports: + - 8007:8006 + - 3307:3389/tcp + - 3307:3389/udp + volumes: + - ../../data/win7:/storage + + win7e: + <<: *common + container_name: win7e + environment: + VERSION: "7e" + ports: + - 8071:8006 + - 3371:3389/tcp + - 3371:3389/udp + volumes: + - ../../data/win7e:/storage diff --git a/compose/desktop/win8.yml b/compose/desktop/win8.yml new file mode 100644 index 0000000..a92ef1f --- /dev/null +++ b/compose/desktop/win8.yml @@ -0,0 +1,27 @@ +include: + - ../base.yml + +services: + win81: + <<: *common + container_name: win81 + environment: + VERSION: "8" + ports: + - 8008:8006 + - 3308:3389/tcp + - 3308:3389/udp + volumes: + - ../../data/win81:/storage + + win81e: + <<: *common + container_name: win81e + environment: + VERSION: "8e" + ports: + - 8081:8006 + - 3381:3389/tcp + - 3381:3389/udp + volumes: + - ../../data/win81e:/storage diff --git a/compose/legacy.yml b/compose/legacy.yml new file mode 100644 index 0000000..d56e088 --- /dev/null +++ b/compose/legacy.yml @@ -0,0 +1,4 @@ +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..0589ca8 --- /dev/null +++ b/compose/legacy/vista.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + vista: + <<: *common + container_name: vista + environment: + VERSION: "vu" + ports: + - 8006:8006 + - 3306:3389/tcp + - 3306:3389/udp + volumes: + - ../../data/vista:/storage diff --git a/compose/legacy/win2k.yml b/compose/legacy/win2k.yml new file mode 100644 index 0000000..376aa52 --- /dev/null +++ b/compose/legacy/win2k.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2k: + <<: *common + container_name: win2k + environment: + VERSION: "2k" + ports: + - 8000:8006 + - 3300:3389/tcp + - 3300:3389/udp + volumes: + - ../../data/win2k:/storage diff --git a/compose/legacy/winxp.yml b/compose/legacy/winxp.yml new file mode 100644 index 0000000..d6c9901 --- /dev/null +++ b/compose/legacy/winxp.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + winxp: + <<: *common + container_name: winxp + environment: + VERSION: "xp" + ports: + - 8005:8006 + - 3305:3389/tcp + - 3305:3389/udp + volumes: + - ../../data/winxp:/storage diff --git a/compose/server.yml b/compose/server.yml new file mode 100644 index 0000000..5f6a826 --- /dev/null +++ b/compose/server.yml @@ -0,0 +1,8 @@ +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..b837aab --- /dev/null +++ b/compose/server/win2003.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2003: + <<: *common + container_name: win2003 + environment: + VERSION: "2003" + ports: + - 8003:8006 + - 3303:3389/tcp + - 3303:3389/udp + volumes: + - ../../data/win2003:/storage diff --git a/compose/server/win2008.yml b/compose/server/win2008.yml new file mode 100644 index 0000000..a36b80b --- /dev/null +++ b/compose/server/win2008.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2008: + <<: *common + container_name: win2008 + environment: + VERSION: "2008" + ports: + - 8108:8006 + - 3208:3389/tcp + - 3208:3389/udp + volumes: + - ../../data/win2008:/storage diff --git a/compose/server/win2012.yml b/compose/server/win2012.yml new file mode 100644 index 0000000..b67a29d --- /dev/null +++ b/compose/server/win2012.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2012: + <<: *common + container_name: win2012 + environment: + VERSION: "2012" + ports: + - 8112:8006 + - 3212:3389/tcp + - 3212:3389/udp + volumes: + - ../../data/win2012:/storage diff --git a/compose/server/win2016.yml b/compose/server/win2016.yml new file mode 100644 index 0000000..5df94f0 --- /dev/null +++ b/compose/server/win2016.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2016: + <<: *common + container_name: win2016 + environment: + VERSION: "2016" + ports: + - 8016:8006 + - 3316:3389/tcp + - 3316:3389/udp + volumes: + - ../../data/win2016:/storage diff --git a/compose/server/win2019.yml b/compose/server/win2019.yml new file mode 100644 index 0000000..f4bdb91 --- /dev/null +++ b/compose/server/win2019.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2019: + <<: *common + container_name: win2019 + environment: + VERSION: "2019" + ports: + - 8019:8006 + - 3319:3389/tcp + - 3319:3389/udp + volumes: + - ../../data/win2019:/storage diff --git a/compose/server/win2022.yml b/compose/server/win2022.yml new file mode 100644 index 0000000..657b93d --- /dev/null +++ b/compose/server/win2022.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2022: + <<: *common + container_name: win2022 + environment: + VERSION: "2022" + ports: + - 8022:8006 + - 3322:3389/tcp + - 3322:3389/udp + volumes: + - ../../data/win2022:/storage diff --git a/compose/server/win2025.yml b/compose/server/win2025.yml new file mode 100644 index 0000000..51cb7d4 --- /dev/null +++ b/compose/server/win2025.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + win2025: + <<: *common + container_name: win2025 + environment: + VERSION: "2025" + ports: + - 8025:8006 + - 3325:3389/tcp + - 3325:3389/udp + volumes: + - ../../data/win2025:/storage diff --git a/compose/tiny.yml b/compose/tiny.yml new file mode 100644 index 0000000..88124d0 --- /dev/null +++ b/compose/tiny.yml @@ -0,0 +1,3 @@ +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..21a6882 --- /dev/null +++ b/compose/tiny/tiny10.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + tiny10: + <<: *common + container_name: tiny10 + environment: + VERSION: "tiny10" + ports: + - 8110:8006 + - 3110:3389/tcp + - 3110:3389/udp + volumes: + - ../../data/tiny10:/storage diff --git a/compose/tiny/tiny11.yml b/compose/tiny/tiny11.yml new file mode 100644 index 0000000..a7e6cd4 --- /dev/null +++ b/compose/tiny/tiny11.yml @@ -0,0 +1,15 @@ +include: + - ../base.yml + +services: + tiny11: + <<: *common + container_name: tiny11 + environment: + VERSION: "tiny11" + ports: + - 8111:8006 + - 3111:3389/tcp + - 3111:3389/udp + volumes: + - ../../data/tiny11:/storage 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 From effdbe0f6f8ff75c7ed687dd4ef6afc366503009 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 23:14:45 +0000 Subject: [PATCH 03/33] feat: Add winctl.sh management script and multi-version setup Add winctl.sh with 12 commands for managing Windows Docker containers: - start, stop, restart, status, logs, shell, stats - build, rebuild, list, inspect, monitor, check - Interactive menus, prerequisites checking, color output - Support for 22 Windows versions across 4 categories Multi-version compose structure: - Split base.yml into base-modern.yml (8G) and base-legacy.yml (2G) - Add .env.example for configuration - Update all compose files to use env_file - Add unique port mappings per version - Update README with winctl.sh documentation Co-Authored-By: Claude Opus 4.5 --- .env.example | 40 ++ .gitignore | 3 + CHANGELOG.md | 37 ++ compose.yml | 24 +- compose/base-legacy.yml | 23 + compose/base-modern.yml | 23 + compose/base.yml | 9 - compose/desktop/win10.yml | 11 +- compose/desktop/win11.yml | 11 +- compose/desktop/win7.yml | 8 +- compose/desktop/win8.yml | 8 +- compose/legacy/vista.yml | 5 +- compose/legacy/win2k.yml | 5 +- compose/legacy/winxp.yml | 5 +- compose/server/win2003.yml | 5 +- compose/server/win2008.yml | 5 +- compose/server/win2012.yml | 5 +- compose/server/win2016.yml | 5 +- compose/server/win2019.yml | 5 +- compose/server/win2022.yml | 5 +- compose/server/win2025.yml | 5 +- compose/tiny/tiny10.yml | 5 +- compose/tiny/tiny11.yml | 5 +- readme.md | 117 +++++ winctl.sh | 1016 ++++++++++++++++++++++++++++++++++++ 25 files changed, 1332 insertions(+), 58 deletions(-) create mode 100644 .env.example create mode 100644 CHANGELOG.md create mode 100644 compose/base-legacy.yml create mode 100644 compose/base-modern.yml delete mode 100644 compose/base.yml create mode 100755 winctl.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..885715c --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# Windows Docker VM Configuration +# Copy this file to .env and customize + +# =========================================== +# MODERN SYSTEMS (Windows 10/11, Server 2016+) +# =========================================== +MODERN_RAM_SIZE=8G +MODERN_CPU_CORES=4 +MODERN_DISK_SIZE=128G + +# =========================================== +# LEGACY SYSTEMS (Windows 7/8, Vista, XP, 2000, Server 2003-2012) +# =========================================== +LEGACY_RAM_SIZE=2G +LEGACY_CPU_CORES=2 +LEGACY_DISK_SIZE=32G + +# =========================================== +# COMMON SETTINGS (applies to all) +# =========================================== + +# 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 + +# Debug +DEBUG=N diff --git a/.gitignore b/.gitignore index ce65657..fd89282 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Environment file (contains local settings) +.env + # Ignore data folder contents but keep structure data/* !data/*/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..acb1456 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2026-01-28 + +### Added +- **winctl.sh**: Management script for Windows Docker containers + - 12 commands: start, stop, restart, status, logs, shell, stats, build, rebuild, list, inspect, monitor, check + - 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 +- 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 + +### Changed +- Default storage location changed from `./windows` to `./data/` +- Compose files now use `env_file` for centralized configuration +- Restart policy changed from `always` to `unless-stopped` + +### 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/compose.yml b/compose.yml index 593f605..c7ab26e 100644 --- a/compose.yml +++ b/compose.yml @@ -1,25 +1,27 @@ # Default compose file - Windows 11 Pro # For more versions, see compose/ folder - -x-common: &common - image: dockurr/windows - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN - restart: unless-stopped - stop_grace_period: 2m +# Configure settings in .env file services: windows: - <<: *common + 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 + cap_add: + - NET_ADMIN ports: - 8006:8006 - 3389:3389/tcp - 3389:3389/udp volumes: - ./data/win11:/storage + restart: unless-stopped + stop_grace_period: 2m diff --git a/compose/base-legacy.yml b/compose/base-legacy.yml new file mode 100644 index 0000000..3dc8cc0 --- /dev/null +++ b/compose/base-legacy.yml @@ -0,0 +1,23 @@ +x-legacy: &legacy + image: dockurr/windows + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + stop_grace_period: 2m + environment: + RAM_SIZE: ${LEGACY_RAM_SIZE:-2G} + CPU_CORES: ${LEGACY_CPU_CORES:-2} + DISK_SIZE: ${LEGACY_DISK_SIZE:-32G} + USERNAME: ${USERNAME:-Docker} + PASSWORD: ${PASSWORD:-admin} + LANGUAGE: ${LANGUAGE:-en} + REGION: ${REGION:-en-US} + KEYBOARD: ${KEYBOARD:-en-US} + WIDTH: ${WIDTH:-1280} + HEIGHT: ${HEIGHT:-720} + DHCP: ${DHCP:-N} + SAMBA: ${SAMBA:-Y} + DEBUG: ${DEBUG:-N} diff --git a/compose/base-modern.yml b/compose/base-modern.yml new file mode 100644 index 0000000..178fa19 --- /dev/null +++ b/compose/base-modern.yml @@ -0,0 +1,23 @@ +x-modern: &modern + image: dockurr/windows + devices: + - /dev/kvm + - /dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + stop_grace_period: 2m + environment: + RAM_SIZE: ${MODERN_RAM_SIZE:-8G} + CPU_CORES: ${MODERN_CPU_CORES:-4} + DISK_SIZE: ${MODERN_DISK_SIZE:-128G} + USERNAME: ${USERNAME:-Docker} + PASSWORD: ${PASSWORD:-admin} + LANGUAGE: ${LANGUAGE:-en} + REGION: ${REGION:-en-US} + KEYBOARD: ${KEYBOARD:-en-US} + WIDTH: ${WIDTH:-1280} + HEIGHT: ${HEIGHT:-720} + DHCP: ${DHCP:-N} + SAMBA: ${SAMBA:-Y} + DEBUG: ${DEBUG:-N} diff --git a/compose/base.yml b/compose/base.yml deleted file mode 100644 index 4f9096e..0000000 --- a/compose/base.yml +++ /dev/null @@ -1,9 +0,0 @@ -x-common: &common - image: dockurr/windows - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN - restart: unless-stopped - stop_grace_period: 2m diff --git a/compose/desktop/win10.yml b/compose/desktop/win10.yml index a48e255..1aa4039 100644 --- a/compose/desktop/win10.yml +++ b/compose/desktop/win10.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-modern.yml services: win10: - <<: *common + <<: *modern container_name: win10 + env_file: ../../.env environment: VERSION: "10" ports: @@ -15,8 +16,9 @@ services: - ../../data/win10:/storage win10e: - <<: *common + <<: *modern container_name: win10e + env_file: ../../.env environment: VERSION: "10e" ports: @@ -27,8 +29,9 @@ services: - ../../data/win10e:/storage win10l: - <<: *common + <<: *modern container_name: win10l + env_file: ../../.env environment: VERSION: "10l" ports: diff --git a/compose/desktop/win11.yml b/compose/desktop/win11.yml index 49e3f6d..9ed4043 100644 --- a/compose/desktop/win11.yml +++ b/compose/desktop/win11.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-modern.yml services: win11: - <<: *common + <<: *modern container_name: win11 + env_file: ../../.env environment: VERSION: "11" ports: @@ -15,8 +16,9 @@ services: - ../../data/win11:/storage win11e: - <<: *common + <<: *modern container_name: win11e + env_file: ../../.env environment: VERSION: "11e" ports: @@ -27,8 +29,9 @@ services: - ../../data/win11e:/storage win11l: - <<: *common + <<: *modern container_name: win11l + env_file: ../../.env environment: VERSION: "11l" ports: diff --git a/compose/desktop/win7.yml b/compose/desktop/win7.yml index 468ea66..7c0a0d5 100644 --- a/compose/desktop/win7.yml +++ b/compose/desktop/win7.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: win7: - <<: *common + <<: *legacy container_name: win7 + env_file: ../../.env environment: VERSION: "7u" ports: @@ -15,8 +16,9 @@ services: - ../../data/win7:/storage win7e: - <<: *common + <<: *legacy container_name: win7e + env_file: ../../.env environment: VERSION: "7e" ports: diff --git a/compose/desktop/win8.yml b/compose/desktop/win8.yml index a92ef1f..408ec52 100644 --- a/compose/desktop/win8.yml +++ b/compose/desktop/win8.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: win81: - <<: *common + <<: *legacy container_name: win81 + env_file: ../../.env environment: VERSION: "8" ports: @@ -15,8 +16,9 @@ services: - ../../data/win81:/storage win81e: - <<: *common + <<: *legacy container_name: win81e + env_file: ../../.env environment: VERSION: "8e" ports: diff --git a/compose/legacy/vista.yml b/compose/legacy/vista.yml index 0589ca8..cf96b10 100644 --- a/compose/legacy/vista.yml +++ b/compose/legacy/vista.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: vista: - <<: *common + <<: *legacy container_name: vista + env_file: ../../.env environment: VERSION: "vu" ports: diff --git a/compose/legacy/win2k.yml b/compose/legacy/win2k.yml index 376aa52..e40e3e6 100644 --- a/compose/legacy/win2k.yml +++ b/compose/legacy/win2k.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: win2k: - <<: *common + <<: *legacy container_name: win2k + env_file: ../../.env environment: VERSION: "2k" ports: diff --git a/compose/legacy/winxp.yml b/compose/legacy/winxp.yml index d6c9901..cb35412 100644 --- a/compose/legacy/winxp.yml +++ b/compose/legacy/winxp.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: winxp: - <<: *common + <<: *legacy container_name: winxp + env_file: ../../.env environment: VERSION: "xp" ports: diff --git a/compose/server/win2003.yml b/compose/server/win2003.yml index b837aab..dcf96a9 100644 --- a/compose/server/win2003.yml +++ b/compose/server/win2003.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: win2003: - <<: *common + <<: *legacy container_name: win2003 + env_file: ../../.env environment: VERSION: "2003" ports: diff --git a/compose/server/win2008.yml b/compose/server/win2008.yml index a36b80b..c2c6c1b 100644 --- a/compose/server/win2008.yml +++ b/compose/server/win2008.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: win2008: - <<: *common + <<: *legacy container_name: win2008 + env_file: ../../.env environment: VERSION: "2008" ports: diff --git a/compose/server/win2012.yml b/compose/server/win2012.yml index b67a29d..56f0514 100644 --- a/compose/server/win2012.yml +++ b/compose/server/win2012.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: win2012: - <<: *common + <<: *legacy container_name: win2012 + env_file: ../../.env environment: VERSION: "2012" ports: diff --git a/compose/server/win2016.yml b/compose/server/win2016.yml index 5df94f0..204388f 100644 --- a/compose/server/win2016.yml +++ b/compose/server/win2016.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-modern.yml services: win2016: - <<: *common + <<: *modern container_name: win2016 + env_file: ../../.env environment: VERSION: "2016" ports: diff --git a/compose/server/win2019.yml b/compose/server/win2019.yml index f4bdb91..73a0821 100644 --- a/compose/server/win2019.yml +++ b/compose/server/win2019.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-modern.yml services: win2019: - <<: *common + <<: *modern container_name: win2019 + env_file: ../../.env environment: VERSION: "2019" ports: diff --git a/compose/server/win2022.yml b/compose/server/win2022.yml index 657b93d..5ee9c35 100644 --- a/compose/server/win2022.yml +++ b/compose/server/win2022.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-modern.yml services: win2022: - <<: *common + <<: *modern container_name: win2022 + env_file: ../../.env environment: VERSION: "2022" ports: diff --git a/compose/server/win2025.yml b/compose/server/win2025.yml index 51cb7d4..c01b6ed 100644 --- a/compose/server/win2025.yml +++ b/compose/server/win2025.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-modern.yml services: win2025: - <<: *common + <<: *modern container_name: win2025 + env_file: ../../.env environment: VERSION: "2025" ports: diff --git a/compose/tiny/tiny10.yml b/compose/tiny/tiny10.yml index 21a6882..b5a730f 100644 --- a/compose/tiny/tiny10.yml +++ b/compose/tiny/tiny10.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: tiny10: - <<: *common + <<: *legacy container_name: tiny10 + env_file: ../../.env environment: VERSION: "tiny10" ports: diff --git a/compose/tiny/tiny11.yml b/compose/tiny/tiny11.yml index a7e6cd4..416ff54 100644 --- a/compose/tiny/tiny11.yml +++ b/compose/tiny/tiny11.yml @@ -1,10 +1,11 @@ include: - - ../base.yml + - ../base-legacy.yml services: tiny11: - <<: *common + <<: *legacy container_name: tiny11 + env_file: ../../.env environment: VERSION: "tiny11" ports: diff --git a/readme.md b/readme.md index adbb6bf..8818e1f 100644 --- a/readme.md +++ b/readme.md @@ -70,6 +70,123 @@ 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 +./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 + +# View logs +./winctl.sh logs win11 -f + +# List all available versions +./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 + +# Full help +./winctl.sh help +``` + +### Quick Start (Manual) + +```bash +# Copy environment template +cp .env.example .env + +# Run default (Windows 11) +docker compose up + +# Run specific version +docker compose -f compose/desktop/win11.yml up win11 +docker compose -f compose/legacy/winxp.yml up winxp + +# Run all desktop versions +docker compose -f compose/desktop.yml up + +# Run everything +docker compose -f compose/all.yml up +``` + +### Configuration + +Edit `.env` to customize resources for all VMs: + +```bash +# Modern systems (Win 10/11, Server 2016+) +MODERN_RAM_SIZE=8G +MODERN_CPU_CORES=4 +MODERN_DISK_SIZE=128G + +# Legacy systems (Win 7/8, Vista, XP, 2000, Server 2003-2012, Tiny) +LEGACY_RAM_SIZE=2G +LEGACY_CPU_CORES=2 +LEGACY_DISK_SIZE=32G + +# Common settings +USERNAME=Docker +PASSWORD=admin +LANGUAGE=en +``` + +### Folder Structure + +``` +compose/ +├── base-modern.yml # High-resource profile +├── base-legacy.yml # Low-resource profile +├── all.yml # All versions +├── desktop.yml # Desktop versions +├── legacy.yml # Vista, XP, 2000 +├── server.yml # Server versions +├── tiny.yml # Tiny versions +├── desktop/ # Individual desktop configs +├── legacy/ # Individual legacy configs +├── server/ # Individual server configs +└── tiny/ # Individual tiny configs + +data/ # VM storage (per-version folders) +├── win11/ +├── 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? diff --git a/winctl.sh b/winctl.sh new file mode 100755 index 0000000..8c86dad --- /dev/null +++ b/winctl.sh @@ -0,0 +1,1016 @@ +#!/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 + +# ============================================================================== +# 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 +) + +# 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" +) + +# Resource requirements +readonly MODERN_RAM_GB=8 +readonly MODERN_DISK_GB=128 +readonly LEGACY_RAM_GB=2 +readonly LEGACY_DISK_GB=32 + +# ============================================================================== +# OUTPUT HELPERS +# ============================================================================== + +info() { + echo -e "${BLUE}[INFO]${RESET} $*" +} + +success() { + echo -e "${GREEN}[OK]${RESET} $*" +} + +warn() { + echo -e "${YELLOW}[WARN]${RESET} $*" +} + +error() { + echo -e "${RED}[ERROR]${RESET} $*" >&2 +} + +die() { + error "$@" + exit 1 +} + +header() { + echo "" + echo -e "${BOLD}${CYAN}$*${RESET}" + echo -e "${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 " ${BOLD}%-12s${RESET} %-26s ${status_color}%-10s${RESET} %-8s %-8s\n" \ + "$version" "$name" "$status" "$web" "$rdp" +} + +table_header() { + echo "" + printf " ${BOLD}${DIM}%-12s %-26s %-10s %-8s %-8s${RESET}\n" \ + "VERSION" "NAME" "STATUS" "WEB" "RDP" + echo -e " ${DIM}$(printf '─%.0s' {1..66})${RESET}" +} + +# ============================================================================== +# PREREQUISITES CHECKS +# ============================================================================== + +check_docker() { + if ! command -v docker &>/dev/null; then + error "Docker is not installed" + echo " Install Docker: https://docs.docker.com/get-docker/" + return 1 + fi + + if ! docker info &>/dev/null; then + error "Docker daemon is not running" + echo " 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" + echo " Install: https://docs.docker.com/compose/install/" + return 1 + fi +} + +check_kvm() { + if [[ ! -e /dev/kvm ]]; then + error "KVM device not found (/dev/kvm)" + echo " Enable virtualization in BIOS or check nested virtualization" + return 1 + fi + + if [[ ! -r /dev/kvm ]] || [[ ! -w /dev/kvm ]]; then + error "KVM device not accessible" + echo " 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 + + echo "" + 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 +} + +# Check if a container is running +is_running() { + local version="$1" + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${version}$" +} + +# Check if a container exists (running or stopped) +container_exists() { + local version="$1" + docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${version}$" +} + +# Get container status +get_status() { + local version="$1" + local status + status=$(docker ps -a --filter "name=^${version}$" --format '{{.State}}' 2>/dev/null) + echo "${status:-not created}" +} + +# Get compose file path for version +get_compose_file() { + local version="$1" + local file="${VERSION_COMPOSE_FILES[$version]:-}" + if [[ -z "$file" ]]; then + die "Unknown version: $version" + fi + echo "$SCRIPT_DIR/$file" +} + +# Validate version +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 +run_compose() { + local version="$1" + shift + local compose_file + compose_file=$(get_compose_file "$version") + + cd "$SCRIPT_DIR" + $(compose_cmd) -f "$compose_file" "$@" +} + +# ============================================================================== +# 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 +select_category() { + header "Select Category" + echo "" + echo " ${BOLD}1${RESET}) Desktop (Win 11, 10, 8.1, 7)" + echo " ${BOLD}2${RESET}) Legacy (Vista, XP, 2000)" + echo " ${BOLD}3${RESET}) Server (2025, 2022, 2019, 2016, 2012, 2008, 2003)" + echo " ${BOLD}4${RESET}) Tiny (Tiny11, Tiny10)" + echo " ${BOLD}5${RESET}) All versions" + echo " ${BOLD}6${RESET}) Select individual versions" + echo "" + echo -n " Select [1-6]: " + + local choice + read -r choice + + case "$choice" in + 1) echo "desktop" ;; + 2) echo "legacy" ;; + 3) echo "server" ;; + 4) echo "tiny" ;; + 5) echo "all" ;; + 6) echo "individual" ;; + *) echo "" ;; + esac +} + +# Show version selection menu +select_versions() { + local category="$1" + local versions=() + + if [[ "$category" == "all" ]]; then + versions=("${ALL_VERSIONS[@]}") + elif [[ "$category" == "individual" ]]; then + versions=("${ALL_VERSIONS[@]}") + else + IFS=' ' read -ra versions <<< "$(get_versions_by_category "$category")" + fi + + if [[ ${#versions[@]} -eq 0 ]]; then + die "No versions found for category: $category" + fi + + header "Select Version(s)" + echo "" + + local i=1 + for v in "${versions[@]}"; do + local status="" + if is_running "$v"; then + status="${GREEN}[running]${RESET}" + elif container_exists "$v"; then + status="${YELLOW}[stopped]${RESET}" + fi + printf " ${BOLD}%2d${RESET}) %-10s %-28s %s\n" "$i" "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" + ((i++)) + done + + echo "" + echo " ${BOLD} a${RESET}) Select all" + echo " ${BOLD} q${RESET}) Cancel" + echo "" + echo -n " Select (numbers separated by spaces, or 'a' for all): " + + local input + read -r input + + if [[ "$input" == "q" ]] || [[ -z "$input" ]]; then + return 1 + fi + + if [[ "$input" == "a" ]]; then + echo "${versions[*]}" + return 0 + fi + + local selected=() + for num in $input; do + if [[ "$num" =~ ^[0-9]+$ ]] && ((num >= 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 + die "Invalid selection" + fi + + local selected + if ! selected=$(select_versions "$category"); then + die "No versions selected" + fi + + echo "$selected" +} + +# ============================================================================== +# COMMANDS +# ============================================================================== + +cmd_start() { + local versions=("$@") + + # Interactive selection if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + IFS=' ' read -ra versions <<< "$(interactive_select)" + fi + + # Validate all versions first + for v in "${versions[@]}"; do + validate_version "$v" || exit 1 + done + + # Run prerequisite checks + check_docker || exit 1 + check_kvm || exit 1 + + for v in "${versions[@]}"; do + header "Starting ${VERSION_DISPLAY_NAMES[$v]} ($v)" + + # Check resources + local resource_type="${VERSION_RESOURCE_TYPE[$v]}" + 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 + + 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 + echo "" + echo -e " ${BOLD}Connection Details:${RESET}" + echo -e " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" + echo -e " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" + echo "" + done +} + +cmd_stop() { + local versions=("$@") + + # Interactive selection if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + IFS=' ' read -ra versions <<< "$(interactive_select)" + fi + + # Validate all versions first + for v in "${versions[@]}"; do + validate_version "$v" || exit 1 + done + + # Show confirmation + header "Stopping Containers" + echo "" + echo " The following containers will be stopped:" + for v in "${versions[@]}"; do + local status + if is_running "$v"; then + status="${GREEN}running${RESET}" + else + status="${YELLOW}not running${RESET}" + fi + echo -e " • $v (${VERSION_DISPLAY_NAMES[$v]}) - $status" + done + echo "" + echo -n " Continue? [y/N]: " + + local confirm + read -r confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + info "Cancelled" + 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 +} + +cmd_restart() { + local versions=("$@") + + # Interactive selection if no versions specified + if [[ ${#versions[@]} -eq 0 ]]; then + IFS=' ' read -ra versions <<< "$(interactive_select)" + fi + + # Validate all versions first + for v in "${versions[@]}"; do + validate_version "$v" || exit 1 + done + + for v in "${versions[@]}"; do + header "Restarting ${VERSION_DISPLAY_NAMES[$v]} ($v)" + + info "Restarting $v..." + if run_compose "$v" restart "$v"; then + success "$v restarted" + echo "" + echo -e " ${BOLD}Connection Details:${RESET}" + echo -e " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" + echo -e " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" + echo "" + else + error "Failed to restart $v" + fi + done +} + +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 + continue + fi + + local status + status=$(get_status "$v") + table_row "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" "${VERSION_PORTS_WEB[$v]}" "${VERSION_PORTS_RDP[$v]}" + done + echo "" +} + +cmd_logs() { + local version="${1:-}" + local follow="${2:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} logs [-f]" + fi + + validate_version "$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_version "$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 + if [[ ${#running[@]} -eq 0 ]]; then + die "No containers are running" + fi + versions=("${running[@]}") + fi + + # Validate versions + local valid_running=() + for v in "${versions[@]}"; do + if validate_version "$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 + IFS=' ' read -ra versions <<< "$(interactive_select)" + fi + + # Validate all versions first + for v in "${versions[@]}"; do + validate_version "$v" || exit 1 + done + + # Show warning + header "⚠️ Rebuild Containers" + echo "" + echo -e " ${RED}${BOLD}WARNING: This will destroy and recreate the following containers.${RESET}" + echo -e " ${RED}Data in /storage volumes will be preserved.${RESET}" + echo "" + for v in "${versions[@]}"; do + echo " • $v (${VERSION_DISPLAY_NAMES[$v]})" + done + echo "" + echo -n " Type 'yes' to confirm: " + + local confirm + read -r confirm + if [[ "$confirm" != "yes" ]]; then + info "Cancelled" + return 0 + fi + + for v in "${versions[@]}"; do + header "Rebuilding $v" + + 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" + echo "" + echo -e " ${BOLD}Connection Details:${RESET}" + echo -e " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" + echo -e " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" + echo "" + else + error "Failed to rebuild $v" + fi + done +} + +cmd_list() { + local category="${1:-all}" + + 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 + echo "" + local cat_upper + cat_upper=$(echo "$cat" | tr '[:lower:]' '[:upper:]') + echo -e " ${BOLD}${cat_upper}${RESET}" + echo -e " ${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 + printf " %-10s %-28s %s %s\n" "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$resource_tag" "$status" + fi + done + done + echo "" +} + +cmd_inspect() { + local version="${1:-}" + + if [[ -z "$version" ]]; then + die "Usage: ${SCRIPT_NAME} inspect " + fi + + validate_version "$version" || exit 1 + + header "Container Details: $version" + echo "" + echo -e " ${BOLD}Version:${RESET} $version" + echo -e " ${BOLD}Name:${RESET} ${VERSION_DISPLAY_NAMES[$version]}" + echo -e " ${BOLD}Category:${RESET} ${VERSION_CATEGORIES[$version]}" + echo -e " ${BOLD}Status:${RESET} $(get_status "$version")" + echo -e " ${BOLD}Web Port:${RESET} ${VERSION_PORTS_WEB[$version]}" + echo -e " ${BOLD}RDP Port:${RESET} ${VERSION_PORTS_RDP[$version]}" + echo -e " ${BOLD}Resources:${RESET} ${VERSION_RESOURCE_TYPE[$version]}" + echo -e " ${BOLD}Compose:${RESET} ${VERSION_COMPOSE_FILES[$version]}" + echo "" + + if container_exists "$version"; then + echo -e " ${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 + echo "" +} + +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)" + echo " Press Ctrl+C to exit" + echo "" + + while true; do + clear + echo -e "${BOLD}${CYAN}Windows Container Monitor${RESET} - $(date '+%Y-%m-%d %H:%M:%S')" + echo -e "${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 + + if [[ $total_count -eq 0 ]]; then + echo -e " ${DIM}No containers found${RESET}" + fi + + echo "" + echo -e " ${BOLD}Summary:${RESET} ${GREEN}$running_count running${RESET}, ${RED}$stopped_count stopped${RESET}, $total_count total" + echo "" + echo -e " ${DIM}Refreshing in ${interval}s... (Ctrl+C to exit)${RESET}" + + sleep "$interval" + done +} + +cmd_check() { + run_all_checks +} + +# ============================================================================== +# HELP +# ============================================================================== + +show_usage() { + cat << EOF +${BOLD}${SCRIPT_NAME}${RESET} v${SCRIPT_VERSION} - Windows Docker Container Management + +${BOLD}USAGE${RESET} + ${SCRIPT_NAME} [options] + +${BOLD}COMMANDS${RESET} + ${BOLD}start${RESET} [version...] Start container(s), interactive if no version + ${BOLD}stop${RESET} [version...] Stop container(s) with 2-min grace period + ${BOLD}restart${RESET} [version...] Restart container(s) + ${BOLD}status${RESET} [version...] Show status of container(s) + ${BOLD}logs${RESET} [-f] View container logs (-f to follow) + ${BOLD}shell${RESET} Open bash shell in container + ${BOLD}stats${RESET} [version...] Show real-time resource usage + ${BOLD}build${RESET} Build Docker image locally + ${BOLD}rebuild${RESET} [version...] Destroy and recreate container(s) + ${BOLD}list${RESET} [category] List versions (desktop/legacy/server/tiny/all) + ${BOLD}inspect${RESET} Show detailed container info + ${BOLD}monitor${RESET} [interval] Real-time dashboard (default: 5s refresh) + ${BOLD}check${RESET} Run prerequisites check + ${BOLD}help${RESET} Show this help message + +${BOLD}CATEGORIES${RESET} + desktop Win 11/10/8.1/7 (Pro, Enterprise, LTSC variants) + legacy Vista, XP, 2000 + server Server 2025/2022/2019/2016/2012/2008/2003 + tiny Tiny11, Tiny10 + +${BOLD}EXAMPLES${RESET} + ${SCRIPT_NAME} start # Interactive menu + ${SCRIPT_NAME} start win11 # Start Windows 11 + ${SCRIPT_NAME} start win11 win10 # Start multiple + ${SCRIPT_NAME} stop win11 # Stop with confirmation + ${SCRIPT_NAME} status # Show all containers + ${SCRIPT_NAME} logs win11 -f # Follow logs + ${SCRIPT_NAME} list desktop # List desktop versions + ${SCRIPT_NAME} monitor 10 # Dashboard with 10s refresh + ${SCRIPT_NAME} rebuild win11 # Recreate container + +${BOLD}PORTS${RESET} + Each version has unique ports for Web UI and RDP access. + Run '${SCRIPT_NAME} list' to see port mappings. + +EOF +} + +# ============================================================================== +# 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 "$@" ;; + help|--help|-h) + show_usage + ;; + "") + show_usage + exit 1 + ;; + *) + error "Unknown command: $command" + echo "Run '${SCRIPT_NAME} help' for usage information" + exit 1 + ;; + esac +} + +main "$@" From f642f8faa2693ca1a612c46a39bd81fed057160f Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 23:22:18 +0000 Subject: [PATCH 04/33] fix: Escape ANSI color codes properly in help output Use printf '%b' instead of cat heredoc to properly interpret ANSI escape sequences in the show_usage function. Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 86 +++++++++++++++++++++++++++---------------------------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/winctl.sh b/winctl.sh index 8c86dad..46abb4c 100755 --- a/winctl.sh +++ b/winctl.sh @@ -927,50 +927,48 @@ cmd_check() { # ============================================================================== show_usage() { - cat << EOF -${BOLD}${SCRIPT_NAME}${RESET} v${SCRIPT_VERSION} - Windows Docker Container Management - -${BOLD}USAGE${RESET} - ${SCRIPT_NAME} [options] - -${BOLD}COMMANDS${RESET} - ${BOLD}start${RESET} [version...] Start container(s), interactive if no version - ${BOLD}stop${RESET} [version...] Stop container(s) with 2-min grace period - ${BOLD}restart${RESET} [version...] Restart container(s) - ${BOLD}status${RESET} [version...] Show status of container(s) - ${BOLD}logs${RESET} [-f] View container logs (-f to follow) - ${BOLD}shell${RESET} Open bash shell in container - ${BOLD}stats${RESET} [version...] Show real-time resource usage - ${BOLD}build${RESET} Build Docker image locally - ${BOLD}rebuild${RESET} [version...] Destroy and recreate container(s) - ${BOLD}list${RESET} [category] List versions (desktop/legacy/server/tiny/all) - ${BOLD}inspect${RESET} Show detailed container info - ${BOLD}monitor${RESET} [interval] Real-time dashboard (default: 5s refresh) - ${BOLD}check${RESET} Run prerequisites check - ${BOLD}help${RESET} Show this help message - -${BOLD}CATEGORIES${RESET} - desktop Win 11/10/8.1/7 (Pro, Enterprise, LTSC variants) - legacy Vista, XP, 2000 - server Server 2025/2022/2019/2016/2012/2008/2003 - tiny Tiny11, Tiny10 - -${BOLD}EXAMPLES${RESET} - ${SCRIPT_NAME} start # Interactive menu - ${SCRIPT_NAME} start win11 # Start Windows 11 - ${SCRIPT_NAME} start win11 win10 # Start multiple - ${SCRIPT_NAME} stop win11 # Stop with confirmation - ${SCRIPT_NAME} status # Show all containers - ${SCRIPT_NAME} logs win11 -f # Follow logs - ${SCRIPT_NAME} list desktop # List desktop versions - ${SCRIPT_NAME} monitor 10 # Dashboard with 10s refresh - ${SCRIPT_NAME} rebuild win11 # Recreate container - -${BOLD}PORTS${RESET} - Each version has unique ports for Web UI and RDP access. - Run '${SCRIPT_NAME} list' to see port mappings. - -EOF + 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}" + printf ' %b [version...] Start container(s), interactive if no version\n' "${BOLD}start${RESET}" + printf ' %b [version...] Stop container(s) with 2-min grace period\n' "${BOLD}stop${RESET}" + printf ' %b [version...] Restart container(s)\n' "${BOLD}restart${RESET}" + printf ' %b [version...] Show status of container(s)\n' "${BOLD}status${RESET}" + printf ' %b [-f] View container logs (-f to follow)\n' "${BOLD}logs${RESET}" + printf ' %b Open bash shell in container\n' "${BOLD}shell${RESET}" + printf ' %b [version...] Show real-time resource usage\n' "${BOLD}stats${RESET}" + printf ' %b Build Docker image locally\n' "${BOLD}build${RESET}" + printf ' %b [version...] Destroy and recreate container(s)\n' "${BOLD}rebuild${RESET}" + printf ' %b [category] List versions (desktop/legacy/server/tiny/all)\n' "${BOLD}list${RESET}" + printf ' %b Show detailed container info\n' "${BOLD}inspect${RESET}" + printf ' %b [interval] Real-time dashboard (default: 5s refresh)\n' "${BOLD}monitor${RESET}" + printf ' %b Run prerequisites check\n' "${BOLD}check${RESET}" + printf ' %b Show this help message\n' "${BOLD}help${RESET}" + 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}EXAMPLES${RESET}" + 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 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 '\n' + printf '%b\n' "${BOLD}PORTS${RESET}" + printf ' Each version has unique ports for Web UI and RDP access.\n' + printf " Run '%s list' to see port mappings.\n" "${SCRIPT_NAME}" + printf '\n' } # ============================================================================== From b06f53cb17336114beb87d521440880bdc88d3b5 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 23:28:56 +0000 Subject: [PATCH 05/33] fix: Interactive menu not displaying and slow status checks - Send menu prompts to stderr so they display in terminal - Read user input from /dev/tty for proper interactive mode - Add status cache to fetch all container states in one Docker call - Handle interactive_select errors properly in all callers Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 145 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 44 deletions(-) diff --git a/winctl.sh b/winctl.sh index 46abb4c..4130ffd 100755 --- a/winctl.sh +++ b/winctl.sh @@ -321,24 +321,55 @@ compose_cmd() { fi } +# Cache for container statuses (populated by refresh_status_cache) +declare -A _STATUS_CACHE=() +_STATUS_CACHE_VALID=false + +# Refresh the status cache with a single docker call +refresh_status_cache() { + _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 +} + # Check if a container is running is_running() { local version="$1" - docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${version}$" + 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" - docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^${version}$" + 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" - local status - status=$(docker ps -a --filter "name=^${version}$" --format '{{.State}}' 2>/dev/null) - echo "${status:-not created}" + 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 } # Get compose file path for version @@ -389,21 +420,23 @@ get_versions_by_category() { echo "${versions[*]}" } -# Show category menu +# Show category menu (prompts to stderr, result to stdout) select_category() { - header "Select Category" - echo "" - echo " ${BOLD}1${RESET}) Desktop (Win 11, 10, 8.1, 7)" - echo " ${BOLD}2${RESET}) Legacy (Vista, XP, 2000)" - echo " ${BOLD}3${RESET}) Server (2025, 2022, 2019, 2016, 2012, 2008, 2003)" - echo " ${BOLD}4${RESET}) Tiny (Tiny11, Tiny10)" - echo " ${BOLD}5${RESET}) All versions" - echo " ${BOLD}6${RESET}) Select individual versions" - echo "" - echo -n " Select [1-6]: " + { + header "Select Category" + echo "" + echo " ${BOLD}1${RESET}) Desktop (Win 11, 10, 8.1, 7)" + echo " ${BOLD}2${RESET}) Legacy (Vista, XP, 2000)" + echo " ${BOLD}3${RESET}) Server (2025, 2022, 2019, 2016, 2012, 2008, 2003)" + echo " ${BOLD}4${RESET}) Tiny (Tiny11, Tiny10)" + echo " ${BOLD}5${RESET}) All versions" + echo " ${BOLD}6${RESET}) Select individual versions" + echo "" + echo -n " Select [1-6]: " + } >&2 local choice - read -r choice + read -r choice &2 local input - read -r input + read -r input Date: Wed, 28 Jan 2026 23:33:56 +0000 Subject: [PATCH 06/33] feat: Add JSON file cache for container status - Cache container statuses in ~/.cache/winctl/status.json - Auto-refresh if cache older than 7 days or data is stale - Add 'refresh' command to force cache refresh - Dramatically faster menus (single Docker call vs 44 calls) - Bump version to 1.1.0 Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 3 +- winctl.sh | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 183 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acb1456..3e1ccb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,13 @@ All notable changes to this project will be documented in this file. ### Added - **winctl.sh**: Management script for Windows Docker containers - - 12 commands: start, stop, restart, status, logs, shell, stats, build, rebuild, list, inspect, monitor, check + - 13 commands: start, stop, restart, status, logs, shell, stats, build, rebuild, list, inspect, monitor, check, refresh - 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-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) diff --git a/winctl.sh b/winctl.sh index 4130ffd..de8cbb1 100755 --- a/winctl.sh +++ b/winctl.sh @@ -16,6 +16,11 @@ 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 # ============================================================================== @@ -321,12 +326,131 @@ compose_cmd() { fi } -# Cache for container statuses (populated by refresh_status_cache) +# ============================================================================== +# 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 -# Refresh the status cache with a single docker call +# 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 @@ -338,6 +462,15 @@ refresh_status_cache() { 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 @@ -595,6 +728,9 @@ cmd_start() { echo -e " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" echo "" done + + # Refresh cache after state changes + invalidate_cache } cmd_stop() { @@ -650,6 +786,9 @@ cmd_stop() { error "Failed to stop $v" fi done + + # Refresh cache after state changes + invalidate_cache } cmd_restart() { @@ -684,6 +823,9 @@ cmd_restart() { error "Failed to restart $v" fi done + + # Refresh cache after state changes + invalidate_cache } cmd_status() { @@ -846,6 +988,9 @@ cmd_rebuild() { error "Failed to rebuild $v" fi done + + # Refresh cache after state changes + invalidate_cache } cmd_list() { @@ -979,6 +1124,38 @@ cmd_check() { run_all_checks } +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) + echo "" + echo -e " ${BOLD}Cache Info:${RESET}" + echo -e " → File: ${CYAN}${CACHE_FILE}${RESET}" + echo -e " → Age: ${age} seconds" + echo -e " → Max Age: ${CACHE_MAX_AGE} seconds (7 days)" + echo "" + + # 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 + echo -e " ${BOLD}Containers:${RESET} ${GREEN}${cnt_running} running${RESET}, ${RED}${cnt_stopped} stopped${RESET}, ${DIM}${cnt_other} other${RESET}" + echo "" +} + # ============================================================================== # HELP # ============================================================================== @@ -1003,6 +1180,7 @@ show_usage() { printf ' %b Show detailed container info\n' "${BOLD}inspect${RESET}" printf ' %b [interval] Real-time dashboard (default: 5s refresh)\n' "${BOLD}monitor${RESET}" printf ' %b Run prerequisites check\n' "${BOLD}check${RESET}" + printf ' %b Force refresh status cache\n' "${BOLD}refresh${RESET}" printf ' %b Show this help message\n' "${BOLD}help${RESET}" printf '\n' printf '%b\n' "${BOLD}CATEGORIES${RESET}" @@ -1053,6 +1231,7 @@ main() { inspect) cmd_inspect "$@" ;; monitor) cmd_monitor "$@" ;; check) cmd_check "$@" ;; + refresh) cmd_refresh "$@" ;; help|--help|-h) show_usage ;; From 620cda9282cc045c10b69eb8883d3e90788280c9 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 23:38:36 +0000 Subject: [PATCH 07/33] fix: Use printf for ANSI colors in interactive menus Replace echo with printf '%b' in select_category and select_versions to properly interpret ANSI escape sequences. Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/winctl.sh b/winctl.sh index de8cbb1..080583c 100755 --- a/winctl.sh +++ b/winctl.sh @@ -557,15 +557,15 @@ get_versions_by_category() { select_category() { { header "Select Category" - echo "" - echo " ${BOLD}1${RESET}) Desktop (Win 11, 10, 8.1, 7)" - echo " ${BOLD}2${RESET}) Legacy (Vista, XP, 2000)" - echo " ${BOLD}3${RESET}) Server (2025, 2022, 2019, 2016, 2012, 2008, 2003)" - echo " ${BOLD}4${RESET}) Tiny (Tiny11, Tiny10)" - echo " ${BOLD}5${RESET}) All versions" - echo " ${BOLD}6${RESET}) Select individual versions" - echo "" - echo -n " Select [1-6]: " + 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 @@ -605,7 +605,7 @@ select_versions() { { header "Select Version(s)" - echo "" + printf '\n' local i=1 for v in "${versions[@]}"; do @@ -615,15 +615,15 @@ select_versions() { elif container_exists "$v"; then status="${YELLOW}[stopped]${RESET}" fi - printf " ${BOLD}%2d${RESET}) %-10s %-28s %s\n" "$i" "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" + printf ' %b) %-10s %-28s %b\n' "${BOLD}$(printf '%2d' "$i")${RESET}" "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" ((i++)) done - echo "" - echo " ${BOLD} a${RESET}) Select all" - echo " ${BOLD} q${RESET}) Cancel" - echo "" - echo -n " Select (numbers separated by spaces, or 'a' for all): " + printf '\n' + printf ' %b) Select all\n' "${BOLD} a${RESET}" + printf ' %b) Cancel\n' "${BOLD} q${RESET}" + printf '\n' + printf ' Select (numbers separated by spaces, or '\''a'\'' for all): ' } >&2 local input From c27837738df6af96b4d852b0051b2ed370e09e3a Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 23:44:10 +0000 Subject: [PATCH 08/33] refactor: Use separate .env.modern and .env.legacy files - Remove base-legacy.yml and base-modern.yml (YAML anchors don't work with include) - Make all compose files self-contained - Add .env.modern (8G RAM, 4 CPU) for Win 10/11, Server 2016+ - Add .env.legacy (2G RAM, 2 CPU) for older systems - Update README and .env.example documentation Co-Authored-By: Claude Opus 4.5 --- .env.example | 62 ++++++++++++++------------------------ .env.legacy | 26 ++++++++++++++++ .env.modern | 26 ++++++++++++++++ compose/base-legacy.yml | 23 -------------- compose/base-modern.yml | 23 -------------- compose/desktop/win10.yml | 36 ++++++++++++++++------ compose/desktop/win11.yml | 36 ++++++++++++++++------ compose/desktop/win7.yml | 25 ++++++++++----- compose/desktop/win8.yml | 25 ++++++++++----- compose/legacy/vista.yml | 14 ++++++--- compose/legacy/win2k.yml | 14 ++++++--- compose/legacy/winxp.yml | 14 ++++++--- compose/server/win2003.yml | 14 ++++++--- compose/server/win2008.yml | 14 ++++++--- compose/server/win2012.yml | 14 ++++++--- compose/server/win2016.yml | 14 ++++++--- compose/server/win2019.yml | 14 ++++++--- compose/server/win2022.yml | 14 ++++++--- compose/server/win2025.yml | 14 ++++++--- compose/tiny/tiny10.yml | 14 ++++++--- compose/tiny/tiny11.yml | 14 ++++++--- readme.md | 53 +++++++++++--------------------- 22 files changed, 291 insertions(+), 212 deletions(-) create mode 100644 .env.legacy create mode 100644 .env.modern delete mode 100644 compose/base-legacy.yml delete mode 100644 compose/base-modern.yml diff --git a/.env.example b/.env.example index 885715c..a5b7c66 100644 --- a/.env.example +++ b/.env.example @@ -1,40 +1,24 @@ # Windows Docker VM Configuration -# Copy this file to .env and customize - -# =========================================== -# MODERN SYSTEMS (Windows 10/11, Server 2016+) -# =========================================== -MODERN_RAM_SIZE=8G -MODERN_CPU_CORES=4 -MODERN_DISK_SIZE=128G - -# =========================================== -# LEGACY SYSTEMS (Windows 7/8, Vista, XP, 2000, Server 2003-2012) -# =========================================== -LEGACY_RAM_SIZE=2G -LEGACY_CPU_CORES=2 -LEGACY_DISK_SIZE=32G - -# =========================================== -# COMMON SETTINGS (applies to all) -# =========================================== - -# 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 - -# Debug -DEBUG=N +# +# 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) +# DEBUG=N # Debug mode (Y/N) diff --git a/.env.legacy b/.env.legacy new file mode 100644 index 0000000..c3edd37 --- /dev/null +++ b/.env.legacy @@ -0,0 +1,26 @@ +# 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 + +# Debug +DEBUG=N diff --git a/.env.modern b/.env.modern new file mode 100644 index 0000000..69b969b --- /dev/null +++ b/.env.modern @@ -0,0 +1,26 @@ +# 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 + +# Debug +DEBUG=N diff --git a/compose/base-legacy.yml b/compose/base-legacy.yml deleted file mode 100644 index 3dc8cc0..0000000 --- a/compose/base-legacy.yml +++ /dev/null @@ -1,23 +0,0 @@ -x-legacy: &legacy - image: dockurr/windows - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN - restart: unless-stopped - stop_grace_period: 2m - environment: - RAM_SIZE: ${LEGACY_RAM_SIZE:-2G} - CPU_CORES: ${LEGACY_CPU_CORES:-2} - DISK_SIZE: ${LEGACY_DISK_SIZE:-32G} - USERNAME: ${USERNAME:-Docker} - PASSWORD: ${PASSWORD:-admin} - LANGUAGE: ${LANGUAGE:-en} - REGION: ${REGION:-en-US} - KEYBOARD: ${KEYBOARD:-en-US} - WIDTH: ${WIDTH:-1280} - HEIGHT: ${HEIGHT:-720} - DHCP: ${DHCP:-N} - SAMBA: ${SAMBA:-Y} - DEBUG: ${DEBUG:-N} diff --git a/compose/base-modern.yml b/compose/base-modern.yml deleted file mode 100644 index 178fa19..0000000 --- a/compose/base-modern.yml +++ /dev/null @@ -1,23 +0,0 @@ -x-modern: &modern - image: dockurr/windows - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN - restart: unless-stopped - stop_grace_period: 2m - environment: - RAM_SIZE: ${MODERN_RAM_SIZE:-8G} - CPU_CORES: ${MODERN_CPU_CORES:-4} - DISK_SIZE: ${MODERN_DISK_SIZE:-128G} - USERNAME: ${USERNAME:-Docker} - PASSWORD: ${PASSWORD:-admin} - LANGUAGE: ${LANGUAGE:-en} - REGION: ${REGION:-en-US} - KEYBOARD: ${KEYBOARD:-en-US} - WIDTH: ${WIDTH:-1280} - HEIGHT: ${HEIGHT:-720} - DHCP: ${DHCP:-N} - SAMBA: ${SAMBA:-Y} - DEBUG: ${DEBUG:-N} diff --git a/compose/desktop/win10.yml b/compose/desktop/win10.yml index 1aa4039..d2eeb56 100644 --- a/compose/desktop/win10.yml +++ b/compose/desktop/win10.yml @@ -1,42 +1,60 @@ -include: - - ../base-modern.yml - services: win10: - <<: *modern + image: dockurr/windows container_name: win10 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m win10e: - <<: *modern + image: dockurr/windows container_name: win10e - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m win10l: - <<: *modern + image: dockurr/windows container_name: win10l - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/desktop/win11.yml b/compose/desktop/win11.yml index 9ed4043..f45b6a7 100644 --- a/compose/desktop/win11.yml +++ b/compose/desktop/win11.yml @@ -1,42 +1,60 @@ -include: - - ../base-modern.yml - services: win11: - <<: *modern + image: dockurr/windows container_name: win11 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m win11e: - <<: *modern + image: dockurr/windows container_name: win11e - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m win11l: - <<: *modern + image: dockurr/windows container_name: win11l - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/desktop/win7.yml b/compose/desktop/win7.yml index 7c0a0d5..a7d6a2d 100644 --- a/compose/desktop/win7.yml +++ b/compose/desktop/win7.yml @@ -1,29 +1,40 @@ -include: - - ../base-legacy.yml - services: win7: - <<: *legacy + image: dockurr/windows container_name: win7 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m win7e: - <<: *legacy + image: dockurr/windows container_name: win7e - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/desktop/win8.yml b/compose/desktop/win8.yml index 408ec52..da69bee 100644 --- a/compose/desktop/win8.yml +++ b/compose/desktop/win8.yml @@ -1,29 +1,40 @@ -include: - - ../base-legacy.yml - services: win81: - <<: *legacy + image: dockurr/windows container_name: win81 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m win81e: - <<: *legacy + image: dockurr/windows container_name: win81e - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/legacy/vista.yml b/compose/legacy/vista.yml index cf96b10..3ac93a8 100644 --- a/compose/legacy/vista.yml +++ b/compose/legacy/vista.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: vista: - <<: *legacy + image: dockurr/windows container_name: vista - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/legacy/win2k.yml b/compose/legacy/win2k.yml index e40e3e6..6600ece 100644 --- a/compose/legacy/win2k.yml +++ b/compose/legacy/win2k.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: win2k: - <<: *legacy + image: dockurr/windows container_name: win2k - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/legacy/winxp.yml b/compose/legacy/winxp.yml index cb35412..5c4e2c7 100644 --- a/compose/legacy/winxp.yml +++ b/compose/legacy/winxp.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: winxp: - <<: *legacy + image: dockurr/windows container_name: winxp - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/server/win2003.yml b/compose/server/win2003.yml index dcf96a9..2d9c89a 100644 --- a/compose/server/win2003.yml +++ b/compose/server/win2003.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: win2003: - <<: *legacy + image: dockurr/windows container_name: win2003 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/server/win2008.yml b/compose/server/win2008.yml index c2c6c1b..1449af7 100644 --- a/compose/server/win2008.yml +++ b/compose/server/win2008.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: win2008: - <<: *legacy + image: dockurr/windows container_name: win2008 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/server/win2012.yml b/compose/server/win2012.yml index 56f0514..e32dc9c 100644 --- a/compose/server/win2012.yml +++ b/compose/server/win2012.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: win2012: - <<: *legacy + image: dockurr/windows container_name: win2012 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/server/win2016.yml b/compose/server/win2016.yml index 204388f..6b5850d 100644 --- a/compose/server/win2016.yml +++ b/compose/server/win2016.yml @@ -1,16 +1,20 @@ -include: - - ../base-modern.yml - services: win2016: - <<: *modern + image: dockurr/windows container_name: win2016 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/server/win2019.yml b/compose/server/win2019.yml index 73a0821..e43c782 100644 --- a/compose/server/win2019.yml +++ b/compose/server/win2019.yml @@ -1,16 +1,20 @@ -include: - - ../base-modern.yml - services: win2019: - <<: *modern + image: dockurr/windows container_name: win2019 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/server/win2022.yml b/compose/server/win2022.yml index 5ee9c35..3576123 100644 --- a/compose/server/win2022.yml +++ b/compose/server/win2022.yml @@ -1,16 +1,20 @@ -include: - - ../base-modern.yml - services: win2022: - <<: *modern + image: dockurr/windows container_name: win2022 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/server/win2025.yml b/compose/server/win2025.yml index c01b6ed..85ac4a9 100644 --- a/compose/server/win2025.yml +++ b/compose/server/win2025.yml @@ -1,16 +1,20 @@ -include: - - ../base-modern.yml - services: win2025: - <<: *modern + image: dockurr/windows container_name: win2025 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/tiny/tiny10.yml b/compose/tiny/tiny10.yml index b5a730f..2a7137b 100644 --- a/compose/tiny/tiny10.yml +++ b/compose/tiny/tiny10.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: tiny10: - <<: *legacy + image: dockurr/windows container_name: tiny10 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/compose/tiny/tiny11.yml b/compose/tiny/tiny11.yml index 416ff54..ad5c022 100644 --- a/compose/tiny/tiny11.yml +++ b/compose/tiny/tiny11.yml @@ -1,16 +1,20 @@ -include: - - ../base-legacy.yml - services: tiny11: - <<: *legacy + image: dockurr/windows container_name: tiny11 - env_file: ../../.env + 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: unless-stopped + stop_grace_period: 2m diff --git a/readme.md b/readme.md index 8818e1f..ff56577 100644 --- a/readme.md +++ b/readme.md @@ -112,39 +112,27 @@ Use `winctl.sh` for easy container management: ### Quick Start (Manual) ```bash -# Copy environment template -cp .env.example .env - -# Run default (Windows 11) -docker compose up - # Run specific version docker compose -f compose/desktop/win11.yml up win11 docker compose -f compose/legacy/winxp.yml up winxp - -# Run all desktop versions -docker compose -f compose/desktop.yml up - -# Run everything -docker compose -f compose/all.yml up ``` ### Configuration -Edit `.env` to customize resources for all VMs: +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 -# Modern systems (Win 10/11, Server 2016+) -MODERN_RAM_SIZE=8G -MODERN_CPU_CORES=4 -MODERN_DISK_SIZE=128G - -# Legacy systems (Win 7/8, Vista, XP, 2000, Server 2003-2012, Tiny) -LEGACY_RAM_SIZE=2G -LEGACY_CPU_CORES=2 -LEGACY_DISK_SIZE=32G - -# Common settings +# .env.modern or .env.legacy +RAM_SIZE=8G +CPU_CORES=4 +DISK_SIZE=128G USERNAME=Docker PASSWORD=admin LANGUAGE=en @@ -153,18 +141,13 @@ LANGUAGE=en ### Folder Structure ``` +.env.modern # Modern system defaults (8G RAM) +.env.legacy # Legacy system defaults (2G RAM) compose/ -├── base-modern.yml # High-resource profile -├── base-legacy.yml # Low-resource profile -├── all.yml # All versions -├── desktop.yml # Desktop versions -├── legacy.yml # Vista, XP, 2000 -├── server.yml # Server versions -├── tiny.yml # Tiny versions -├── desktop/ # Individual desktop configs -├── legacy/ # Individual legacy configs -├── server/ # Individual server configs -└── tiny/ # Individual tiny configs +├── desktop/ # Win 11, 10, 8.1, 7 +├── legacy/ # Vista, XP, 2000 +├── server/ # Server 2003-2025 +└── tiny/ # Tiny10, Tiny11 data/ # VM storage (per-version folders) ├── win11/ From 99053a02389aeba8d02ed2ecc538a9f1deff0cca Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 23:53:24 +0000 Subject: [PATCH 09/33] chore: Use lowercase username 'docker' Co-Authored-By: Claude Opus 4.5 --- .env.legacy | 2 +- .env.modern | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.legacy b/.env.legacy index c3edd37..feea471 100644 --- a/.env.legacy +++ b/.env.legacy @@ -6,7 +6,7 @@ CPU_CORES=2 DISK_SIZE=32G # User Credentials -USERNAME=Docker +USERNAME=docker PASSWORD=admin # Language & Region diff --git a/.env.modern b/.env.modern index 69b969b..9e32268 100644 --- a/.env.modern +++ b/.env.modern @@ -6,7 +6,7 @@ CPU_CORES=4 DISK_SIZE=128G # User Credentials -USERNAME=Docker +USERNAME=docker PASSWORD=admin # Language & Region From e78fed6bd39eea82ec1372aa7c9977c454ab83f2 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Wed, 28 Jan 2026 23:55:51 +0000 Subject: [PATCH 10/33] feat: Auto-create data directory before starting container Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/winctl.sh b/winctl.sh index 080583c..c94578d 100755 --- a/winctl.sh +++ b/winctl.sh @@ -709,6 +709,13 @@ cmd_start() { 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 + if is_running "$v"; then info "$v is already running" else @@ -973,6 +980,13 @@ cmd_rebuild() { for v in "${versions[@]}"; do 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 From 68e1cce00279472804f2a9f8eb48a28d1f5c34b5 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 00:09:19 +0000 Subject: [PATCH 11/33] docs: Add comprehensive winctl.sh user guide Co-Authored-By: Claude Opus 4.5 --- WINCTL_GUIDE.md | 704 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 WINCTL_GUIDE.md diff --git a/WINCTL_GUIDE.md b/WINCTL_GUIDE.md new file mode 100644 index 0000000..c812199 --- /dev/null +++ b/WINCTL_GUIDE.md @@ -0,0 +1,704 @@ +# 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) +- [Configuration](#configuration) +- [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 +- **Status caching** for fast performance +- **Resource profiles** optimized for modern and legacy systems + +### 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 | + +### 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! +``` + +### 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 +``` + +- **Web Viewer**: Open in browser for quick access +- **RDP**: Use any RDP client for better performance + +### 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. Creates data directory if missing +3. Checks available resources +4. Starts the container +5. Shows connection details + +--- + +### 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 + +# 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 +``` + +--- + +### 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) +``` + +--- + +### 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 +``` + +--- + +### 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 + +--- + +## 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 +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 | +| `DEBUG` | Debug mode | N | + +--- + +## 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: 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 +``` + +--- + +## 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 → Stop other containers or services +- Not enough disk space → Free up space or reduce DISK_SIZE + +### 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 via Data Backup + +The VM disk is stored in `data//`. Back it up to create a snapshot: +```bash +./winctl.sh stop win11 +cp -r data/win11 data/win11-backup +./winctl.sh start win11 +``` + +### 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 +├── data/ +│ ├── win11/ # Win11 VM storage +│ ├── win10/ # Win10 VM storage +│ └── ... # Other VM storage +└── ~/.cache/winctl/ + └── status.json # Status cache +``` + +--- + +## Getting Help + +```bash +# Show all commands +./winctl.sh help + +# Check system requirements +./winctl.sh check + +# List all versions +./winctl.sh list +``` + +For issues, visit: https://github.com/dockur/windows/issues From 831531eeb4372d5dbd96d10b60be406cde4a8cf4 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 00:11:01 +0000 Subject: [PATCH 12/33] docs: Fix username case and add link to winctl guide - Changed USERNAME example from Docker to docker (lowercase) - Added link to WINCTL_GUIDE.md for complete documentation Co-Authored-By: Claude Opus 4.5 --- readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index ff56577..501d112 100644 --- a/readme.md +++ b/readme.md @@ -133,11 +133,13 @@ Edit these files to customize: RAM_SIZE=8G CPU_CORES=4 DISK_SIZE=128G -USERNAME=Docker +USERNAME=docker PASSWORD=admin LANGUAGE=en ``` +> 📖 See [WINCTL_GUIDE.md](WINCTL_GUIDE.md) for complete documentation, usage scenarios, and troubleshooting. + ### Folder Structure ``` From 5b274afbbacd68293707e49d3711813ac3b50973 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 00:32:34 +0000 Subject: [PATCH 13/33] fix: Monitor command exiting immediately due to arithmetic error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed post-increment to pre-increment in cmd_monitor ((total_count++)) → ((++total_count)) to avoid set -e exit on ((0)) - Added invalidate_cache for accurate real-time status updates Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/winctl.sh b/winctl.sh index c94578d..01ae912 100755 --- a/winctl.sh +++ b/winctl.sh @@ -1097,6 +1097,9 @@ cmd_monitor() { echo "" while true; do + # Refresh cache for accurate status + invalidate_cache + clear echo -e "${BOLD}${CYAN}Windows Container Monitor${RESET} - $(date '+%Y-%m-%d %H:%M:%S')" echo -e "${DIM}$(printf '─%.0s' {1..70})${RESET}" @@ -1111,11 +1114,11 @@ cmd_monitor() { local status status=$(get_status "$v") if [[ "$status" != "not created" ]]; then - ((total_count++)) + ((++total_count)) if [[ "$status" == "running" ]]; then - ((running_count++)) + ((++running_count)) else - ((stopped_count++)) + ((++stopped_count)) fi table_row "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" "${VERSION_PORTS_WEB[$v]}" "${VERSION_PORTS_RDP[$v]}" fi From 23cc43470301475910130865a8c92f5f32471b35 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 00:46:11 +0000 Subject: [PATCH 14/33] feat: Add configurable restart policy, default to on-failure - Changed restart policy from unless-stopped to on-failure so Windows shutdown stops the container instead of restarting - Added RESTART_POLICY env variable to .env.modern and .env.legacy - Compose files use ${RESTART_POLICY:-on-failure} for flexibility - Updated WINCTL_GUIDE.md with restart policy documentation - Updated readme.md with RESTART_POLICY setting Co-Authored-By: Claude Opus 4.5 --- .env.example | 1 + .env.legacy | 3 +++ .env.modern | 3 +++ WINCTL_GUIDE.md | 13 +++++++++++++ compose/desktop/win10.yml | 6 +++--- compose/desktop/win11.yml | 6 +++--- compose/desktop/win7.yml | 4 ++-- compose/desktop/win8.yml | 4 ++-- compose/legacy/vista.yml | 2 +- compose/legacy/win2k.yml | 2 +- compose/legacy/winxp.yml | 2 +- compose/server/win2003.yml | 2 +- compose/server/win2008.yml | 2 +- compose/server/win2012.yml | 2 +- compose/server/win2016.yml | 2 +- compose/server/win2019.yml | 2 +- compose/server/win2022.yml | 2 +- compose/server/win2025.yml | 2 +- compose/tiny/tiny10.yml | 2 +- compose/tiny/tiny11.yml | 2 +- readme.md | 1 + 21 files changed, 43 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index a5b7c66..c37b0cc 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,5 @@ # 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) diff --git a/.env.legacy b/.env.legacy index feea471..31aea64 100644 --- a/.env.legacy +++ b/.env.legacy @@ -22,5 +22,8 @@ HEIGHT=720 DHCP=N SAMBA=Y +# Restart Policy (no, on-failure, always, unless-stopped) +RESTART_POLICY=on-failure + # Debug DEBUG=N diff --git a/.env.modern b/.env.modern index 9e32268..40819d0 100644 --- a/.env.modern +++ b/.env.modern @@ -22,5 +22,8 @@ HEIGHT=720 DHCP=N SAMBA=Y +# Restart Policy (no, on-failure, always, unless-stopped) +RESTART_POLICY=on-failure + # Debug DEBUG=N diff --git a/WINCTL_GUIDE.md b/WINCTL_GUIDE.md index c812199..48d4d99 100644 --- a/WINCTL_GUIDE.md +++ b/WINCTL_GUIDE.md @@ -410,6 +410,7 @@ REGION=en-US KEYBOARD=en-US DHCP=N SAMBA=Y +RESTART_POLICY=on-failure DEBUG=N ``` @@ -429,8 +430,20 @@ DEBUG=N | `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 | +### 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. + --- ## Interactive Menus diff --git a/compose/desktop/win10.yml b/compose/desktop/win10.yml index d2eeb56..610210f 100644 --- a/compose/desktop/win10.yml +++ b/compose/desktop/win10.yml @@ -16,7 +16,7 @@ services: - 3310:3389/udp volumes: - ../../data/win10:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m win10e: @@ -36,7 +36,7 @@ services: - 3314:3389/udp volumes: - ../../data/win10e:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m win10l: @@ -56,5 +56,5 @@ services: - 3315:3389/udp volumes: - ../../data/win10l:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/desktop/win11.yml b/compose/desktop/win11.yml index f45b6a7..065ad04 100644 --- a/compose/desktop/win11.yml +++ b/compose/desktop/win11.yml @@ -16,7 +16,7 @@ services: - 3311:3389/udp volumes: - ../../data/win11:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m win11e: @@ -36,7 +36,7 @@ services: - 3312:3389/udp volumes: - ../../data/win11e:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m win11l: @@ -56,5 +56,5 @@ services: - 3313:3389/udp volumes: - ../../data/win11l:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/desktop/win7.yml b/compose/desktop/win7.yml index a7d6a2d..091e217 100644 --- a/compose/desktop/win7.yml +++ b/compose/desktop/win7.yml @@ -16,7 +16,7 @@ services: - 3307:3389/udp volumes: - ../../data/win7:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m win7e: @@ -36,5 +36,5 @@ services: - 3371:3389/udp volumes: - ../../data/win7e:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/desktop/win8.yml b/compose/desktop/win8.yml index da69bee..8440c6b 100644 --- a/compose/desktop/win8.yml +++ b/compose/desktop/win8.yml @@ -16,7 +16,7 @@ services: - 3308:3389/udp volumes: - ../../data/win81:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m win81e: @@ -36,5 +36,5 @@ services: - 3381:3389/udp volumes: - ../../data/win81e:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/legacy/vista.yml b/compose/legacy/vista.yml index 3ac93a8..ba0ec05 100644 --- a/compose/legacy/vista.yml +++ b/compose/legacy/vista.yml @@ -16,5 +16,5 @@ services: - 3306:3389/udp volumes: - ../../data/vista:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/legacy/win2k.yml b/compose/legacy/win2k.yml index 6600ece..68db30e 100644 --- a/compose/legacy/win2k.yml +++ b/compose/legacy/win2k.yml @@ -16,5 +16,5 @@ services: - 3300:3389/udp volumes: - ../../data/win2k:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/legacy/winxp.yml b/compose/legacy/winxp.yml index 5c4e2c7..0280663 100644 --- a/compose/legacy/winxp.yml +++ b/compose/legacy/winxp.yml @@ -16,5 +16,5 @@ services: - 3305:3389/udp volumes: - ../../data/winxp:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/server/win2003.yml b/compose/server/win2003.yml index 2d9c89a..7ee1c41 100644 --- a/compose/server/win2003.yml +++ b/compose/server/win2003.yml @@ -16,5 +16,5 @@ services: - 3303:3389/udp volumes: - ../../data/win2003:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/server/win2008.yml b/compose/server/win2008.yml index 1449af7..46a73ac 100644 --- a/compose/server/win2008.yml +++ b/compose/server/win2008.yml @@ -16,5 +16,5 @@ services: - 3208:3389/udp volumes: - ../../data/win2008:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/server/win2012.yml b/compose/server/win2012.yml index e32dc9c..8a45438 100644 --- a/compose/server/win2012.yml +++ b/compose/server/win2012.yml @@ -16,5 +16,5 @@ services: - 3212:3389/udp volumes: - ../../data/win2012:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/server/win2016.yml b/compose/server/win2016.yml index 6b5850d..823f947 100644 --- a/compose/server/win2016.yml +++ b/compose/server/win2016.yml @@ -16,5 +16,5 @@ services: - 3316:3389/udp volumes: - ../../data/win2016:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/server/win2019.yml b/compose/server/win2019.yml index e43c782..4eb0439 100644 --- a/compose/server/win2019.yml +++ b/compose/server/win2019.yml @@ -16,5 +16,5 @@ services: - 3319:3389/udp volumes: - ../../data/win2019:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/server/win2022.yml b/compose/server/win2022.yml index 3576123..51c7dbe 100644 --- a/compose/server/win2022.yml +++ b/compose/server/win2022.yml @@ -16,5 +16,5 @@ services: - 3322:3389/udp volumes: - ../../data/win2022:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/server/win2025.yml b/compose/server/win2025.yml index 85ac4a9..8ede87c 100644 --- a/compose/server/win2025.yml +++ b/compose/server/win2025.yml @@ -16,5 +16,5 @@ services: - 3325:3389/udp volumes: - ../../data/win2025:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/tiny/tiny10.yml b/compose/tiny/tiny10.yml index 2a7137b..a7f03e6 100644 --- a/compose/tiny/tiny10.yml +++ b/compose/tiny/tiny10.yml @@ -16,5 +16,5 @@ services: - 3110:3389/udp volumes: - ../../data/tiny10:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/compose/tiny/tiny11.yml b/compose/tiny/tiny11.yml index ad5c022..06cc891 100644 --- a/compose/tiny/tiny11.yml +++ b/compose/tiny/tiny11.yml @@ -16,5 +16,5 @@ services: - 3111:3389/udp volumes: - ../../data/tiny11:/storage - restart: unless-stopped + restart: ${RESTART_POLICY:-on-failure} stop_grace_period: 2m diff --git a/readme.md b/readme.md index 501d112..8ea61b8 100644 --- a/readme.md +++ b/readme.md @@ -136,6 +136,7 @@ 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. From 8864309c7de009c1a0edc6fc4fe6452b53548bda Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 00:54:47 +0000 Subject: [PATCH 15/33] feat: Add 'stop all' to stop all running containers - ./winctl.sh stop all finds and stops all running containers - Updated help, WINCTL_GUIDE.md, and readme.md Co-Authored-By: Claude Opus 4.5 --- WINCTL_GUIDE.md | 3 +++ readme.md | 1 + winctl.sh | 20 +++++++++++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/WINCTL_GUIDE.md b/WINCTL_GUIDE.md index 48d4d99..94e89fe 100644 --- a/WINCTL_GUIDE.md +++ b/WINCTL_GUIDE.md @@ -173,6 +173,9 @@ Stop containers with a 2-minute grace period for clean shutdown. # Stop multiple versions ./winctl.sh stop win11 win10 +# Stop all running containers +./winctl.sh stop all + # Interactive menu ./winctl.sh stop ``` diff --git a/readme.md b/readme.md index 8ea61b8..71aa872 100644 --- a/readme.md +++ b/readme.md @@ -91,6 +91,7 @@ Use `winctl.sh` for easy container management: # Stop containers (with confirmation) ./winctl.sh stop win11 +./winctl.sh stop all # Stop all running # View logs ./winctl.sh logs win11 -f diff --git a/winctl.sh b/winctl.sh index 01ae912..c8673f8 100755 --- a/winctl.sh +++ b/winctl.sh @@ -743,6 +743,23 @@ cmd_start() { cmd_stop() { local versions=("$@") + # Stop all running containers + 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 + 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 @@ -1185,7 +1202,7 @@ show_usage() { printf '\n' printf '%b\n' "${BOLD}COMMANDS${RESET}" printf ' %b [version...] Start container(s), interactive if no version\n' "${BOLD}start${RESET}" - printf ' %b [version...] Stop container(s) with 2-min grace period\n' "${BOLD}stop${RESET}" + printf ' %b [version...|all] Stop container(s) or all running\n' "${BOLD}stop${RESET}" printf ' %b [version...] Restart container(s)\n' "${BOLD}restart${RESET}" printf ' %b [version...] Show status of container(s)\n' "${BOLD}status${RESET}" printf ' %b [-f] View container logs (-f to follow)\n' "${BOLD}logs${RESET}" @@ -1211,6 +1228,7 @@ show_usage() { 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}" From d75f6b23b1e788993b1e882375bb040ecbb7a1af Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 01:51:05 +0000 Subject: [PATCH 16/33] fix: Replace all echo with printf for reliable ANSI color output - Changed color definitions from '\033[...]' to $'\033[...]' (actual escape bytes instead of literal strings) - Replaced all display echo -e/echo -n/echo with printf - Kept echo only for function return values and JSON output - Fixes ANSI escape codes showing as raw text in list/inspect/monitor Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 182 +++++++++++++++++++++++++++--------------------------- 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/winctl.sh b/winctl.sh index c8673f8..a0f78eb 100755 --- a/winctl.sh +++ b/winctl.sh @@ -26,16 +26,16 @@ readonly CACHE_MAX_AGE=$((7 * 24 * 60 * 60)) # 7 days in seconds # ============================================================================== 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' + 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='' @@ -147,19 +147,19 @@ readonly LEGACY_DISK_GB=32 # ============================================================================== info() { - echo -e "${BLUE}[INFO]${RESET} $*" + printf '%s\n' "${BLUE}[INFO]${RESET} $*" } success() { - echo -e "${GREEN}[OK]${RESET} $*" + printf '%s\n' "${GREEN}[OK]${RESET} $*" } warn() { - echo -e "${YELLOW}[WARN]${RESET} $*" + printf '%s\n' "${YELLOW}[WARN]${RESET} $*" } error() { - echo -e "${RED}[ERROR]${RESET} $*" >&2 + printf '%s\n' "${RED}[ERROR]${RESET} $*" >&2 } die() { @@ -168,9 +168,9 @@ die() { } header() { - echo "" - echo -e "${BOLD}${CYAN}$*${RESET}" - echo -e "${DIM}$(printf '─%.0s' {1..60})${RESET}" + printf '\n' + printf '%s\n' "${BOLD}${CYAN}$*${RESET}" + printf '%s\n' "${DIM}$(printf '─%.0s' {1..60})${RESET}" } # Print a formatted table row @@ -188,15 +188,15 @@ table_row() { *) status_color="${YELLOW}" ;; esac - printf " ${BOLD}%-12s${RESET} %-26s ${status_color}%-10s${RESET} %-8s %-8s\n" \ - "$version" "$name" "$status" "$web" "$rdp" + printf " %s%-12s%s %-26s %s%-10s%s %-8s %-8s\n" \ + "${BOLD}" "$version" "${RESET}" "$name" "$status_color" "$status" "${RESET}" "$web" "$rdp" } table_header() { - echo "" - printf " ${BOLD}${DIM}%-12s %-26s %-10s %-8s %-8s${RESET}\n" \ - "VERSION" "NAME" "STATUS" "WEB" "RDP" - echo -e " ${DIM}$(printf '─%.0s' {1..66})${RESET}" + 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}" } # ============================================================================== @@ -206,13 +206,13 @@ table_header() { check_docker() { if ! command -v docker &>/dev/null; then error "Docker is not installed" - echo " Install Docker: https://docs.docker.com/get-docker/" + 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" - echo " Start Docker: sudo systemctl start docker" + printf '%s\n' " Start Docker: sudo systemctl start docker" return 1 fi @@ -229,7 +229,7 @@ check_compose() { return 0 else error "Docker Compose is not installed" - echo " Install: https://docs.docker.com/compose/install/" + printf '%s\n' " Install: https://docs.docker.com/compose/install/" return 1 fi } @@ -237,13 +237,13 @@ check_compose() { check_kvm() { if [[ ! -e /dev/kvm ]]; then error "KVM device not found (/dev/kvm)" - echo " Enable virtualization in BIOS or check nested virtualization" + 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" - echo " Fix: sudo usermod -aG kvm \$USER && newgrp kvm" + printf '%s\n' " Fix: sudo usermod -aG kvm \$USER && newgrp kvm" return 1 fi @@ -303,7 +303,7 @@ run_all_checks() { check_memory || true # Warning only check_disk || true # Warning only - echo "" + printf '\n' if ((failed > 0)); then error "Some critical checks failed. Please fix the issues above." return 1 @@ -729,11 +729,11 @@ cmd_start() { fi # Show connection info - echo "" - echo -e " ${BOLD}Connection Details:${RESET}" - echo -e " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" - echo -e " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" - echo "" + printf '\n' + printf '%s\n' " ${BOLD}Connection Details:${RESET}" + printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" + printf '%s\n' " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" + printf '\n' done # Refresh cache after state changes @@ -776,8 +776,8 @@ cmd_stop() { # Show confirmation header "Stopping Containers" - echo "" - echo " The following containers will be stopped:" + printf '\n' + printf '%s\n' " The following containers will be stopped:" for v in "${versions[@]}"; do local status if is_running "$v"; then @@ -785,10 +785,10 @@ cmd_stop() { else status="${YELLOW}not running${RESET}" fi - echo -e " • $v (${VERSION_DISPLAY_NAMES[$v]}) - $status" + printf '%s\n' " • $v (${VERSION_DISPLAY_NAMES[$v]}) - $status" done - echo "" - echo -n " Continue? [y/N]: " + printf '\n' + printf '%s' " Continue? [y/N]: " local confirm read -r confirm @@ -838,11 +838,11 @@ cmd_restart() { info "Restarting $v..." if run_compose "$v" restart "$v"; then success "$v restarted" - echo "" - echo -e " ${BOLD}Connection Details:${RESET}" - echo -e " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" - echo -e " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" - echo "" + printf '\n' + printf '%s\n' " ${BOLD}Connection Details:${RESET}" + printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" + printf '%s\n' " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" + printf '\n' else error "Failed to restart $v" fi @@ -871,7 +871,7 @@ cmd_status() { status=$(get_status "$v") table_row "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" "${VERSION_PORTS_WEB[$v]}" "${VERSION_PORTS_RDP[$v]}" done - echo "" + printf '\n' } cmd_logs() { @@ -977,15 +977,15 @@ cmd_rebuild() { # Show warning header "⚠️ Rebuild Containers" - echo "" - echo -e " ${RED}${BOLD}WARNING: This will destroy and recreate the following containers.${RESET}" - echo -e " ${RED}Data in /storage volumes will be preserved.${RESET}" - echo "" + 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 - echo " • $v (${VERSION_DISPLAY_NAMES[$v]})" + printf '%s\n' " • $v (${VERSION_DISPLAY_NAMES[$v]})" done - echo "" - echo -n " Type 'yes' to confirm: " + printf '\n' + printf '%s' " Type 'yes' to confirm: " local confirm read -r confirm @@ -1010,11 +1010,11 @@ cmd_rebuild() { info "Recreating $v..." if run_compose "$v" up -d "$v"; then success "$v rebuilt successfully" - echo "" - echo -e " ${BOLD}Connection Details:${RESET}" - echo -e " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" - echo -e " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" - echo "" + printf '\n' + printf '%s\n' " ${BOLD}Connection Details:${RESET}" + printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" + printf '%s\n' " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${RESET}" + printf '\n' else error "Failed to rebuild $v" fi @@ -1042,11 +1042,11 @@ cmd_list() { esac for cat in "${categories[@]}"; do - echo "" + printf '\n' local cat_upper cat_upper=$(echo "$cat" | tr '[:lower:]' '[:upper:]') - echo -e " ${BOLD}${cat_upper}${RESET}" - echo -e " ${DIM}$(printf '─%.0s' {1..50})${RESET}" + 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 @@ -1066,7 +1066,7 @@ cmd_list() { fi done done - echo "" + printf '\n' } cmd_inspect() { @@ -1079,19 +1079,19 @@ cmd_inspect() { validate_version "$version" || exit 1 header "Container Details: $version" - echo "" - echo -e " ${BOLD}Version:${RESET} $version" - echo -e " ${BOLD}Name:${RESET} ${VERSION_DISPLAY_NAMES[$version]}" - echo -e " ${BOLD}Category:${RESET} ${VERSION_CATEGORIES[$version]}" - echo -e " ${BOLD}Status:${RESET} $(get_status "$version")" - echo -e " ${BOLD}Web Port:${RESET} ${VERSION_PORTS_WEB[$version]}" - echo -e " ${BOLD}RDP Port:${RESET} ${VERSION_PORTS_RDP[$version]}" - echo -e " ${BOLD}Resources:${RESET} ${VERSION_RESOURCE_TYPE[$version]}" - echo -e " ${BOLD}Compose:${RESET} ${VERSION_COMPOSE_FILES[$version]}" - echo "" + printf '\n' + printf '%s\n' " ${BOLD}Version:${RESET} $version" + printf '%s\n' " ${BOLD}Name:${RESET} ${VERSION_DISPLAY_NAMES[$version]}" + printf '%s\n' " ${BOLD}Category:${RESET} ${VERSION_CATEGORIES[$version]}" + printf '%s\n' " ${BOLD}Status:${RESET} $(get_status "$version")" + printf '%s\n' " ${BOLD}Web Port:${RESET} ${VERSION_PORTS_WEB[$version]}" + printf '%s\n' " ${BOLD}RDP Port:${RESET} ${VERSION_PORTS_RDP[$version]}" + printf '%s\n' " ${BOLD}Resources:${RESET} ${VERSION_RESOURCE_TYPE[$version]}" + printf '%s\n' " ${BOLD}Compose:${RESET} ${VERSION_COMPOSE_FILES[$version]}" + printf '\n' if container_exists "$version"; then - echo -e " ${BOLD}Docker Info:${RESET}" + printf '%s\n' " ${BOLD}Docker Info:${RESET}" docker inspect "$version" --format ' Image: {{.Config.Image}} Created: {{.Created}} @@ -1099,7 +1099,7 @@ cmd_inspect() { Mounts: {{range .Mounts}}{{.Source}} -> {{.Destination}} {{end}}' 2>/dev/null || true fi - echo "" + printf '\n' } cmd_monitor() { @@ -1110,16 +1110,16 @@ cmd_monitor() { fi header "Real-time Monitor (refresh: ${interval}s)" - echo " Press Ctrl+C to exit" - echo "" + printf '%s\n' " Press Ctrl+C to exit" + printf '\n' while true; do # Refresh cache for accurate status invalidate_cache clear - echo -e "${BOLD}${CYAN}Windows Container Monitor${RESET} - $(date '+%Y-%m-%d %H:%M:%S')" - echo -e "${DIM}$(printf '─%.0s' {1..70})${RESET}" + 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 @@ -1142,13 +1142,13 @@ cmd_monitor() { done if [[ $total_count -eq 0 ]]; then - echo -e " ${DIM}No containers found${RESET}" + printf '%s\n' " ${DIM}No containers found${RESET}" fi - echo "" - echo -e " ${BOLD}Summary:${RESET} ${GREEN}$running_count running${RESET}, ${RED}$stopped_count stopped${RESET}, $total_count total" - echo "" - echo -e " ${DIM}Refreshing in ${interval}s... (Ctrl+C to exit)${RESET}" + 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 @@ -1170,12 +1170,12 @@ cmd_refresh() { # Show cache info local age age=$(get_cache_age) - echo "" - echo -e " ${BOLD}Cache Info:${RESET}" - echo -e " → File: ${CYAN}${CACHE_FILE}${RESET}" - echo -e " → Age: ${age} seconds" - echo -e " → Max Age: ${CACHE_MAX_AGE} seconds (7 days)" - echo "" + 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 @@ -1186,8 +1186,8 @@ cmd_refresh() { *) ((cnt_other++)) || true ;; esac done - echo -e " ${BOLD}Containers:${RESET} ${GREEN}${cnt_running} running${RESET}, ${RED}${cnt_stopped} stopped${RESET}, ${DIM}${cnt_other} other${RESET}" - echo "" + printf '%s\n' " ${BOLD}Containers:${RESET} ${GREEN}${cnt_running} running${RESET}, ${RED}${cnt_stopped} stopped${RESET}, ${DIM}${cnt_other} other${RESET}" + printf '\n' } # ============================================================================== @@ -1276,7 +1276,7 @@ main() { ;; *) error "Unknown command: $command" - echo "Run '${SCRIPT_NAME} help' for usage information" + printf '%s\n' "Run '${SCRIPT_NAME} help' for usage information" exit 1 ;; esac From 6a0b6a1511501afb32aaedbacfc9790fa8d4a9c4 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 07:24:03 +0000 Subject: [PATCH 17/33] feat: Add ARM64 architecture auto-detection and image selection Auto-detect machine architecture via uname and use the correct Docker image (dockurr/windows for x86, dockurr/windows-arm for ARM64). Block unsupported versions on ARM with a clear error message, and show [x86 only] tags in the list command. Compose files now use a configurable WINDOWS_IMAGE variable with a default fallback. Co-Authored-By: Claude Opus 4.5 --- .env.example | 1 + .env.legacy | 3 ++ .env.modern | 3 ++ WINCTL_GUIDE.md | 22 ++++++++++++++ compose/desktop/win10.yml | 6 ++-- compose/desktop/win11.yml | 6 ++-- compose/desktop/win7.yml | 4 +-- compose/desktop/win8.yml | 4 +-- compose/legacy/vista.yml | 2 +- compose/legacy/win2k.yml | 2 +- compose/legacy/winxp.yml | 2 +- compose/server/win2003.yml | 2 +- compose/server/win2008.yml | 2 +- compose/server/win2012.yml | 2 +- compose/server/win2016.yml | 2 +- compose/server/win2019.yml | 2 +- compose/server/win2022.yml | 2 +- compose/server/win2025.yml | 2 +- compose/tiny/tiny10.yml | 2 +- compose/tiny/tiny11.yml | 2 +- readme.md | 2 +- winctl.sh | 59 +++++++++++++++++++++++++++++++++++++- 22 files changed, 110 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index c37b0cc..56bd37c 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,4 @@ # 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) diff --git a/.env.legacy b/.env.legacy index 31aea64..9f72db3 100644 --- a/.env.legacy +++ b/.env.legacy @@ -25,5 +25,8 @@ 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 index 40819d0..ac5e811 100644 --- a/.env.modern +++ b/.env.modern @@ -25,5 +25,8 @@ 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/WINCTL_GUIDE.md b/WINCTL_GUIDE.md index 94e89fe..e2835cb 100644 --- a/WINCTL_GUIDE.md +++ b/WINCTL_GUIDE.md @@ -35,6 +35,27 @@ A comprehensive guide to managing Windows Docker containers with `winctl.sh`. | **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: @@ -435,6 +456,7 @@ DEBUG=N | `SAMBA` | Enable file sharing | Y | | `RESTART_POLICY` | Container restart policy | on-failure | | `DEBUG` | Debug mode | N | +| `WINDOWS_IMAGE` | Docker image | dockurr/windows | ### Restart Policy Options diff --git a/compose/desktop/win10.yml b/compose/desktop/win10.yml index 610210f..a88e417 100644 --- a/compose/desktop/win10.yml +++ b/compose/desktop/win10.yml @@ -1,6 +1,6 @@ services: win10: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win10 env_file: ../../.env.modern environment: @@ -20,7 +20,7 @@ services: stop_grace_period: 2m win10e: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win10e env_file: ../../.env.modern environment: @@ -40,7 +40,7 @@ services: stop_grace_period: 2m win10l: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win10l env_file: ../../.env.modern environment: diff --git a/compose/desktop/win11.yml b/compose/desktop/win11.yml index 065ad04..fa3eeaa 100644 --- a/compose/desktop/win11.yml +++ b/compose/desktop/win11.yml @@ -1,6 +1,6 @@ services: win11: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win11 env_file: ../../.env.modern environment: @@ -20,7 +20,7 @@ services: stop_grace_period: 2m win11e: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win11e env_file: ../../.env.modern environment: @@ -40,7 +40,7 @@ services: stop_grace_period: 2m win11l: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win11l env_file: ../../.env.modern environment: diff --git a/compose/desktop/win7.yml b/compose/desktop/win7.yml index 091e217..aa24640 100644 --- a/compose/desktop/win7.yml +++ b/compose/desktop/win7.yml @@ -1,6 +1,6 @@ services: win7: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win7 env_file: ../../.env.legacy environment: @@ -20,7 +20,7 @@ services: stop_grace_period: 2m win7e: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win7e env_file: ../../.env.legacy environment: diff --git a/compose/desktop/win8.yml b/compose/desktop/win8.yml index 8440c6b..f91b98d 100644 --- a/compose/desktop/win8.yml +++ b/compose/desktop/win8.yml @@ -1,6 +1,6 @@ services: win81: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win81 env_file: ../../.env.legacy environment: @@ -20,7 +20,7 @@ services: stop_grace_period: 2m win81e: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win81e env_file: ../../.env.legacy environment: diff --git a/compose/legacy/vista.yml b/compose/legacy/vista.yml index ba0ec05..8df7848 100644 --- a/compose/legacy/vista.yml +++ b/compose/legacy/vista.yml @@ -1,6 +1,6 @@ services: vista: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: vista env_file: ../../.env.legacy environment: diff --git a/compose/legacy/win2k.yml b/compose/legacy/win2k.yml index 68db30e..2f09863 100644 --- a/compose/legacy/win2k.yml +++ b/compose/legacy/win2k.yml @@ -1,6 +1,6 @@ services: win2k: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2k env_file: ../../.env.legacy environment: diff --git a/compose/legacy/winxp.yml b/compose/legacy/winxp.yml index 0280663..7942037 100644 --- a/compose/legacy/winxp.yml +++ b/compose/legacy/winxp.yml @@ -1,6 +1,6 @@ services: winxp: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: winxp env_file: ../../.env.legacy environment: diff --git a/compose/server/win2003.yml b/compose/server/win2003.yml index 7ee1c41..7995784 100644 --- a/compose/server/win2003.yml +++ b/compose/server/win2003.yml @@ -1,6 +1,6 @@ services: win2003: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2003 env_file: ../../.env.legacy environment: diff --git a/compose/server/win2008.yml b/compose/server/win2008.yml index 46a73ac..8aa46c1 100644 --- a/compose/server/win2008.yml +++ b/compose/server/win2008.yml @@ -1,6 +1,6 @@ services: win2008: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2008 env_file: ../../.env.legacy environment: diff --git a/compose/server/win2012.yml b/compose/server/win2012.yml index 8a45438..95dbe9f 100644 --- a/compose/server/win2012.yml +++ b/compose/server/win2012.yml @@ -1,6 +1,6 @@ services: win2012: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2012 env_file: ../../.env.legacy environment: diff --git a/compose/server/win2016.yml b/compose/server/win2016.yml index 823f947..e3792bc 100644 --- a/compose/server/win2016.yml +++ b/compose/server/win2016.yml @@ -1,6 +1,6 @@ services: win2016: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2016 env_file: ../../.env.modern environment: diff --git a/compose/server/win2019.yml b/compose/server/win2019.yml index 4eb0439..846d3c8 100644 --- a/compose/server/win2019.yml +++ b/compose/server/win2019.yml @@ -1,6 +1,6 @@ services: win2019: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2019 env_file: ../../.env.modern environment: diff --git a/compose/server/win2022.yml b/compose/server/win2022.yml index 51c7dbe..03448af 100644 --- a/compose/server/win2022.yml +++ b/compose/server/win2022.yml @@ -1,6 +1,6 @@ services: win2022: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2022 env_file: ../../.env.modern environment: diff --git a/compose/server/win2025.yml b/compose/server/win2025.yml index 8ede87c..55c9f09 100644 --- a/compose/server/win2025.yml +++ b/compose/server/win2025.yml @@ -1,6 +1,6 @@ services: win2025: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: win2025 env_file: ../../.env.modern environment: diff --git a/compose/tiny/tiny10.yml b/compose/tiny/tiny10.yml index a7f03e6..2d36783 100644 --- a/compose/tiny/tiny10.yml +++ b/compose/tiny/tiny10.yml @@ -1,6 +1,6 @@ services: tiny10: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: tiny10 env_file: ../../.env.legacy environment: diff --git a/compose/tiny/tiny11.yml b/compose/tiny/tiny11.yml index 06cc891..094a6f7 100644 --- a/compose/tiny/tiny11.yml +++ b/compose/tiny/tiny11.yml @@ -1,6 +1,6 @@ services: tiny11: - image: dockurr/windows + image: ${WINDOWS_IMAGE:-dockurr/windows} container_name: tiny11 env_file: ../../.env.legacy environment: diff --git a/readme.md b/readme.md index 71aa872..641f1b1 100644 --- a/readme.md +++ b/readme.md @@ -224,7 +224,7 @@ data/ # VM storage (per-version folders) | `2003` | Windows Server 2003 | 0.6 GB | > [!TIP] -> To install ARM64 versions of Windows use [dockur/windows-arm](https://github.com/dockur/windows-arm/). +> To install ARM64 versions of Windows use [dockur/windows-arm](https://github.com/dockur/windows-arm/). The `winctl.sh` script auto-detects your architecture and blocks unsupported versions on ARM. Set `WINDOWS_IMAGE=dockurr/windows-arm` in your `.env.modern` file when running on ARM64. Only Windows 10 and 11 variants are supported on ARM64. ### How do I change the storage location? diff --git a/winctl.sh b/winctl.sh index a0f78eb..f157464 100755 --- a/winctl.sh +++ b/winctl.sh @@ -62,6 +62,11 @@ readonly ALL_VERSIONS=( 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 @@ -199,6 +204,36 @@ table_header() { 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 +} + # ============================================================================== # PREREQUISITES CHECKS # ============================================================================== @@ -692,6 +727,16 @@ cmd_start() { validate_version "$v" || exit 1 done + # Check ARM compatibility + detect_arch + if [[ "$DETECTED_ARCH" == "arm64" ]]; then + for v in "${versions[@]}"; do + if ! is_arm_supported "$v"; then + die "${VERSION_DISPLAY_NAMES[$v]} ($v) is not supported on ARM64. Supported: ${ARM_VERSIONS[*]}" + fi + done + fi + # Run prerequisite checks check_docker || exit 1 check_kvm || exit 1 @@ -1027,6 +1072,7 @@ cmd_rebuild() { cmd_list() { local category="${1:-all}" + detect_arch header "Available Windows Versions" local categories=() @@ -1062,7 +1108,11 @@ cmd_list() { else resource_tag="${DIM}(2G RAM)${RESET}" fi - printf " %-10s %-28s %s %s\n" "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$resource_tag" "$status" + 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 @@ -1155,7 +1205,14 @@ cmd_monitor() { } 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 + printf '\n' } cmd_refresh() { From fd2100193a99d815faf92ddd54e446764ea1c0a1 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 12:31:16 +0000 Subject: [PATCH 18/33] feat: Add ARM64 support section to help output Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/winctl.sh b/winctl.sh index f157464..d290037 100755 --- a/winctl.sh +++ b/winctl.sh @@ -1296,6 +1296,11 @@ show_usage() { printf ' Each version has unique ports for Web UI and RDP access.\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' } # ============================================================================== From 15dd30727994b832784d7d6f2e9f31467dcd7296 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 12:48:14 +0000 Subject: [PATCH 19/33] fix: Replace deprecated fail_on_error with fail_level in reviewdog actions All reviewdog actions in review.yml now use fail_level: error instead of the deprecated fail_on_error parameter. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/review.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 7dd6f48..d8074e3 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_level: error 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 }} From e3ea8a11540d37df86fab1de68fdba85618b4c00 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 13:37:17 +0000 Subject: [PATCH 20/33] docs: Add git remotes and PR workflow to CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3fe3e19..835f381 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,47 @@ ShellCheck exclusions (from CI): SC1091, SC2001, SC2002, SC2034, SC2064, SC2153, | 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 "..." +``` + ## Adding New Windows Versions 1. Add version aliases in `src/define.sh` `parseVersion()` function From bdabd06c3ac1407354fbe3dee432ec0753bbb850 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 13:37:38 +0000 Subject: [PATCH 21/33] docs: Add active PRs reference to CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 835f381..98feed4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,13 @@ gh pr edit --repo dockur/windows --body "..." 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 From b6029d521d740fd874aa3cb1643d3eaebf79d715 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 13:38:35 +0000 Subject: [PATCH 22/33] docs: Add ARM64 FAQ entry and update winctl.sh section in README Co-Authored-By: Claude Opus 4.5 --- readme.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index 641f1b1..8c521b9 100644 --- a/readme.md +++ b/readme.md @@ -79,7 +79,7 @@ This repository includes pre-configured compose files for all Windows versions w Use `winctl.sh` for easy container management: ```bash -# Check prerequisites +# Check prerequisites and detected architecture ./winctl.sh check # Start a container (interactive menu if no version specified) @@ -91,12 +91,12 @@ Use `winctl.sh` for easy container management: # Stop containers (with confirmation) ./winctl.sh stop win11 -./winctl.sh stop all # Stop all running +./winctl.sh stop all # Stop all running # View logs ./winctl.sh logs win11 -f -# List all available versions +# List all available versions (shows [x86 only] on ARM64) ./winctl.sh list ./winctl.sh list desktop # Filter by category @@ -106,7 +106,7 @@ Use `winctl.sh` for easy container management: # Rebuild container (preserves data) ./winctl.sh rebuild win11 -# Full help +# Full help (includes ARM64 info) ./winctl.sh help ``` @@ -224,7 +224,31 @@ data/ # VM storage (per-version folders) | `2003` | Windows Server 2003 | 0.6 GB | > [!TIP] -> To install ARM64 versions of Windows use [dockur/windows-arm](https://github.com/dockur/windows-arm/). The `winctl.sh` script auto-detects your architecture and blocks unsupported versions on ARM. Set `WINDOWS_IMAGE=dockurr/windows-arm` in your `.env.modern` file when running on ARM64. Only Windows 10 and 11 variants are supported on ARM64. +> 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? From ba6c915ea418ef86740904b6ba99d7da7d49e084 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 13:39:41 +0000 Subject: [PATCH 23/33] docs: Add ARM64 setup section and update commands in WINCTL_GUIDE Co-Authored-By: Claude Opus 4.5 --- WINCTL_GUIDE.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/WINCTL_GUIDE.md b/WINCTL_GUIDE.md index e2835cb..089e0ce 100644 --- a/WINCTL_GUIDE.md +++ b/WINCTL_GUIDE.md @@ -9,6 +9,7 @@ A comprehensive guide to managing Windows Docker containers with `winctl.sh`. - [Quick Start](#quick-start) - [Commands Reference](#commands-reference) - [Configuration](#configuration) +- [ARM64 Setup](#arm64-setup) - [Interactive Menus](#interactive-menus) - [Common Scenarios](#common-scenarios) - [Troubleshooting](#troubleshooting) @@ -25,6 +26,7 @@ A comprehensive guide to managing Windows Docker containers with `winctl.sh`. - **Interactive menus** when you don't specify a version - **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 @@ -101,6 +103,14 @@ Prerequisites Check [OK] Disk space OK: 500GB available (128GB needed) [OK] All critical prerequisites passed! + Architecture: amd64 +``` + +On ARM64, the output also shows: +``` + Architecture: arm64 + ARM64 image: dockurr/windows-arm + Supported: win11 win11e win11l win10 win10e win10l ``` ### Fix Common Issues @@ -176,10 +186,11 @@ Start one or more containers. **What it does:** 1. Checks prerequisites (Docker, KVM) -2. Creates data directory if missing -3. Checks available resources -4. Starts the container -5. Shows connection details +2. Detects architecture and blocks unsupported versions on ARM64 +3. Creates data directory if missing +4. Checks available resources +5. Starts the container +6. Shows connection details --- @@ -331,6 +342,11 @@ Available Windows Versions win7 Windows 7 Ultimate (2G RAM) ``` +On ARM64, unsupported versions show an `[x86 only]` tag: +``` + win7 Windows 7 Ultimate (2G RAM) [x86 only] +``` + --- ### inspect @@ -471,6 +487,59 @@ DEBUG=N --- +## 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. @@ -587,7 +656,23 @@ Select Version(s) ./winctl.sh start win11 ``` -### Scenario 6: Fresh Start (Reset VM) +### 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 From a51651e34ad6ff07f147bba04407c92cd7716d7c Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 13:43:21 +0000 Subject: [PATCH 24/33] fix: Use US spelling 'Canceled' instead of 'Cancelled' Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/winctl.sh b/winctl.sh index d290037..6494536 100755 --- a/winctl.sh +++ b/winctl.sh @@ -838,7 +838,7 @@ cmd_stop() { local confirm read -r confirm if [[ ! "$confirm" =~ ^[Yy]$ ]]; then - info "Cancelled" + info "Canceled" return 0 fi @@ -1035,7 +1035,7 @@ cmd_rebuild() { local confirm read -r confirm if [[ "$confirm" != "yes" ]]; then - info "Cancelled" + info "Canceled" return 0 fi From 814c6e859184c644a0e493d01710a3a76cc91c83 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 13:45:49 +0000 Subject: [PATCH 25/33] fix: Add YAML document start marker to all compose files Adds missing `---` document start to satisfy yamllint. Co-Authored-By: Claude Opus 4.5 --- compose/all.yml | 1 + compose/desktop.yml | 1 + compose/desktop/win10.yml | 1 + compose/desktop/win11.yml | 1 + compose/desktop/win7.yml | 1 + compose/desktop/win8.yml | 1 + compose/legacy.yml | 1 + compose/legacy/vista.yml | 1 + compose/legacy/win2k.yml | 1 + compose/legacy/winxp.yml | 1 + compose/server.yml | 1 + compose/server/win2003.yml | 1 + compose/server/win2008.yml | 1 + compose/server/win2012.yml | 1 + compose/server/win2016.yml | 1 + compose/server/win2019.yml | 1 + compose/server/win2022.yml | 1 + compose/server/win2025.yml | 1 + compose/tiny.yml | 1 + compose/tiny/tiny10.yml | 1 + compose/tiny/tiny11.yml | 1 + 21 files changed, 21 insertions(+) diff --git a/compose/all.yml b/compose/all.yml index 74180b5..e3b68dc 100644 --- a/compose/all.yml +++ b/compose/all.yml @@ -1,3 +1,4 @@ +--- include: - desktop.yml - legacy.yml diff --git a/compose/desktop.yml b/compose/desktop.yml index b933560..69ec6d8 100644 --- a/compose/desktop.yml +++ b/compose/desktop.yml @@ -1,3 +1,4 @@ +--- include: - desktop/win11.yml - desktop/win10.yml diff --git a/compose/desktop/win10.yml b/compose/desktop/win10.yml index a88e417..6dd374c 100644 --- a/compose/desktop/win10.yml +++ b/compose/desktop/win10.yml @@ -1,3 +1,4 @@ +--- services: win10: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/desktop/win11.yml b/compose/desktop/win11.yml index fa3eeaa..96944a0 100644 --- a/compose/desktop/win11.yml +++ b/compose/desktop/win11.yml @@ -1,3 +1,4 @@ +--- services: win11: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/desktop/win7.yml b/compose/desktop/win7.yml index aa24640..e53277f 100644 --- a/compose/desktop/win7.yml +++ b/compose/desktop/win7.yml @@ -1,3 +1,4 @@ +--- services: win7: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/desktop/win8.yml b/compose/desktop/win8.yml index f91b98d..a24a79b 100644 --- a/compose/desktop/win8.yml +++ b/compose/desktop/win8.yml @@ -1,3 +1,4 @@ +--- services: win81: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/legacy.yml b/compose/legacy.yml index d56e088..1df181e 100644 --- a/compose/legacy.yml +++ b/compose/legacy.yml @@ -1,3 +1,4 @@ +--- include: - legacy/vista.yml - legacy/winxp.yml diff --git a/compose/legacy/vista.yml b/compose/legacy/vista.yml index 8df7848..5c148d6 100644 --- a/compose/legacy/vista.yml +++ b/compose/legacy/vista.yml @@ -1,3 +1,4 @@ +--- services: vista: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/legacy/win2k.yml b/compose/legacy/win2k.yml index 2f09863..154b927 100644 --- a/compose/legacy/win2k.yml +++ b/compose/legacy/win2k.yml @@ -1,3 +1,4 @@ +--- services: win2k: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/legacy/winxp.yml b/compose/legacy/winxp.yml index 7942037..00db6df 100644 --- a/compose/legacy/winxp.yml +++ b/compose/legacy/winxp.yml @@ -1,3 +1,4 @@ +--- services: winxp: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/server.yml b/compose/server.yml index 5f6a826..4cf21c4 100644 --- a/compose/server.yml +++ b/compose/server.yml @@ -1,3 +1,4 @@ +--- include: - server/win2025.yml - server/win2022.yml diff --git a/compose/server/win2003.yml b/compose/server/win2003.yml index 7995784..ac9ac14 100644 --- a/compose/server/win2003.yml +++ b/compose/server/win2003.yml @@ -1,3 +1,4 @@ +--- services: win2003: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/server/win2008.yml b/compose/server/win2008.yml index 8aa46c1..3104c0d 100644 --- a/compose/server/win2008.yml +++ b/compose/server/win2008.yml @@ -1,3 +1,4 @@ +--- services: win2008: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/server/win2012.yml b/compose/server/win2012.yml index 95dbe9f..7adc7d1 100644 --- a/compose/server/win2012.yml +++ b/compose/server/win2012.yml @@ -1,3 +1,4 @@ +--- services: win2012: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/server/win2016.yml b/compose/server/win2016.yml index e3792bc..6d899be 100644 --- a/compose/server/win2016.yml +++ b/compose/server/win2016.yml @@ -1,3 +1,4 @@ +--- services: win2016: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/server/win2019.yml b/compose/server/win2019.yml index 846d3c8..805e1f2 100644 --- a/compose/server/win2019.yml +++ b/compose/server/win2019.yml @@ -1,3 +1,4 @@ +--- services: win2019: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/server/win2022.yml b/compose/server/win2022.yml index 03448af..79b21cc 100644 --- a/compose/server/win2022.yml +++ b/compose/server/win2022.yml @@ -1,3 +1,4 @@ +--- services: win2022: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/server/win2025.yml b/compose/server/win2025.yml index 55c9f09..cbd8a25 100644 --- a/compose/server/win2025.yml +++ b/compose/server/win2025.yml @@ -1,3 +1,4 @@ +--- services: win2025: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/tiny.yml b/compose/tiny.yml index 88124d0..c7930ce 100644 --- a/compose/tiny.yml +++ b/compose/tiny.yml @@ -1,3 +1,4 @@ +--- include: - tiny/tiny11.yml - tiny/tiny10.yml diff --git a/compose/tiny/tiny10.yml b/compose/tiny/tiny10.yml index 2d36783..38773ec 100644 --- a/compose/tiny/tiny10.yml +++ b/compose/tiny/tiny10.yml @@ -1,3 +1,4 @@ +--- services: tiny10: image: ${WINDOWS_IMAGE:-dockurr/windows} diff --git a/compose/tiny/tiny11.yml b/compose/tiny/tiny11.yml index 094a6f7..8d3f59a 100644 --- a/compose/tiny/tiny11.yml +++ b/compose/tiny/tiny11.yml @@ -1,3 +1,4 @@ +--- services: tiny11: image: ${WINDOWS_IMAGE:-dockurr/windows} From 3330596879afb5b240640f50e2113e3524f60bbf Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Thu, 29 Jan 2026 16:39:57 +0000 Subject: [PATCH 26/33] docs: Add GitHub Codespaces section to CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 98feed4..6b25e8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,16 @@ The container starts via `/run/entry.sh` which sources scripts in sequence: - 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 From 2b07b9cf07b32ba621de7ee56efda71f6d280e16 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Fri, 30 Jan 2026 07:58:20 +0000 Subject: [PATCH 27/33] feat: Add ISO cache for winctl.sh to skip re-downloads Cache downloaded ISOs so new instances of the same Windows version skip the 3-6 GB download. Adds `winctl cache` subcommands (save/list/rm/flush) and auto-restores cached ISOs when creating new instances with --new. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 6 + WINCTL_GUIDE.md | 437 ++++++++++++- readme.md | 42 ++ winctl.sh | 1656 +++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 2076 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index fd89282..70d0da7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ data/* !data/*/ data/*/** !data/*/.gitkeep + +# Instance compose files and registry (generated at runtime) +instances/ + +# ISO cache (cached ISOs for faster instance creation) +cache/ diff --git a/WINCTL_GUIDE.md b/WINCTL_GUIDE.md index 089e0ce..bedba17 100644 --- a/WINCTL_GUIDE.md +++ b/WINCTL_GUIDE.md @@ -8,6 +8,9 @@ A comprehensive guide to managing Windows Docker containers with `winctl.sh`. - [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) @@ -24,6 +27,10 @@ A comprehensive guide to managing Windows Docker containers with `winctl.sh`. - **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 @@ -104,6 +111,7 @@ Prerequisites Check [OK] All critical prerequisites passed! Architecture: amd64 + LAN IP: 192.168.1.100 ``` On ARM64, the output also shows: @@ -111,6 +119,7 @@ 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 @@ -148,10 +157,13 @@ 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 @@ -187,10 +199,11 @@ Start one or more containers. **What it does:** 1. Checks prerequisites (Docker, KVM) 2. Detects architecture and blocks unsupported versions on ARM64 -3. Creates data directory if missing -4. Checks available resources -5. Starts the container -6. Shows connection details +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) --- @@ -245,6 +258,8 @@ Example output: 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 ``` --- @@ -370,6 +385,10 @@ Container Details: win11 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 ``` --- @@ -415,6 +434,337 @@ The cache is stored at `~/.cache/winctl/status.json` and auto-refreshes when: --- +### 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. + +--- + +## 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. Start a VM and wait for the ISO to download +2. Cache the ISO: `./winctl.sh cache save winxp` +3. 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 sees the ISO on startup and skips the download. + +### Caching an ISO + +```bash +# Cache ISOs from an existing VM's data directory +./winctl.sh cache save winxp +./winctl.sh cache save win11 +``` + +The ISOs are copied from `data//` to `cache//`. + +### 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 ISOs, they are copied to data/winxp-1/ before start +./winctl.sh start winxp --new +``` + +This is skipped when using `--clone`, since cloning copies all data from the base version including any ISOs. + +### Cache Directory Structure + +``` +cache/ +├── winxp/ +│ └── custom.iso +├── win11/ +│ └── win11x64.iso +└── win10/ + └── win10x64.iso +``` + +> **Note:** The cache stores processed ISOs from the container's data directory, not raw downloads. + +--- + ## Configuration ### Environment Files @@ -683,6 +1033,55 @@ 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 @@ -701,8 +1100,8 @@ rm -rf data/win11/* **Common issues:** - KVM not accessible → Add user to kvm group -- Port already in use → Stop other containers or services -- Not enough disk space → Free up space or reduce DISK_SIZE +- 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 @@ -760,15 +1159,16 @@ Bookmark your commonly used VMs: Each VM has a "Shared" folder on the desktop that maps to the host. Use this to transfer files. -### 4. Snapshots via Data Backup +### 4. Snapshots -The VM disk is stored in `data//`. Back it up to create a snapshot: +Use the built-in snapshot and restore commands: ```bash -./winctl.sh stop win11 -cp -r data/win11 data/win11-backup -./winctl.sh start win11 +./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: @@ -801,10 +1201,25 @@ For servers, you can start VMs and access only via RDP: │ ├── 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 ``` diff --git a/readme.md b/readme.md index 8c521b9..af37b15 100644 --- a/readme.md +++ b/readme.md @@ -106,6 +106,35 @@ Use `winctl.sh` for easy container management: # 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 save winxp # Cache ISO after first download +./winctl.sh cache list # Show cached ISOs +./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 ``` @@ -153,9 +182,22 @@ compose/ ├── 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/ └── ... ``` diff --git a/winctl.sh b/winctl.sh index 6494536..23987d4 100755 --- a/winctl.sh +++ b/winctl.sh @@ -141,6 +141,41 @@ declare -A VERSION_RESOURCE_TYPE=( ["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 @@ -234,6 +269,21 @@ is_arm_supported() { 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 # ============================================================================== @@ -540,17 +590,240 @@ get_status() { fi } -# Get compose file path for version +# ============================================================================== +# 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 version="$1" - local file="${VERSION_COMPOSE_FILES[$version]:-}" + 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: $version" + die "Unknown version or instance: $target" fi echo "$SCRIPT_DIR/$file" } -# Validate version +# ============================================================================== +# 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 @@ -561,15 +834,19 @@ validate_version() { return 0 } -# Run compose command for a version +# Run compose command for a version or instance run_compose() { - local version="$1" + local target="$1" shift local compose_file - compose_file=$(get_compose_file "$version") + compose_file=$(get_compose_file "$target") cd "$SCRIPT_DIR" - $(compose_cmd) -f "$compose_file" "$@" + if is_instance "$target"; then + $(compose_cmd) -p "$target" -f "$compose_file" "$@" + else + $(compose_cmd) -f "$compose_file" "$@" + fi } # ============================================================================== @@ -711,7 +988,42 @@ interactive_select() { # ============================================================================== cmd_start() { - local versions=("$@") + 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 @@ -722,17 +1034,18 @@ cmd_start() { IFS=' ' read -ra versions <<< "$selected" fi - # Validate all versions first + # Validate all targets first for v in "${versions[@]}"; do - validate_version "$v" || exit 1 + validate_target "$v" || exit 1 done - # Check ARM compatibility + # Check ARM compatibility (only for base versions) detect_arch if [[ "$DETECTED_ARCH" == "arm64" ]]; then for v in "${versions[@]}"; do - if ! is_arm_supported "$v"; then - die "${VERSION_DISPLAY_NAMES[$v]} ($v) is not supported on ARM64. Supported: ${ARM_VERSIONS[*]}" + 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 @@ -742,10 +1055,11 @@ cmd_start() { check_kvm || exit 1 for v in "${versions[@]}"; do - header "Starting ${VERSION_DISPLAY_NAMES[$v]} ($v)" + resolve_target "$v" + header "Starting ${RESOLVED_DISPLAY_NAME} ($v)" # Check resources - local resource_type="${VERSION_RESOURCE_TYPE[$v]}" + 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 @@ -761,6 +1075,16 @@ cmd_start() { mkdir -p "$data_dir" 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 @@ -774,10 +1098,15 @@ cmd_start() { fi # Show connection info + detect_lan_ip printf '\n' printf '%s\n' " ${BOLD}Connection Details:${RESET}" - printf '%s\n' " → Web Viewer: ${CYAN}http://localhost:${VERSION_PORTS_WEB[$v]}${RESET}" - printf '%s\n' " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${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 @@ -788,7 +1117,7 @@ cmd_start() { cmd_stop() { local versions=("$@") - # Stop all running containers + # Stop all running containers (base + instances) if [[ ${#versions[@]} -eq 1 && "${versions[0]}" == "all" ]]; then versions=() refresh_status_cache @@ -799,6 +1128,15 @@ cmd_stop() { 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 @@ -814,9 +1152,9 @@ cmd_stop() { IFS=' ' read -ra versions <<< "$selected" fi - # Validate all versions first + # Validate all targets first for v in "${versions[@]}"; do - validate_version "$v" || exit 1 + validate_target "$v" || exit 1 done # Show confirmation @@ -824,13 +1162,14 @@ cmd_stop() { 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 (${VERSION_DISPLAY_NAMES[$v]}) - $status" + printf '%s\n' " • $v (${RESOLVED_DISPLAY_NAME}) - $status" done printf '\n' printf '%s' " Continue? [y/N]: " @@ -872,21 +1211,27 @@ cmd_restart() { IFS=' ' read -ra versions <<< "$selected" fi - # Validate all versions first + # Validate all targets first for v in "${versions[@]}"; do - validate_version "$v" || exit 1 + validate_target "$v" || exit 1 done for v in "${versions[@]}"; do - header "Restarting ${VERSION_DISPLAY_NAMES[$v]} ($v)" + 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:${VERSION_PORTS_WEB[$v]}${RESET}" - printf '%s\n' " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${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" @@ -908,15 +1253,40 @@ cmd_status() { table_header for v in "${versions[@]}"; do - if ! validate_version "$v" 2>/dev/null; then - continue + 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 - - local status - status=$(get_status "$v") - table_row "$v" "${VERSION_DISPLAY_NAMES[$v]}" "$status" "${VERSION_PORTS_WEB[$v]}" "${VERSION_PORTS_RDP[$v]}" 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() { @@ -924,10 +1294,10 @@ cmd_logs() { local follow="${2:-}" if [[ -z "$version" ]]; then - die "Usage: ${SCRIPT_NAME} logs [-f]" + die "Usage: ${SCRIPT_NAME} logs [-f]" fi - validate_version "$version" || exit 1 + validate_target "$version" || exit 1 local args=() if [[ "$follow" == "-f" ]]; then @@ -942,10 +1312,10 @@ cmd_shell() { local version="${1:-}" if [[ -z "$version" ]]; then - die "Usage: ${SCRIPT_NAME} shell " + die "Usage: ${SCRIPT_NAME} shell " fi - validate_version "$version" || exit 1 + validate_target "$version" || exit 1 if ! is_running "$version"; then die "$version is not running" @@ -966,16 +1336,23 @@ cmd_stats() { 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 versions + # Validate targets local valid_running=() for v in "${versions[@]}"; do - if validate_version "$v" 2>/dev/null && is_running "$v"; then + if validate_target "$v" 2>/dev/null && is_running "$v"; then valid_running+=("$v") fi done @@ -1015,19 +1392,20 @@ cmd_rebuild() { IFS=' ' read -ra versions <<< "$selected" fi - # Validate all versions first + # Validate all targets first for v in "${versions[@]}"; do - validate_version "$v" || exit 1 + validate_target "$v" || exit 1 done # Show warning - header "⚠️ Rebuild Containers" + 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 - printf '%s\n' " • $v (${VERSION_DISPLAY_NAMES[$v]})" + resolve_target "$v" + printf '%s\n' " • $v (${RESOLVED_DISPLAY_NAME})" done printf '\n' printf '%s' " Type 'yes' to confirm: " @@ -1040,6 +1418,7 @@ cmd_rebuild() { fi for v in "${versions[@]}"; do + resolve_target "$v" header "Rebuilding $v" # Ensure data directory exists @@ -1055,10 +1434,15 @@ cmd_rebuild() { 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:${VERSION_PORTS_WEB[$v]}${RESET}" - printf '%s\n' " → RDP: ${CYAN}localhost:${VERSION_PORTS_RDP[$v]}${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" @@ -1116,6 +1500,32 @@ cmd_list() { 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' } @@ -1123,21 +1533,43 @@ cmd_inspect() { local version="${1:-}" if [[ -z "$version" ]]; then - die "Usage: ${SCRIPT_NAME} inspect " + die "Usage: ${SCRIPT_NAME} inspect " fi - validate_version "$version" || exit 1 + 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} ${VERSION_DISPLAY_NAMES[$version]}" - printf '%s\n' " ${BOLD}Category:${RESET} ${VERSION_CATEGORIES[$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} ${VERSION_PORTS_WEB[$version]}" - printf '%s\n' " ${BOLD}RDP Port:${RESET} ${VERSION_PORTS_RDP[$version]}" - printf '%s\n' " ${BOLD}Resources:${RESET} ${VERSION_RESOURCE_TYPE[$version]}" - printf '%s\n' " ${BOLD}Compose:${RESET} ${VERSION_COMPOSE_FILES[$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 @@ -1191,6 +1623,23 @@ cmd_monitor() { 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 @@ -1212,6 +1661,10 @@ cmd_check() { 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' } @@ -1247,6 +1700,1057 @@ cmd_refresh() { 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 + 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 iso_files + iso_files=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + if [[ -n "$iso_files" ]]; then + info "Restoring cached ISO for $version..." + cp "$cache_src"/*.iso "$data_dir/" + 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 +# ============================================================================== + +cmd_cache() { + local subcmd="${1:-}" + shift || true + + case "$subcmd" in + save) cmd_cache_save "$@" ;; + 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 + while IFS= read -r iso; do + local filename + filename=$(basename "$iso") + 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' + success "Cached $count ISO file(s) to cache/$RESOLVED_BASE/" + 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 # ============================================================================== @@ -1272,8 +2776,22 @@ show_usage() { printf ' %b [interval] Real-time dashboard (default: 5s refresh)\n' "${BOLD}monitor${RESET}" printf ' %b Run prerequisites check\n' "${BOLD}check${RESET}" printf ' %b Force refresh status cache\n' "${BOLD}refresh${RESET}" + printf ' %b Open web viewer in browser\n' "${BOLD}open${RESET}" + printf ' %b Pull latest Docker image\n' "${BOLD}pull${RESET}" + printf ' %b [version...] Show disk usage per VM\n' "${BOLD}disk${RESET}" + printf ' %b [name] Back up VM data directory\n' "${BOLD}snapshot${RESET}" + printf ' %b [name] Restore VM data from snapshot\n' "${BOLD}restore${RESET}" + printf ' %b [--data] Remove stopped containers\n' "${BOLD}clean${RESET}" + printf ' %b Permanently remove an instance\n' "${BOLD}destroy${RESET}" + printf ' %b [base] List all registered instances\n' "${BOLD}instances${RESET}" + printf ' %b Manage ISO cache (save/list/rm/flush)\n' "${BOLD}cache${RESET}" printf ' %b Show this help message\n' "${BOLD}help${RESET}" printf '\n' + printf '%b\n' "${BOLD}INSTANCE FLAGS (used with start)${RESET}" + printf ' %b Create a new instance of a version\n' "${BOLD}--new${RESET}" + printf ' %b [name] Name the instance (default: auto-numbered)\n' "${BOLD}--new${RESET}" + printf ' %b Clone data from base version to new instance\n' "${BOLD}--clone${RESET}" + 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' @@ -1291,9 +2809,30 @@ show_usage() { 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 ' %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 ' %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' 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}" @@ -1329,6 +2868,15 @@ main() { 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 ;; From aa45e1691ee2318aac33c6411a4d98ce408a1a21 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Fri, 30 Jan 2026 08:10:55 +0000 Subject: [PATCH 28/33] fix: Reset magic byte on cached ISOs to trigger install pipeline The container marks processed ISOs with byte 0x16 at offset 0. Restoring a cached ISO with this byte into an empty data directory caused the container to skip installation and try to boot a nonexistent disk, dropping into a UEFI shell. Now resets byte 0 to 0x00 after copying so the container re-processes the ISO (driver injection, answer file, etc.) without re-downloading it. Also adds cache auto-restore to cmd_start() for base versions, not just cmd_new() instances. Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/winctl.sh b/winctl.sh index 23987d4..457b7b9 100755 --- a/winctl.sh +++ b/winctl.sh @@ -1075,6 +1075,26 @@ cmd_start() { mkdir -p "$data_dir" fi + # Pre-populate from ISO cache if data dir is empty + if [[ -z "$(ls -A "$data_dir" 2>/dev/null)" ]]; then + local cache_src="$ISO_CACHE_DIR/$RESOLVED_BASE" + if [[ -d "$cache_src" ]]; then + local iso_files + iso_files=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + if [[ -n "$iso_files" ]]; then + info "Restoring cached ISO for $RESOLVED_BASE..." + cp "$cache_src"/*.iso "$data_dir/" + # Reset magic byte so the container re-processes the ISO + # (installs from it) instead of trying to boot a missing disk + local restored_iso + for restored_iso in "$data_dir"/*.iso; do + printf '\x00' | dd of="$restored_iso" bs=1 seek=0 count=1 conv=notrunc status=none 2>/dev/null || true + done + 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" @@ -2353,6 +2373,11 @@ cmd_new() { if [[ -n "$iso_files" ]]; then info "Restoring cached ISO for $version..." cp "$cache_src"/*.iso "$data_dir/" + # Reset magic byte so the container re-processes the ISO + local restored_iso + for restored_iso in "$data_dir"/*.iso; do + printf '\x00' | dd of="$restored_iso" bs=1 seek=0 count=1 conv=notrunc status=none 2>/dev/null || true + done success "ISO restored from cache (skipping download)" fi fi From e7dc55454b53ec34ac3c73620bee9165def5861a Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Fri, 30 Jan 2026 10:42:10 +0000 Subject: [PATCH 29/33] fix: Check for existing ISOs instead of empty dir in cache restore The data directories contain .gitkeep files, so the empty-directory check (ls -A) always returned non-empty and cache restore never triggered. Now checks for *.iso files specifically, which is the actual condition that determines whether a download is needed. Co-Authored-By: Claude Opus 4.5 --- winctl.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/winctl.sh b/winctl.sh index 457b7b9..f7f88dd 100755 --- a/winctl.sh +++ b/winctl.sh @@ -1075,13 +1075,15 @@ cmd_start() { mkdir -p "$data_dir" fi - # Pre-populate from ISO cache if data dir is empty - if [[ -z "$(ls -A "$data_dir" 2>/dev/null)" ]]; then + # 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 iso_files - iso_files=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) - if [[ -n "$iso_files" ]]; then + local cached_isos + cached_isos=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) + if [[ -n "$cached_isos" ]]; then info "Restoring cached ISO for $RESOLVED_BASE..." cp "$cache_src"/*.iso "$data_dir/" # Reset magic byte so the container re-processes the ISO From 40b5466a771c9ebcfdfc541dced3bef7e25ee39e Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Fri, 30 Jan 2026 10:43:44 +0000 Subject: [PATCH 30/33] chore: Add snapshots/ to .gitignore with .gitkeep placeholder Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 ++++ snapshots/.gitkeep | 0 2 files changed, 4 insertions(+) create mode 100644 snapshots/.gitkeep diff --git a/.gitignore b/.gitignore index 70d0da7..8fc5726 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ data/* data/*/** !data/*/.gitkeep +# Snapshot contents (generated at runtime) +snapshots/* +!snapshots/.gitkeep + # Instance compose files and registry (generated at runtime) instances/ diff --git a/snapshots/.gitkeep b/snapshots/.gitkeep new file mode 100644 index 0000000..e69de29 From 04d909acd7ff9ce4b9d1b696534c6f5175eb8d5a Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Fri, 30 Jan 2026 12:04:18 +0000 Subject: [PATCH 31/33] feat: Add auto-cache, fix cache restore, refactor help system - Add AUTO_CACHE=Y/N setting in .env to auto-cache ISOs on stop - Fix cache restore to copy metadata files (windows.base, windows.ver, windows.mode, windows.type, windows.args) alongside ISOs so the container recognizes them as already processed and skips re-download - Refactor show_usage() into topic-based help with interactive menu (commands, instances, cache, examples, config) and aligned columns - Fix clean --data to unregister instances and remove compose files - Update WINCTL_GUIDE.md and readme.md with all changes Co-Authored-By: Claude Opus 4.5 --- .env.example | 4 + WINCTL_GUIDE.md | 49 +++++-- readme.md | 1 + winctl.sh | 378 ++++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 361 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index 56bd37c..cc369fb 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,7 @@ # 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/WINCTL_GUIDE.md b/WINCTL_GUIDE.md index bedba17..584906a 100644 --- a/WINCTL_GUIDE.md +++ b/WINCTL_GUIDE.md @@ -532,7 +532,7 @@ Remove stopped containers and optionally purge their data directories. ./winctl.sh clean --data ``` -Requires typing `yes` to confirm. Shows freed disk space on completion. +Requires typing `yes` to confirm. Shows freed disk space on completion. Stopped instances are automatically unregistered and their compose files removed. --- @@ -706,7 +706,7 @@ Windows ISOs are large (3-6 GB) and re-downloaded every time a new container is 2. Cache the ISO: `./winctl.sh cache save winxp` 3. 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 sees the ISO on startup and skips the download. +When creating a new instance with `--new`, winctl checks `cache//` for ISOs and metadata files, then copies them into the new instance's data directory before starting the container. The container recognizes the ISO as already processed and boots directly — no download or re-extraction needed. ### Caching an ISO @@ -716,7 +716,7 @@ When creating a new instance with `--new`, winctl checks `cache//` for ./winctl.sh cache save win11 ``` -The ISOs are copied from `data//` to `cache//`. +The ISOs and metadata files (`windows.base`, `windows.ver`, `windows.mode`, `windows.type`, `windows.args`) are copied from `data//` to `cache//`. ### Listing Cached ISOs @@ -743,25 +743,43 @@ Both commands require typing `yes` to confirm. When creating a new instance with `--new` (without `--clone`), winctl automatically checks the cache: ```bash -# If cache/winxp/ has ISOs, they are copied to data/winxp-1/ before start +# If cache/winxp/ has ISOs + metadata, they are copied to data/winxp-1/ before start ./winctl.sh start winxp --new ``` -This is skipped when using `--clone`, since cloning copies all data from the base version including any ISOs. +Both the ISO and metadata files are restored so the container recognizes the ISO as already processed. 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 ISOs found in the stopped container's data directory. ISOs that are already cached are skipped. This removes the need to manually run `cache save` after the first download. ### Cache Directory Structure ``` cache/ ├── winxp/ -│ └── custom.iso +│ ├── winxpx86.iso +│ ├── windows.base +│ ├── windows.ver +│ ├── windows.mode +│ ├── windows.type +│ └── windows.args ├── win11/ -│ └── win11x64.iso +│ ├── win11x64.iso +│ └── ... └── win10/ - └── win10x64.iso + ├── win10x64.iso + └── ... ``` -> **Note:** The cache stores processed ISOs from the container's data directory, not raw downloads. +> **Note:** The cache stores processed ISOs and their metadata from the container's data directory, not raw downloads. Metadata files tell the container the ISO is already processed, so it boots directly without re-extracting or re-downloading. --- @@ -823,6 +841,7 @@ DEBUG=N | `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 @@ -1229,9 +1248,17 @@ For servers, you can start VMs and access only via RDP: ## Getting Help ```bash -# Show all commands +# 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 @@ -1239,4 +1266,6 @@ For servers, you can start VMs and access only via RDP: ./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/readme.md b/readme.md index af37b15..cb88ab8 100644 --- a/readme.md +++ b/readme.md @@ -134,6 +134,7 @@ Use `winctl.sh` for easy container management: ./winctl.sh cache list # Show cached ISOs ./winctl.sh cache rm winxp # Remove cached winxp ISO ./winctl.sh cache flush # Clear all cached ISOs +# Or set AUTO_CACHE=Y in .env to cache ISOs automatically on stop # Full help (includes ARM64 info) ./winctl.sh help diff --git a/winctl.sh b/winctl.sh index f7f88dd..be729f5 100755 --- a/winctl.sh +++ b/winctl.sh @@ -182,6 +182,14 @@ 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 # ============================================================================== @@ -1081,16 +1089,19 @@ cmd_start() { if [[ -z "$existing_isos" ]]; then local cache_src="$ISO_CACHE_DIR/$RESOLVED_BASE" if [[ -d "$cache_src" ]]; then - local cached_isos - cached_isos=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) - if [[ -n "$cached_isos" ]]; then - info "Restoring cached ISO for $RESOLVED_BASE..." - cp "$cache_src"/*.iso "$data_dir/" - # Reset magic byte so the container re-processes the ISO - # (installs from it) instead of trying to boot a missing disk - local restored_iso - for restored_iso in "$data_dir"/*.iso; do - printf '\x00' | dd of="$restored_iso" bs=1 seek=0 count=1 conv=notrunc status=none 2>/dev/null || true + 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 + # Keep original filename — the container's parseVersion + # determines the expected name (e.g. winxpx86.iso, not xp.iso) + local iso_name + iso_name=$(basename "$cached_iso") + info "Restoring cached ISO: $iso_name..." + cp "$cached_iso" "$data_dir/$iso_name" + # Restore metadata so the container recognizes the ISO as processed + local _meta + for _meta in windows.base windows.ver windows.mode windows.type windows.args; do + [[ -f "$cache_src/$_meta" ]] && cp "$cache_src/$_meta" "$data_dir/$_meta" done success "ISO restored from cache (skipping download)" fi @@ -1217,6 +1228,13 @@ cmd_stop() { 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 } @@ -2222,6 +2240,12 @@ cmd_clean() { 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 @@ -2370,15 +2394,19 @@ cmd_new() { # Pre-populate from ISO cache if available (skip if cloning) local cache_src="$ISO_CACHE_DIR/$version" if [[ -d "$cache_src" ]]; then - local iso_files - iso_files=$(find "$cache_src" -maxdepth 1 -name '*.iso' -type f 2>/dev/null || true) - if [[ -n "$iso_files" ]]; then - info "Restoring cached ISO for $version..." - cp "$cache_src"/*.iso "$data_dir/" - # Reset magic byte so the container re-processes the ISO - local restored_iso - for restored_iso in "$data_dir"/*.iso; do - printf '\x00' | dd of="$restored_iso" bs=1 seek=0 count=1 conv=notrunc status=none 2>/dev/null || true + 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 + # Keep original filename — the container's parseVersion + # determines the expected name (e.g. winxpx86.iso, not xp.iso) + local iso_name + iso_name=$(basename "$cached_iso") + info "Restoring cached ISO: $iso_name..." + cp "$cached_iso" "$data_dir/$iso_name" + # Restore metadata so the container recognizes the ISO as processed + local _meta + for _meta in windows.base windows.ver windows.mode windows.type windows.args; do + [[ -f "$cache_src/$_meta" ]] && cp "$cache_src/$_meta" "$data_dir/$_meta" done success "ISO restored from cache (skipping download)" fi @@ -2569,6 +2597,38 @@ cmd_instances() { # ISO CACHE # ============================================================================== +# Non-interactive cache save — silently skips if no ISOs or already cached. +# 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") + if [[ ! -f "$cache_dest/$filename" ]]; then + cp "$iso" "$cache_dest/$filename" + info "Auto-cached ISO: $filename" + fi + done <<< "$iso_files" + + # Cache metadata files so the container recognizes the ISO as processed + local meta + for meta in windows.base windows.ver windows.mode windows.type windows.args; do + if [[ -f "$data_dir/$meta" ]] && [[ ! -f "$cache_dest/$meta" ]]; then + cp "$data_dir/$meta" "$cache_dest/$meta" + fi + done +} + cmd_cache() { local subcmd="${1:-}" shift || true @@ -2637,6 +2697,14 @@ cmd_cache_save() { ((count++)) done <<< "$iso_files" + # Cache metadata files so the container recognizes the ISO as processed + local meta + for meta in windows.base windows.ver windows.mode windows.type windows.args; do + if [[ -f "$data_dir/$meta" ]] && [[ ! -f "$cache_dest/$meta" ]]; then + cp "$data_dir/$meta" "$cache_dest/$meta" + fi + done + printf '\n' success "Cached $count ISO file(s) to cache/$RESOLVED_BASE/" printf '\n' @@ -2782,42 +2850,143 @@ cmd_cache_flush() { # 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}" - printf ' %b [version...] Start container(s), interactive if no version\n' "${BOLD}start${RESET}" - printf ' %b [version...|all] Stop container(s) or all running\n' "${BOLD}stop${RESET}" - printf ' %b [version...] Restart container(s)\n' "${BOLD}restart${RESET}" - printf ' %b [version...] Show status of container(s)\n' "${BOLD}status${RESET}" - printf ' %b [-f] View container logs (-f to follow)\n' "${BOLD}logs${RESET}" - printf ' %b Open bash shell in container\n' "${BOLD}shell${RESET}" - printf ' %b [version...] Show real-time resource usage\n' "${BOLD}stats${RESET}" - printf ' %b Build Docker image locally\n' "${BOLD}build${RESET}" - printf ' %b [version...] Destroy and recreate container(s)\n' "${BOLD}rebuild${RESET}" - printf ' %b [category] List versions (desktop/legacy/server/tiny/all)\n' "${BOLD}list${RESET}" - printf ' %b Show detailed container info\n' "${BOLD}inspect${RESET}" - printf ' %b [interval] Real-time dashboard (default: 5s refresh)\n' "${BOLD}monitor${RESET}" - printf ' %b Run prerequisites check\n' "${BOLD}check${RESET}" - printf ' %b Force refresh status cache\n' "${BOLD}refresh${RESET}" - printf ' %b Open web viewer in browser\n' "${BOLD}open${RESET}" - printf ' %b Pull latest Docker image\n' "${BOLD}pull${RESET}" - printf ' %b [version...] Show disk usage per VM\n' "${BOLD}disk${RESET}" - printf ' %b [name] Back up VM data directory\n' "${BOLD}snapshot${RESET}" - printf ' %b [name] Restore VM data from snapshot\n' "${BOLD}restore${RESET}" - printf ' %b [--data] Remove stopped containers\n' "${BOLD}clean${RESET}" - printf ' %b Permanently remove an instance\n' "${BOLD}destroy${RESET}" - printf ' %b [base] List all registered instances\n' "${BOLD}instances${RESET}" - printf ' %b Manage ISO cache (save/list/rm/flush)\n' "${BOLD}cache${RESET}" - printf ' %b Show this help message\n' "${BOLD}help${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 (save/list/rm/flush)" + _help_row "help [topic]" "Show help (topics below, or 'all')" printf '\n' - printf '%b\n' "${BOLD}INSTANCE FLAGS (used with start)${RESET}" - printf ' %b Create a new instance of a version\n' "${BOLD}--new${RESET}" - printf ' %b [name] Name the instance (default: auto-numbered)\n' "${BOLD}--new${RESET}" - printf ' %b Clone data from base version to new instance\n' "${BOLD}--clone${RESET}" + 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 (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' @@ -2825,7 +2994,61 @@ show_usage() { 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 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 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' + printf '%b\n' "${BOLD}AUTO-CACHE${RESET}" + printf '\n' + printf ' Set AUTO_CACHE=Y in .env to automatically cache ISOs on stop.\n' + printf ' Cached ISOs are auto-restored when creating new instances with --new.\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}" @@ -2844,29 +3067,62 @@ show_usage() { printf ' %s clean # Remove stopped containers\n' "${SCRIPT_NAME}" printf '\n' printf '%b\n' "${BOLD}INSTANCE EXAMPLES${RESET}" - 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 '\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 ' %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' - 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}" +} + +_help_topic_config() { + printf '%b\n' "${BOLD}CONFIGURATION${RESET}" 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 ' 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 } # ============================================================================== @@ -2905,7 +3161,7 @@ main() { instances) cmd_instances "$@" ;; cache) cmd_cache "$@" ;; help|--help|-h) - show_usage + show_usage "$@" ;; "") show_usage From 2e51fafe80fd2265d60dad8fec9aecf51ced81d5 Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Fri, 30 Jan 2026 12:41:40 +0000 Subject: [PATCH 32/33] fix: Cache original ISOs instead of rebuilt ones, add cache download The container's genisoimage rebuilds ISOs with a duplicate boot catalog entry that 7z cannot re-extract ("Break signaled"). This caused cache restore to fail for new instances. - Add `cache download ` command to download original ISOs directly using the container's download logic - Add `_is_rebuilt_iso()` helper to detect rebuilt ISOs (magic byte 0x16) - Skip rebuilt ISOs in `cache save` and `auto_cache_save` with warning - Remove magic byte manipulation from restore paths (originals work as-is) - Update WINCTL_GUIDE.md and readme.md with new cache workflow Co-Authored-By: Claude Opus 4.5 --- WINCTL_GUIDE.md | 47 +++++----- readme.md | 4 +- winctl.sh | 226 ++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 205 insertions(+), 72 deletions(-) diff --git a/WINCTL_GUIDE.md b/WINCTL_GUIDE.md index 584906a..fb8f596 100644 --- a/WINCTL_GUIDE.md +++ b/WINCTL_GUIDE.md @@ -702,21 +702,29 @@ Windows ISOs are large (3-6 GB) and re-downloaded every time a new container is ### How It Works -1. Start a VM and wait for the ISO to download -2. Cache the ISO: `./winctl.sh cache save winxp` -3. Create new instances — cached ISOs are auto-restored: `./winctl.sh start winxp --new` +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 metadata files, then copies them into the new instance's data directory before starting the container. The container recognizes the ISO as already processed and boots directly — no download or re-extraction needed. +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. -### Caching an ISO +### Downloading an ISO ```bash -# Cache ISOs from an existing VM's data directory -./winctl.sh cache save winxp -./winctl.sh cache save win11 +# Download original ISOs to the cache +./winctl.sh cache download winxp +./winctl.sh cache download win11 ``` -The ISOs and metadata files (`windows.base`, `windows.ver`, `windows.mode`, `windows.type`, `windows.args`) are copied from `data//` to `cache//`. +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 @@ -743,11 +751,11 @@ Both commands require typing `yes` to confirm. When creating a new instance with `--new` (without `--clone`), winctl automatically checks the cache: ```bash -# If cache/winxp/ has ISOs + metadata, they are copied to data/winxp-1/ before start +# If cache/winxp/ has an ISO, it is copied to data/winxp-1/ before start ./winctl.sh start winxp --new ``` -Both the ISO and metadata files are restored so the container recognizes the ISO as already processed. This is skipped when using `--clone`, since cloning copies all data from the base version including any ISOs. +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 @@ -758,28 +766,21 @@ To automatically cache ISOs whenever a container is stopped, add `AUTO_CACHE=Y` AUTO_CACHE=Y ``` -When enabled, `winctl.sh stop` will silently cache any ISOs found in the stopped container's data directory. ISOs that are already cached are skipped. This removes the need to manually run `cache save` after the first download. +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 -│ ├── windows.base -│ ├── windows.ver -│ ├── windows.mode -│ ├── windows.type -│ └── windows.args +│ └── winxpx86.iso ├── win11/ -│ ├── win11x64.iso -│ └── ... +│ └── win11x64.iso └── win10/ - ├── win10x64.iso - └── ... + └── win10x64.iso ``` -> **Note:** The cache stores processed ISOs and their metadata from the container's data directory, not raw downloads. Metadata files tell the container the ISO is already processed, so it boots directly without re-extracting or re-downloading. +> **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. --- diff --git a/readme.md b/readme.md index cb88ab8..143a891 100644 --- a/readme.md +++ b/readme.md @@ -130,11 +130,11 @@ Use `winctl.sh` for easy container management: ./winctl.sh destroy winxp-lab # Remove instance # ISO cache (skip re-downloads for new instances) -./winctl.sh cache save winxp # Cache ISO after first download +./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 -# Or set AUTO_CACHE=Y in .env to cache ISOs automatically on stop # Full help (includes ARM64 info) ./winctl.sh help diff --git a/winctl.sh b/winctl.sh index be729f5..7b8fa9d 100755 --- a/winctl.sh +++ b/winctl.sh @@ -1092,17 +1092,10 @@ cmd_start() { 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 - # Keep original filename — the container's parseVersion - # determines the expected name (e.g. winxpx86.iso, not xp.iso) local iso_name iso_name=$(basename "$cached_iso") info "Restoring cached ISO: $iso_name..." cp "$cached_iso" "$data_dir/$iso_name" - # Restore metadata so the container recognizes the ISO as processed - local _meta - for _meta in windows.base windows.ver windows.mode windows.type windows.args; do - [[ -f "$cache_src/$_meta" ]] && cp "$cache_src/$_meta" "$data_dir/$_meta" - done success "ISO restored from cache (skipping download)" fi fi @@ -2397,17 +2390,10 @@ cmd_new() { 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 - # Keep original filename — the container's parseVersion - # determines the expected name (e.g. winxpx86.iso, not xp.iso) local iso_name iso_name=$(basename "$cached_iso") info "Restoring cached ISO: $iso_name..." cp "$cached_iso" "$data_dir/$iso_name" - # Restore metadata so the container recognizes the ISO as processed - local _meta - for _meta in windows.base windows.ver windows.mode windows.type windows.args; do - [[ -f "$cache_src/$_meta" ]] && cp "$cache_src/$_meta" "$data_dir/$_meta" - done success "ISO restored from cache (skipping download)" fi fi @@ -2597,7 +2583,17 @@ cmd_instances() { # ISO CACHE # ============================================================================== -# Non-interactive cache save — silently skips if no ISOs or already cached. +# 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" @@ -2614,19 +2610,13 @@ auto_cache_save() { 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" - - # Cache metadata files so the container recognizes the ISO as processed - local meta - for meta in windows.base windows.ver windows.mode windows.type windows.args; do - if [[ -f "$data_dir/$meta" ]] && [[ ! -f "$cache_dest/$meta" ]]; then - cp "$data_dir/$meta" "$cache_dest/$meta" - fi - done } cmd_cache() { @@ -2634,18 +2624,19 @@ cmd_cache() { shift || true case "$subcmd" in - save) cmd_cache_save "$@" ;; - list) cmd_cache_list ;; - rm) cmd_cache_rm "$@" ;; - flush) cmd_cache_flush ;; + 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 " + printf '%s\n' " Usage: ${SCRIPT_NAME} cache " exit 1 ;; *) error "Unknown cache subcommand: $subcmd" - printf '%s\n' " Usage: ${SCRIPT_NAME} cache " + printf '%s\n' " Usage: ${SCRIPT_NAME} cache " exit 1 ;; esac @@ -2681,9 +2672,15 @@ cmd_cache_save() { 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 @@ -2697,16 +2694,144 @@ cmd_cache_save() { ((count++)) done <<< "$iso_files" - # Cache metadata files so the container recognizes the ISO as processed - local meta - for meta in windows.base windows.ver windows.mode windows.type windows.args; do - if [[ -f "$data_dir/$meta" ]] && [[ ! -f "$cache_dest/$meta" ]]; then - cp "$data_dir/$meta" "$cache_dest/$meta" - fi - done + 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 "Cached $count ISO file(s) to cache/$RESOLVED_BASE/" + success "Downloaded and cached: $dl_name ($size)" printf '\n' } @@ -2905,7 +3030,7 @@ _help_summary() { _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 (save/list/rm/flush)" + _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}" @@ -2985,7 +3110,7 @@ _help_topic_commands() { _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 (save/list/rm/flush)" + _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}" @@ -3027,22 +3152,29 @@ _help_topic_instances() { _help_topic_cache() { printf '%b\n' "${BOLD}CACHE COMMANDS${RESET}" printf '\n' - _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" + _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 save winxp # Cache ISO after first download\n' "${SCRIPT_NAME}" + 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}AUTO-CACHE${RESET}" + printf '%b\n' "${BOLD}HOW IT WORKS${RESET}" printf '\n' - printf ' Set AUTO_CACHE=Y in .env to automatically cache ISOs on stop.\n' - printf ' Cached ISOs are auto-restored when creating new instances with --new.\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' } From 31b71532751dc5748ee70440d4d85249a805faca Mon Sep 17 00:00:00 2001 From: Michel Abboud Date: Fri, 30 Jan 2026 17:12:14 +0000 Subject: [PATCH 33/33] docs: Update changelog with all v1.0.0 features Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1ccb5..6d9507f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,39 @@ All notable changes to this project will be documented in this file. -## [1.0.0] - 2026-01-28 +## [1.0.0] - 2026-01-30 ### Added - **winctl.sh**: Management script for Windows Docker containers - - 13 commands: start, stop, restart, status, logs, shell, stats, build, rebuild, list, inspect, monitor, check, refresh + - 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) @@ -24,11 +46,13 @@ All notable changes to this project will be documented in this file. - 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 `unless-stopped` +- Restart policy changed from `always` to `on-failure` +- `clean --data` now unregisters instances and removes compose files ### Resource Profiles