chore: import upstream snapshot with attribution
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# GitHub repo 'Sponsor' button. GitHub Sponsors is now approved for
|
||||
# kernalix7, so it goes first (renders with the GitHub heart). Ko-fi is on
|
||||
# GitHub's recognized list so it uses the proper `ko_fi:` field (renders
|
||||
# with the Ko-fi icon). fairy.hada.io is a Korean tipping platform not on
|
||||
# the recognized list, so it goes under `custom:` (generic external link).
|
||||
github: kernalix7
|
||||
ko_fi: kernalix7
|
||||
custom:
|
||||
- https://fairy.hada.io/@kernalix7
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug to help us improve WinPodX
|
||||
title: "[Bug] "
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
A clear and concise description of the bug.
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What actually happened.
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS**: [e.g. Ubuntu 24.04, Fedora 41, openSUSE Tumbleweed]
|
||||
- **Python version**: [e.g. 3.13]
|
||||
- **FreeRDP version**: [e.g. 3.x]
|
||||
- **Desktop Environment**: [e.g. GNOME 47, KDE Plasma 6]
|
||||
- **Display Server**: [e.g. X11, Wayland]
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain the problem.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other context about the problem here.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement for WinPodX
|
||||
title: "[Feature] "
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A clear and concise description of the feature you'd like.
|
||||
|
||||
## Motivation
|
||||
|
||||
Why is this feature needed? What problem does it solve?
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Describe how you'd like this to work.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Any alternative solutions or features you've considered.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other context, mockups, or references here.
|
||||
@@ -0,0 +1,23 @@
|
||||
## Summary
|
||||
|
||||
Brief description of what this PR does.
|
||||
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Related Issues
|
||||
|
||||
Closes #
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `pytest tests/ -v`: all tests pass
|
||||
- [ ] `ruff check src/ tests/`: zero errors
|
||||
- [ ] `ruff format --check src/ tests/`: formatted
|
||||
- [ ] Documentation updated (CHANGELOG, docs: both ko & en)
|
||||
- [ ] No hardcoded paths, credentials, or personal info
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots for UI changes.
|
||||
@@ -0,0 +1,283 @@
|
||||
name: AppImage
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Optional release tag to build from (e.g. v0.5.8). Leave empty to build from the selected ref (usually main) -- useful for verifying the recipe before cutting a tag."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# workflow_dispatch input: explicit tag; tag push: ref already there.
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install build prerequisites
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --upgrade build
|
||||
|
||||
- name: Pre-install appimagetool (extract-and-wrap, FUSE-free)
|
||||
# GitHub Actions ubuntu-24.04 runners cannot mount AppImages
|
||||
# via FUSE in the sandboxed environment. Download appimagetool
|
||||
# ourselves, extract its squashfs payload, and put a thin
|
||||
# wrapper on PATH at /usr/local/bin/appimagetool that exec's
|
||||
# into the extracted AppRun.
|
||||
run: |
|
||||
cd /tmp
|
||||
wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool.AppImage
|
||||
chmod +x appimagetool.AppImage
|
||||
./appimagetool.AppImage --appimage-extract >/dev/null
|
||||
sudo mv squashfs-root /opt/appimagetool
|
||||
sudo tee /usr/local/bin/appimagetool >/dev/null <<'WRAPPER'
|
||||
#!/bin/sh
|
||||
exec /opt/appimagetool/AppRun "$@"
|
||||
WRAPPER
|
||||
sudo chmod +x /usr/local/bin/appimagetool
|
||||
appimagetool --version || true
|
||||
|
||||
- name: Build winpodx wheel
|
||||
run: python -m build --wheel --outdir dist
|
||||
|
||||
- name: Stage AppDir with portable Python + winpodx
|
||||
# Bypass python-appimage entirely -- it does its own
|
||||
# appimagetool download that ignores our PATH wrapper and
|
||||
# silently fails on CI. Instead: drop a python-build-standalone
|
||||
# portable Python 3.11 into AppDir, install winpodx into its
|
||||
# site-packages, write AppRun + desktop + icon, then hand off
|
||||
# to bundle-system-bins.sh for the FreeRDP overlay.
|
||||
#
|
||||
# Thin AppImage (0.6.0 item A): we no longer pip-install
|
||||
# podman-compose into the bundled interpreter, and the dnf step
|
||||
# below no longer installs the podman stack. Rootless podman
|
||||
# cannot be bundled (host systemd / subuid integration); the
|
||||
# AppImage now requires the user to install podman / docker /
|
||||
# libvirt from the host package manager. See #357 / #363 for the
|
||||
# root cause this redesign removes.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p AppDir/opt AppDir/usr/bin AppDir/usr/lib
|
||||
# Portable Python 3.11 with linked OpenSSL, gnu-linux x86_64.
|
||||
# Pinned to a known-good astral-sh python-build-standalone release.
|
||||
# Bump the tag + version pair together when refreshing.
|
||||
PYBUILD_URL="https://github.com/astral-sh/python-build-standalone/releases/download/20240909/cpython-3.11.10+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz"
|
||||
curl -fsSL "${PYBUILD_URL}" -o /tmp/python-standalone.tar.gz
|
||||
# Supply-chain: verify against the upstream .sha256 sidecar
|
||||
# (astral-sh publishes one per asset). The portable interpreter
|
||||
# executes winpodx's code -- a tampered asset would run with the
|
||||
# user's RDP password + agent token, so fail closed on mismatch.
|
||||
curl -fsSL "${PYBUILD_URL}.sha256" -o /tmp/python-standalone.sha256
|
||||
echo "$(cat /tmp/python-standalone.sha256) /tmp/python-standalone.tar.gz" | sha256sum -c -
|
||||
tar -xzf /tmp/python-standalone.tar.gz -C AppDir/opt/
|
||||
# Tarball extracts to AppDir/opt/python/ on the install_only build.
|
||||
AppDir/opt/python/bin/python3 -m pip install --upgrade pip
|
||||
WHEEL="$(ls -t dist/winpodx-*.whl | head -n1)"
|
||||
AppDir/opt/python/bin/python3 -m pip install --no-cache-dir "${WHEEL}[gui,reverse-open]"
|
||||
# AppRun
|
||||
cat > AppDir/AppRun <<'APPRUN'
|
||||
#!/bin/sh
|
||||
APPDIR="$(dirname "$(readlink -f "$0")")"
|
||||
export PATH="$APPDIR/usr/bin:$APPDIR/opt/python/bin:$PATH"
|
||||
export LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/opt/python/lib:$LD_LIBRARY_PATH"
|
||||
export PYTHONPATH="$APPDIR/opt/python/lib/python3.11/site-packages:$PYTHONPATH"
|
||||
# Invoke via `python3 -m winpodx`, NOT the pip-generated
|
||||
# bin/winpodx console script. pip bakes an absolute build-time
|
||||
# shebang (#!/home/runner/work/.../AppDir/opt/python/bin/python3)
|
||||
# into bin/winpodx that doesn't exist at AppImage-extract time
|
||||
# -> "bad interpreter". The -m form runs the module through the
|
||||
# bundled interpreter directly and ignores the broken shebang.
|
||||
exec "$APPDIR/opt/python/bin/python3" -m winpodx "$@"
|
||||
APPRUN
|
||||
chmod +x AppDir/AppRun
|
||||
# Desktop + icon -- appimagetool expects them in AppDir root.
|
||||
cp packaging/appimage/recipe/winpodx.desktop AppDir/
|
||||
cp packaging/appimage/recipe/winpodx.png AppDir/
|
||||
# Symlink common icon location too (some launchers look here)
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
cp packaging/appimage/recipe/winpodx.png AppDir/usr/share/icons/hicolor/256x256/apps/winpodx.png
|
||||
# License texts (LGPL/GPL/Apache/PSF redistribution obligation --
|
||||
# the fat AppImage redistributes third-party binaries, so their
|
||||
# license + NOTICE texts must travel with it). winpodx's own MIT
|
||||
# LICENSE + the aggregate THIRD_PARTY_LICENSES.md go at the doc
|
||||
# root; the Fedora-bundled binaries' texts are added by the
|
||||
# docker step below under third-party/. PyPI deps (PySide6/Qt,
|
||||
# Pillow, cairosvg, pyxdg) already carry their license in their
|
||||
# site-packages *.dist-info inside AppDir/opt/python.
|
||||
mkdir -p AppDir/usr/share/doc/winpodx/third-party
|
||||
cp LICENSE THIRD_PARTY_LICENSES.md AppDir/usr/share/doc/winpodx/
|
||||
# python-build-standalone ships its license files; surface them.
|
||||
find AppDir/opt/python -maxdepth 3 -iname "LICENSE*" -path "*python*" \
|
||||
-exec cp {} AppDir/usr/share/doc/winpodx/third-party/python-LICENSE.txt \; 2>/dev/null || true
|
||||
# LGPL compliance (legal gate): the fat AppImage REDISTRIBUTES the
|
||||
# PySide6/Qt6 binaries (LGPL-3.0), so their license text must travel
|
||||
# with it. The PySide6 wheels do NOT ship the LGPL/GPL texts (verified:
|
||||
# neither the dist-info nor the PySide6/ tree carries them), so we
|
||||
# vendor the verbatim texts + a NOTICE in-repo under
|
||||
# packaging/appimage/licenses/pyside6/ and copy those in. HARD-FAIL if
|
||||
# the vendored texts are missing, so the compliance artifact can't be
|
||||
# dropped silently.
|
||||
mkdir -p AppDir/usr/share/doc/winpodx/third-party/pyside6
|
||||
cp packaging/appimage/licenses/pyside6/*.txt \
|
||||
AppDir/usr/share/doc/winpodx/third-party/pyside6/
|
||||
if ! ls AppDir/usr/share/doc/winpodx/third-party/pyside6/ 2>/dev/null \
|
||||
| grep -qiE "lgpl"; then
|
||||
echo "::error::LGPL compliance: no PySide6/Qt LGPL text bundled in the AppImage; refusing to publish a non-compliant build." >&2
|
||||
exit 1
|
||||
fi
|
||||
du -sh AppDir
|
||||
|
||||
- name: Slim PySide6 — drop unused Qt6 modules
|
||||
# PySide6 wheels bundle the entire Qt6 stack (QtWebEngine ~100 MB+,
|
||||
# QtQuick/QtQml, Qt3D, QtCharts, QtMultimedia + FFmpeg, QtPdf, …).
|
||||
# winpodx links only QtCore/QtGui/QtWidgets/QtSvg/QtDBus, so the rest
|
||||
# is dead weight — this was the real reason the "Thin" AppImage was
|
||||
# still ~270 MB (dropping the podman stack only saved ~20 MB). The
|
||||
# prune removes ~500 MB uncompressed (the bulk of the AppImage).
|
||||
run: |
|
||||
bash packaging/appimage/slim-pyside6.sh \
|
||||
AppDir/opt/python/lib/python3.11/site-packages/PySide6
|
||||
du -sh AppDir
|
||||
|
||||
- name: Bundle FreeRDP via Fedora 41
|
||||
# Thin-bundle stage: pulls only the FreeRDP 3 client binaries +
|
||||
# libwinpr out of a Fedora 41 container and traverses ldd to bring
|
||||
# in transitive lib deps. Host-critical libs (glibc, libX11,
|
||||
# libwayland, libGL, ...) stay on the host -- they cannot be
|
||||
# bundled without breaking desktop integration or hard-crashing
|
||||
# on glibc mismatch.
|
||||
#
|
||||
# The container stack (podman / podman-compose / conmon / crun /
|
||||
# netavark / aardvark-dns / slirp4netns / passt / pasta) is
|
||||
# intentionally NOT installed here -- 0.6.0 item A drops it.
|
||||
# Rootless podman needs host systemd / subuid integration that
|
||||
# an AppImage can't carry; bundling it caused #357 (shadowed
|
||||
# host podman-compose) and #363 (LD_LIBRARY_PATH-poisoned
|
||||
# systemd-run / aardvark-dns). The AppImage now requires the
|
||||
# user to install a container runtime from the host package
|
||||
# manager (same model as install.sh).
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD/AppDir:/AppDir" \
|
||||
-v "$PWD/packaging/appimage:/scripts" \
|
||||
fedora:41 bash -c '
|
||||
set -euo pipefail
|
||||
dnf install -y --setopt=install_weak_deps=False \
|
||||
freerdp \
|
||||
freerdp-libs \
|
||||
libwinpr \
|
||||
file
|
||||
echo "[provenance] freerdp binaries provided:"
|
||||
rpm -ql freerdp | grep -E "^(/usr/bin|/usr/libexec)/.*freerdp.*" || true
|
||||
bash /scripts/bundle-system-bins.sh /AppDir
|
||||
# License + NOTICE texts for the bundled FreeRDP stack
|
||||
# (Apache-2.0). VENDORED in-repo at
|
||||
# packaging/appimage/licenses/ (mounted at /scripts/licenses),
|
||||
# harvested verbatim from the Fedora rpm payloads -- see
|
||||
# that dir`s README for provenance + refresh steps. We do
|
||||
# NOT read them off the installed filesystem: the fedora
|
||||
# image installs with `tsflags=nodocs`, which strips
|
||||
# /usr/share/licenses, and every attempt to defeat that on
|
||||
# the CI runner silently produced nothing.
|
||||
#
|
||||
# Thin AppImage: only the FreeRDP stack`s license texts
|
||||
# ride along now; the container-stack license dirs (podman
|
||||
# / podman-compose / conmon / crun / netavark / passt /
|
||||
# slirp4netns) stay vendored in-repo for provenance but are
|
||||
# no longer copied into the AppImage because their binaries
|
||||
# are no longer bundled.
|
||||
dst=/AppDir/usr/share/doc/winpodx/third-party
|
||||
mkdir -p "$dst"
|
||||
src=/scripts/licenses
|
||||
# REQUIRED FreeRDP-stack license texts -- fail the build if
|
||||
# missing. Apache-2.0 redistribution obligation: freerdp-libs
|
||||
# + libwinpr ship the combined FreeRDP license.
|
||||
for pkg in freerdp-libs libwinpr; do
|
||||
if [ ! -d "$src/$pkg" ]; then
|
||||
echo "FATAL: vendored license dir $src/$pkg missing -- cannot ship a" >&2
|
||||
echo "license-compliant AppImage. Re-run packaging/appimage/licenses refresh." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -rL "$src/$pkg" "$dst/$pkg"
|
||||
done
|
||||
echo "[provenance] bundled license dirs:"
|
||||
ls -1 "$dst" || true
|
||||
'
|
||||
|
||||
- name: Repack as thin AppImage
|
||||
env:
|
||||
ARCH: x86_64
|
||||
run: |
|
||||
OUT="winpodx-x86_64.AppImage"
|
||||
appimagetool AppDir "${OUT}"
|
||||
chmod +x "${OUT}"
|
||||
ls -lh "${OUT}"
|
||||
|
||||
- name: Locate built AppImage
|
||||
id: locate
|
||||
run: |
|
||||
APPIMAGE="$(ls -t winpodx-x86_64.AppImage | head -n1)"
|
||||
if [ -z "${APPIMAGE}" ]; then
|
||||
echo "No AppImage produced." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "appimage_path=${APPIMAGE}" >> "$GITHUB_OUTPUT"
|
||||
ls -lh "${APPIMAGE}"
|
||||
|
||||
- name: Upload as workflow artefact (always)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: winpodx-appimage-x86_64
|
||||
path: ${{ steps.locate.outputs.appimage_path }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Single-writer rule: release.yml (REL- marker tag) is the SOLE creator
|
||||
# of the GitHub Release and the only writer of its title/notes. This
|
||||
# workflow only attaches the AppImage to an already-created release —
|
||||
# it never creates or edits the notes. (softprops/action-gh-release
|
||||
# would create-or-update the release even with only `files:` set, which
|
||||
# contributed to the "edited N times" churn; `gh release upload` to an
|
||||
# existing release does not.) Wait up to 12 min for release.yml — which
|
||||
# runs the full test suite first — to create the release; skip
|
||||
# gracefully if it never appears (the AppImage stays a build artifact).
|
||||
- name: Wait for release (release.yml owns creation)
|
||||
if: github.event_name == 'push'
|
||||
id: wait
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${{ github.ref_name }}"
|
||||
for _ in $(seq 1 48); do
|
||||
if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release $tag exists — attaching AppImage."
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Release $tag not created yet (release.yml owns creation) — waiting…"
|
||||
sleep 15
|
||||
done
|
||||
echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping AppImage upload; it remains as a build artifact."
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach AppImage to GitHub Release (tag push only)
|
||||
if: github.event_name == 'push' && steps.wait.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" \
|
||||
"${{ steps.locate.outputs.appimage_path }}" --clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
@@ -0,0 +1,118 @@
|
||||
name: AUR Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (e.g. v0.1.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
# Secret-gated so tag pushes made before AUR_SSH_PRIVATE_KEY is set up
|
||||
# (see packaging/aur/README.md for the one-time onboarding flow) do not
|
||||
# red-X the Release. Once the secret is configured, subsequent tag
|
||||
# pushes publish to AUR automatically with no other changes.
|
||||
- name: Check AUR secret presence
|
||||
id: check
|
||||
env:
|
||||
AUR_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -z "$AUR_KEY" ]; then
|
||||
echo "::notice::AUR_SSH_PRIVATE_KEY is not set; skipping AUR publish. See packaging/aur/README.md for one-time setup."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Resolve tag
|
||||
if: steps.check.outputs.skip == 'false'
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${{ github.ref_name }}"
|
||||
else
|
||||
tag="${{ inputs.tag }}"
|
||||
fi
|
||||
# AUR pkgver disallows '-' (reserved for the pkgrel delimiter),
|
||||
# so strip the RTM gate suffix before stamping PKGBUILD. Tag
|
||||
# name itself is preserved for the source URL.
|
||||
version="${tag#v}"
|
||||
version="${version%%-RTM*}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render PKGBUILD (stamp version + sha256)
|
||||
if: steps.check.outputs.skip == 'false'
|
||||
id: render
|
||||
run: |
|
||||
set -e
|
||||
version="${{ steps.tag.outputs.version }}"
|
||||
tag="${{ steps.tag.outputs.tag }}"
|
||||
url="https://github.com/${{ github.repository }}/archive/${tag}.tar.gz"
|
||||
|
||||
# GitHub generates the archive tarball on first request; give it
|
||||
# a few attempts in case the tag-push → archive materialization
|
||||
# has a small race window.
|
||||
for i in 1 2 3 4 5; do
|
||||
sha=$(curl -sSL "$url" | sha256sum | awk '{print $1}')
|
||||
# sha256 of empty string → don't accept it.
|
||||
if [ "$sha" != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ] \
|
||||
&& [ -n "$sha" ]; then
|
||||
break
|
||||
fi
|
||||
echo "archive not ready yet, retrying ($i/5)..."
|
||||
sleep 5
|
||||
done
|
||||
if [ -z "$sha" ]; then
|
||||
echo "failed to compute sha256 for $url" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "sha256: $sha"
|
||||
|
||||
# Anchor on the assignment lines so the template comment at the
|
||||
# top (which mentions __PKGVER__ / __SHA256__ literally) is not
|
||||
# also rewritten — otherwise the published PKGBUILD ships with
|
||||
# the stamped values inlined into the explanatory text.
|
||||
sed -e "s|^pkgver=__PKGVER__$|pkgver=${version}|" \
|
||||
-e "s|^sha256sums=('__SHA256__')$|sha256sums=('${sha}')|" \
|
||||
packaging/aur/PKGBUILD > PKGBUILD.rendered
|
||||
mv PKGBUILD.rendered packaging/aur/PKGBUILD
|
||||
echo "---- rendered PKGBUILD ----"
|
||||
cat packaging/aur/PKGBUILD
|
||||
|
||||
# KSXGitHub/github-actions-deploy-aur handles: makepkg-style SRCINFO
|
||||
# regeneration, SSH key material setup, ssh-keyscan of aur.archlinux.org,
|
||||
# and the git clone/commit/push to ssh://aur@aur.archlinux.org/<pkg>.git.
|
||||
# Pinned to v4.1.3 (specific patch) — v2 floating tag has an upstream
|
||||
# regression that fails with `bash: --command: invalid option`
|
||||
# (upstream issue #50, fixed 2026-04-18). v3+ uses the same input
|
||||
# shape, so the pin is safe.
|
||||
- name: Publish to AUR
|
||||
if: steps.check.outputs.skip == 'false'
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
|
||||
with:
|
||||
pkgname: winpodx
|
||||
pkgbuild: packaging/aur/PKGBUILD
|
||||
# #255 PR 4: ship the install scriptlet alongside PKGBUILD so
|
||||
# pacman's install= directive resolves at install time. Without
|
||||
# this, the post_install / post_remove banners + cleanup hook
|
||||
# don't fire (the PKGBUILD references winpodx.install but the
|
||||
# AUR repo wouldn't have the file).
|
||||
assets: |
|
||||
packaging/aur/winpodx.install
|
||||
commit_username: kernalix7
|
||||
commit_email: kernalix7@kodenet.io
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_message: "Update to ${{ steps.tag.outputs.version }}"
|
||||
ssh_keyscan_types: rsa,ecdsa,ed25519
|
||||
@@ -0,0 +1,280 @@
|
||||
name: Check Windows Build Updates
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 3" # Every Wednesday 09:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Check latest dockur Windows images
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# x86_64 Windows image (dockur/windows)
|
||||
DOCKUR_LATEST=$(gh api repos/dockur/windows/releases/latest --jq '.tag_name' 2>/dev/null || echo "unknown")
|
||||
echo "dockur_latest=$DOCKUR_LATEST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ARM Windows image (dockur/windows-arm) — pulled in by aarch64
|
||||
# hosts via core/config.py:_default_pod_image. See #141.
|
||||
DOCKUR_ARM_LATEST=$(gh api repos/dockur/windows-arm/releases/latest --jq '.tag_name' 2>/dev/null || echo "unknown")
|
||||
echo "dockur_arm_latest=$DOCKUR_ARM_LATEST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
VERSION_FILE="config/oem/VERSIONS.txt"
|
||||
DOCKUR_CURRENT=""
|
||||
DOCKUR_ARM_CURRENT=""
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
DOCKUR_CURRENT=$(grep "^dockur=" "$VERSION_FILE" | cut -d= -f2 || echo "")
|
||||
DOCKUR_ARM_CURRENT=$(grep "^dockur-arm=" "$VERSION_FILE" | cut -d= -f2 || echo "")
|
||||
fi
|
||||
|
||||
NEEDS_UPDATE_X86=false
|
||||
if [ "$DOCKUR_LATEST" != "$DOCKUR_CURRENT" ] && [ "$DOCKUR_LATEST" != "unknown" ]; then
|
||||
NEEDS_UPDATE_X86=true
|
||||
echo "dockur/windows: ${DOCKUR_CURRENT:-none} -> $DOCKUR_LATEST"
|
||||
fi
|
||||
|
||||
NEEDS_UPDATE_ARM=false
|
||||
if [ "$DOCKUR_ARM_LATEST" != "$DOCKUR_ARM_CURRENT" ] && [ "$DOCKUR_ARM_LATEST" != "unknown" ]; then
|
||||
NEEDS_UPDATE_ARM=true
|
||||
echo "dockur/windows-arm: ${DOCKUR_ARM_CURRENT:-none} -> $DOCKUR_ARM_LATEST"
|
||||
fi
|
||||
|
||||
echo "needs_update_x86=$NEEDS_UPDATE_X86" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_update_arm=$NEEDS_UPDATE_ARM" >> "$GITHUB_OUTPUT"
|
||||
echo "dockur_current=$DOCKUR_CURRENT" >> "$GITHUB_OUTPUT"
|
||||
echo "dockur_arm_current=$DOCKUR_ARM_CURRENT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check :latest digest drift on Docker Hub
|
||||
id: digest_check
|
||||
run: |
|
||||
# Anonymous Docker Hub Registry API. We fetch the manifest
|
||||
# headers for ``:latest`` and read ``Docker-Content-Digest``
|
||||
# to detect silent rebuilds (digest changed but the GitHub
|
||||
# release tag did not). dockur sometimes ships security
|
||||
# patches as same-tag rebuilds, which the release-tag check
|
||||
# alone misses.
|
||||
fetch_digest() {
|
||||
local repo="$1"
|
||||
local out_dir
|
||||
out_dir="$(mktemp -d)"
|
||||
curl -fsSL -o "$out_dir/token.json" \
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull"
|
||||
local token
|
||||
token=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['token'])" "$out_dir/token.json")
|
||||
curl -fsSL -D "$out_dir/headers.txt" -o /dev/null \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json" \
|
||||
"https://registry-1.docker.io/v2/${repo}/manifests/latest"
|
||||
grep -i '^docker-content-digest:' "$out_dir/headers.txt" \
|
||||
| awk '{print $2}' | tr -d '\r\n'
|
||||
}
|
||||
|
||||
DIGEST_X86_LATEST=$(fetch_digest "dockurr/windows" || echo "")
|
||||
DIGEST_ARM_LATEST=$(fetch_digest "dockurr/windows-arm" || echo "")
|
||||
|
||||
VERSION_FILE="config/oem/VERSIONS.txt"
|
||||
DIGEST_X86_CURRENT=""
|
||||
DIGEST_ARM_CURRENT=""
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
DIGEST_X86_CURRENT=$(grep "^dockur-digest=" "$VERSION_FILE" | cut -d= -f2- || echo "")
|
||||
DIGEST_ARM_CURRENT=$(grep "^dockur-arm-digest=" "$VERSION_FILE" | cut -d= -f2- || echo "")
|
||||
fi
|
||||
|
||||
DIGEST_DRIFT_X86=false
|
||||
if [ -n "$DIGEST_X86_LATEST" ] && [ "$DIGEST_X86_LATEST" != "$DIGEST_X86_CURRENT" ]; then
|
||||
DIGEST_DRIFT_X86=true
|
||||
echo "dockur/windows:latest digest drift: ${DIGEST_X86_CURRENT:-none} -> $DIGEST_X86_LATEST"
|
||||
fi
|
||||
|
||||
DIGEST_DRIFT_ARM=false
|
||||
if [ -n "$DIGEST_ARM_LATEST" ] && [ "$DIGEST_ARM_LATEST" != "$DIGEST_ARM_CURRENT" ]; then
|
||||
DIGEST_DRIFT_ARM=true
|
||||
echo "dockur/windows-arm:latest digest drift: ${DIGEST_ARM_CURRENT:-none} -> $DIGEST_ARM_LATEST"
|
||||
fi
|
||||
|
||||
echo "digest_drift_x86=$DIGEST_DRIFT_X86" >> "$GITHUB_OUTPUT"
|
||||
echo "digest_drift_arm=$DIGEST_DRIFT_ARM" >> "$GITHUB_OUTPUT"
|
||||
echo "digest_x86_latest=$DIGEST_X86_LATEST" >> "$GITHUB_OUTPUT"
|
||||
echo "digest_arm_latest=$DIGEST_ARM_LATEST" >> "$GITHUB_OUTPUT"
|
||||
echo "digest_x86_current=$DIGEST_X86_CURRENT" >> "$GITHUB_OUTPUT"
|
||||
echo "digest_arm_current=$DIGEST_ARM_CURRENT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Open x86_64 tracking issue
|
||||
if: steps.check.outputs.needs_update_x86 == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKUR_LATEST: ${{ steps.check.outputs.dockur_latest }}
|
||||
DOCKUR_CURRENT: ${{ steps.check.outputs.dockur_current }}
|
||||
DIGEST_X86_LATEST: ${{ steps.digest_check.outputs.digest_x86_latest }}
|
||||
run: |
|
||||
TITLE="chore: dockur/windows update available ($DOCKUR_LATEST)"
|
||||
|
||||
EXISTING=$(gh issue list \
|
||||
--search "$TITLE in:title is:open" \
|
||||
--json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Issue #$EXISTING already tracks this update, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh issue create \
|
||||
--title "$TITLE" \
|
||||
--body "$(cat <<EOF
|
||||
## Windows Build Update (x86_64)
|
||||
|
||||
**dockur/windows**: ${DOCKUR_CURRENT:-unknown} → $DOCKUR_LATEST
|
||||
**Current :latest digest**: \`${DIGEST_X86_LATEST:-unknown}\`
|
||||
|
||||
### Action items
|
||||
1. Update \`config/oem/VERSIONS.txt\`:
|
||||
- \`dockur=$DOCKUR_LATEST\`
|
||||
- \`dockur-digest=${DIGEST_X86_LATEST:-<paste new digest>}\`
|
||||
2. Refresh \`DOCKUR_IMAGE_PIN\` in \`src/winpodx/core/config.py\` if rolling forward:
|
||||
\`\`\`
|
||||
podman pull docker.io/dockurr/windows:latest
|
||||
podman image inspect docker.io/dockurr/windows:latest -f '{{json .RepoDigests}}'
|
||||
\`\`\`
|
||||
3. Recreate container to pick up the new Windows build
|
||||
4. Check if \`TargetReleaseVersionInfo\` in \`config/oem/install.bat\` needs updating
|
||||
|
||||
Release notes: https://github.com/dockur/windows/releases/tag/$DOCKUR_LATEST
|
||||
EOF
|
||||
)"
|
||||
|
||||
- name: Open aarch64 tracking issue
|
||||
if: steps.check.outputs.needs_update_arm == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKUR_ARM_LATEST: ${{ steps.check.outputs.dockur_arm_latest }}
|
||||
DOCKUR_ARM_CURRENT: ${{ steps.check.outputs.dockur_arm_current }}
|
||||
DIGEST_ARM_LATEST: ${{ steps.digest_check.outputs.digest_arm_latest }}
|
||||
run: |
|
||||
TITLE="chore: dockur/windows-arm update available ($DOCKUR_ARM_LATEST)"
|
||||
|
||||
EXISTING=$(gh issue list \
|
||||
--search "$TITLE in:title is:open" \
|
||||
--json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Issue #$EXISTING already tracks this update, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh issue create \
|
||||
--title "$TITLE" \
|
||||
--body "$(cat <<EOF
|
||||
## Windows Build Update (aarch64)
|
||||
|
||||
**dockur/windows-arm**: ${DOCKUR_ARM_CURRENT:-unknown} → $DOCKUR_ARM_LATEST
|
||||
**Current :latest digest**: \`${DIGEST_ARM_LATEST:-unknown}\`
|
||||
|
||||
### Action items
|
||||
1. Update \`config/oem/VERSIONS.txt\`:
|
||||
- \`dockur-arm=$DOCKUR_ARM_LATEST\`
|
||||
- \`dockur-arm-digest=${DIGEST_ARM_LATEST:-<paste new digest>}\`
|
||||
2. Refresh \`DOCKUR_IMAGE_ARM_PIN\` in \`src/winpodx/core/config.py\` if rolling forward:
|
||||
\`\`\`
|
||||
podman pull docker.io/dockurr/windows-arm:latest
|
||||
podman image inspect docker.io/dockurr/windows-arm:latest -f '{{json .RepoDigests}}'
|
||||
\`\`\`
|
||||
3. Recreate container on an aarch64 host to pick up the new Windows-on-ARM build
|
||||
4. Check if \`TargetReleaseVersionInfo\` in \`config/oem/install.bat\` needs updating
|
||||
|
||||
Release notes: https://github.com/dockur/windows-arm/releases/tag/$DOCKUR_ARM_LATEST
|
||||
EOF
|
||||
)"
|
||||
|
||||
- name: Open x86_64 silent-rebuild issue
|
||||
# Only fire when digest drifted AND no release-tag bump (the
|
||||
# tag-bump issue above already covers the drift in that case).
|
||||
if: steps.digest_check.outputs.digest_drift_x86 == 'true' && steps.check.outputs.needs_update_x86 != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKUR_LATEST: ${{ steps.check.outputs.dockur_latest }}
|
||||
DIGEST_X86_LATEST: ${{ steps.digest_check.outputs.digest_x86_latest }}
|
||||
DIGEST_X86_CURRENT: ${{ steps.digest_check.outputs.digest_x86_current }}
|
||||
run: |
|
||||
TITLE="chore: dockur/windows :latest digest drift (silent rebuild)"
|
||||
|
||||
EXISTING=$(gh issue list \
|
||||
--search "$TITLE in:title is:open" \
|
||||
--json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Issue #$EXISTING already tracks this drift, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh issue create \
|
||||
--title "$TITLE" \
|
||||
--body "$(cat <<EOF
|
||||
## Silent rebuild of dockur/windows:latest (x86_64)
|
||||
|
||||
The \`:latest\` tag points at a new image digest, but no new
|
||||
release tag was published on GitHub. This usually means an
|
||||
upstream security patch was baked into the same release tag.
|
||||
|
||||
**Tag**: \`${DOCKUR_LATEST:-unknown}\` (unchanged)
|
||||
**Previous digest**: \`${DIGEST_X86_CURRENT:-unknown}\`
|
||||
**New digest**: \`$DIGEST_X86_LATEST\`
|
||||
|
||||
### Action items
|
||||
1. Investigate dockur/windows recent commits for the rebuild trigger:
|
||||
https://github.com/dockur/windows/commits/master
|
||||
2. If the rebuild is desirable, update \`config/oem/VERSIONS.txt\`:
|
||||
- \`dockur-digest=$DIGEST_X86_LATEST\`
|
||||
3. Refresh \`DOCKUR_IMAGE_PIN\` in \`src/winpodx/core/config.py\` if rolling forward.
|
||||
4. If the rebuild looks unintentional or breaks something, leave
|
||||
the pin alone and update only the baseline once stable.
|
||||
EOF
|
||||
)"
|
||||
|
||||
- name: Open aarch64 silent-rebuild issue
|
||||
if: steps.digest_check.outputs.digest_drift_arm == 'true' && steps.check.outputs.needs_update_arm != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKUR_ARM_LATEST: ${{ steps.check.outputs.dockur_arm_latest }}
|
||||
DIGEST_ARM_LATEST: ${{ steps.digest_check.outputs.digest_arm_latest }}
|
||||
DIGEST_ARM_CURRENT: ${{ steps.digest_check.outputs.digest_arm_current }}
|
||||
run: |
|
||||
TITLE="chore: dockur/windows-arm :latest digest drift (silent rebuild)"
|
||||
|
||||
EXISTING=$(gh issue list \
|
||||
--search "$TITLE in:title is:open" \
|
||||
--json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Issue #$EXISTING already tracks this drift, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh issue create \
|
||||
--title "$TITLE" \
|
||||
--body "$(cat <<EOF
|
||||
## Silent rebuild of dockur/windows-arm:latest (aarch64)
|
||||
|
||||
The \`:latest\` tag points at a new image digest, but no new
|
||||
release tag was published on GitHub. This usually means an
|
||||
upstream security patch was baked into the same release tag.
|
||||
|
||||
**Tag**: \`${DOCKUR_ARM_LATEST:-unknown}\` (unchanged)
|
||||
**Previous digest**: \`${DIGEST_ARM_CURRENT:-unknown}\`
|
||||
**New digest**: \`$DIGEST_ARM_LATEST\`
|
||||
|
||||
### Action items
|
||||
1. Investigate dockur/windows-arm recent commits for the rebuild trigger:
|
||||
https://github.com/dockur/windows-arm/commits/master
|
||||
2. If the rebuild is desirable, update \`config/oem/VERSIONS.txt\`:
|
||||
- \`dockur-arm-digest=$DIGEST_ARM_LATEST\`
|
||||
3. Refresh \`DOCKUR_IMAGE_ARM_PIN\` in \`src/winpodx/core/config.py\` if rolling forward.
|
||||
4. If the rebuild looks unintentional or breaks something, leave
|
||||
the pin alone and update only the baseline once stable.
|
||||
EOF
|
||||
)"
|
||||
@@ -0,0 +1,99 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
- run: ruff check src/ tests/
|
||||
- run: ruff format --check src/ tests/
|
||||
- name: Verify version stamps agree across files
|
||||
# Catches the v0.5.2 release-prep mistake (debian/changelog stayed at
|
||||
# 0.5.1 while pyproject.toml / __init__.py moved to 0.5.2) before the
|
||||
# tag push. See scripts/ci/verify_versions.py for details.
|
||||
run: python scripts/ci/verify_versions.py
|
||||
|
||||
test:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
runner: ["ubuntu-latest"]
|
||||
include:
|
||||
# aarch64 regression catch — winpodx ships arch-aware code paths
|
||||
# (image picker in core/config.py, compose ARGUMENTS in
|
||||
# core/pod/compose.py, install.sh arch detection) for ARM64
|
||||
# Linux hosts per #141. The x86_64 matrix covers all 5 Python
|
||||
# versions; we keep aarch64 minimal at the latest Python only
|
||||
# so the regression check is fast + cheap. GitHub Actions
|
||||
# provides free aarch64 runners for public repos since 2024.
|
||||
- python-version: "3.14"
|
||||
runner: "ubuntu-24.04-arm"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: pytest tests/ -v
|
||||
|
||||
discover-apps-ps:
|
||||
# Smoke test for scripts/windows/discover_apps.ps1 — runs the script
|
||||
# under -DryRun (no Registry/AppX access) and asserts stdout parses as
|
||||
# a non-empty JSON array with the shape core.discovery expects. Catches
|
||||
# PowerShell syntax regressions before they hit the Windows guest.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# Defense in depth: the DryRun path is read-only, but we still
|
||||
# do not want the job's GITHUB_TOKEN left persisted in
|
||||
# .git/config where any subsequent step (including the pwsh
|
||||
# smoke test) could observe it.
|
||||
persist-credentials: false
|
||||
- name: Ensure PowerShell Core is available
|
||||
run: |
|
||||
if command -v pwsh >/dev/null 2>&1; then
|
||||
echo "pwsh already installed: $(pwsh --version)"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends wget apt-transport-https ca-certificates
|
||||
# Official Microsoft repository for PowerShell on Ubuntu.
|
||||
source /etc/os-release
|
||||
wget -q "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/packages-microsoft-prod.deb
|
||||
sudo dpkg -i /tmp/packages-microsoft-prod.deb
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y powershell
|
||||
pwsh --version
|
||||
fi
|
||||
- name: Run discover_apps.ps1 -DryRun and validate JSON shape
|
||||
run: |
|
||||
pwsh -NoProfile -File scripts/windows/discover_apps.ps1 -DryRun > /tmp/discover.json
|
||||
python3 scripts/ci/validate_discover_dryrun.py /tmp/discover.json
|
||||
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install pip-audit
|
||||
# `[all]` is libvirt-free as of 0.6.0 (the libvirt backend + its
|
||||
# libvirt-python extra were dropped — #286), so it no longer needs the
|
||||
# libvirt-dev native headers and is safe to install on ubuntu-latest.
|
||||
- run: pip install -e ".[all]" || pip install -e ".[dev]"
|
||||
- run: pip-audit --desc --skip-editable
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,154 @@
|
||||
name: Debian/Ubuntu Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (e.g. v0.1.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: debian:12
|
||||
suffix: debian12
|
||||
backports: true
|
||||
- image: debian:13
|
||||
suffix: debian13
|
||||
- image: ubuntu:24.04
|
||||
suffix: ubuntu2404
|
||||
- image: ubuntu:25.04
|
||||
suffix: ubuntu2504
|
||||
- image: ubuntu:25.10
|
||||
suffix: ubuntu2510
|
||||
- image: ubuntu:26.04
|
||||
suffix: ubuntu2604
|
||||
container: ${{ matrix.image }}
|
||||
steps:
|
||||
- name: Bootstrap container (git + apt)
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates git
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Enable backports if needed
|
||||
if: matrix.backports
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||
echo "deb http://deb.debian.org/debian ${codename}-backports main" \
|
||||
> /etc/apt/sources.list.d/backports.list
|
||||
apt-get update
|
||||
|
||||
- name: Install build deps
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
pkgs="build-essential devscripts debhelper dh-python \
|
||||
python3-all python3-setuptools python3-wheel \
|
||||
python3-hatchling python3-pip python3-build"
|
||||
apt-get install -y --no-install-recommends $pkgs
|
||||
if [ "${{ matrix.backports }}" = "true" ]; then
|
||||
codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||
apt-get install -y -t ${codename}-backports pybuild-plugin-pyproject
|
||||
else
|
||||
apt-get install -y --no-install-recommends pybuild-plugin-pyproject
|
||||
fi
|
||||
|
||||
- name: Build .deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
dpkg-buildpackage -us -uc -b
|
||||
mkdir -p dist
|
||||
for f in ../winpodx_*.deb; do
|
||||
base=$(basename "$f" .deb)
|
||||
cp "$f" "dist/${base}_${{ matrix.suffix }}.deb"
|
||||
done
|
||||
ls -la dist/
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb-${{ matrix.suffix }}
|
||||
path: dist/*.deb
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-debs
|
||||
pattern: deb-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List collected debs
|
||||
run: ls -la all-debs/
|
||||
|
||||
# Single-writer rule: release.yml (REL- marker tag) is the SOLE creator
|
||||
# of the GitHub Release and the only writer of its title/notes. This
|
||||
# packaging workflow never creates or edits the release — it waits for
|
||||
# release.yml to create it, then attaches its assets. (The old per-
|
||||
# workflow `gh release create --notes "Release artifacts…"` stubs both
|
||||
# raced to produce duplicate releases — kernalix7 hit this on v0.4.1
|
||||
# push 2026-05-05 — and churned the notes with placeholder text that
|
||||
# release.yml then overwrote, so the release showed as "edited" many
|
||||
# times. Waiting instead of creating eliminates both.)
|
||||
#
|
||||
# release.yml runs the full test suite before creating the release, so
|
||||
# it typically appears ~5-6 min after the push; poll up to 12 min. If it
|
||||
# never appears (e.g. only the v-tag was pushed, no REL- marker), skip
|
||||
# the upload gracefully — the .debs remain available as build-job
|
||||
# artifacts — rather than failing the run.
|
||||
- name: Wait for release (release.yml owns creation)
|
||||
id: wait
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${{ steps.tag.outputs.tag }}"
|
||||
for _ in $(seq 1 48); do
|
||||
if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release $tag exists — attaching .debs."
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Release $tag not created yet (release.yml owns creation) — waiting…"
|
||||
sleep 15
|
||||
done
|
||||
echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping .deb upload; .debs remain as build-job artifacts."
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload .deb to release
|
||||
if: steps.wait.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ steps.tag.outputs.tag }}" all-debs/*.deb --clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
@@ -0,0 +1,264 @@
|
||||
name: OBS Publish
|
||||
|
||||
# OBS builds RPMs asynchronously on shared openSUSE infrastructure
|
||||
# (~30-60 min per rebuild). Trying to trigger the rebuild AND download the
|
||||
# freshly-built RPMs in the same tag-push run was structurally broken: the
|
||||
# download ran minutes after the trigger, long before OBS had published the
|
||||
# new version, so every release push failed with "No RPMs were downloaded".
|
||||
#
|
||||
# This workflow is split to match OBS's async reality:
|
||||
# * `trigger` (tag push / dispatch) — fire the OBS rebuild, then exit
|
||||
# green. It never tries to download, so a release push can't go red here.
|
||||
# * `attach` (hourly schedule / dispatch) — once OBS has published the
|
||||
# RPMs for the latest release's version, download them and attach them to
|
||||
# the existing GitHub Release. It is a no-op (green) when the RPMs aren't
|
||||
# built yet (retries next hour) or are already attached (no churn).
|
||||
#
|
||||
# Single-writer rule: this workflow NEVER creates the GitHub Release or edits
|
||||
# its title/notes — release.yml (REL- marker tag) is the sole creator and the
|
||||
# only writer of the release body. `attach` only uploads assets to a release
|
||||
# that already exists, so it can't cause the "edited N times" / placeholder-
|
||||
# notes churn the old per-workflow `gh release create --notes …` stubs did.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
schedule:
|
||||
# Hourly sweep: attach OBS RPMs to the latest release once OBS finishes
|
||||
# building them. A no-op the rest of the time (already attached / not
|
||||
# built yet), so it never edits a release it has nothing new for.
|
||||
- cron: "17 * * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to trigger / attach RPMs to (e.g. v0.6.0)"
|
||||
required: true
|
||||
force:
|
||||
description: "Force OBS runservice even if Tumbleweed RPM exists (use when source bumped without version change)"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
OBS_PROJECT: home:Kernalix7
|
||||
OBS_PACKAGE: winpodx
|
||||
OBS_API: https://api.opensuse.org
|
||||
# /public/ is OBS's anonymous read-only API mirror — no auth needed for
|
||||
# public home: projects. The runservice token can't read /build/.
|
||||
OBS_PUBLIC_API: https://api.opensuse.org/public
|
||||
OBS_DOWNLOAD: https://download.opensuse.org/repositories/home:/Kernalix7
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Fire the OBS rebuild and exit. Runs on tag push and manual dispatch only.
|
||||
# Never downloads — so a release push is always green here; the RPMs land
|
||||
# asynchronously via the `attach` job once OBS finishes the server-side build.
|
||||
trigger:
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${{ github.ref_name }}"
|
||||
else
|
||||
tag="${{ inputs.tag }}"
|
||||
fi
|
||||
# OBS feeds the version into both deb and rpm specs; rpm forbids
|
||||
# '-' in Version: (Version/Release delimiter). Strip the RTM
|
||||
# gate suffix so `0.3.0-RTM1` lands as `0.3.0`.
|
||||
version="${tag#v}"
|
||||
version="${version%%-RTM*}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install curl + jq
|
||||
run: sudo apt-get update && sudo apt-get install -y curl jq
|
||||
|
||||
- name: Probe whether OBS already has target version built
|
||||
# Avoid triggering an unnecessary OBS rebuild when the target version
|
||||
# is already published. A single rebuild costs ~30-60min on OBS shared
|
||||
# infrastructure and is pure waste when the RPMs we'd download are
|
||||
# byte-identical to what's already on the server. Probe a representative
|
||||
# shipping repo (Tumbleweed) for a winpodx-<version>-*.rpm filename in
|
||||
# the public download index; if found, OBS has the build cached.
|
||||
id: probe
|
||||
run: |
|
||||
version="${{ steps.tag.outputs.version }}"
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "force=true requested — bypassing Tumbleweed RPM probe and triggering runservice."
|
||||
echo "skip_runservice=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
listing_url="${OBS_DOWNLOAD}/openSUSE_Tumbleweed/noarch/"
|
||||
html=$(curl -sS -f -L "$listing_url" 2>/dev/null || echo "")
|
||||
if echo "$html" | grep -qE "winpodx-${version}-[0-9]+\.[0-9]+\.noarch\.rpm"; then
|
||||
echo "OBS already has openSUSE_Tumbleweed RPM for v${version} — skipping runservice."
|
||||
echo "skip_runservice=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "OBS doesn't yet have openSUSE_Tumbleweed RPM for v${version} — will trigger runservice."
|
||||
echo "skip_runservice=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Trigger OBS service run (token)
|
||||
if: steps.probe.outputs.skip_runservice == 'false'
|
||||
env:
|
||||
OBS_TOKEN: ${{ secrets.OBS_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$OBS_TOKEN" ]; then
|
||||
echo "OBS_TOKEN secret is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
curl -sS -f -X POST \
|
||||
-H "Authorization: Token $OBS_TOKEN" \
|
||||
"$OBS_API/trigger/runservice?project=$OBS_PROJECT&package=$OBS_PACKAGE"
|
||||
echo "OBS rebuild triggered for v${{ steps.tag.outputs.version }}. RPMs will be attached by the hourly 'attach' job once OBS finishes (~30-60 min)."
|
||||
|
||||
# Download OBS-built RPMs once they're published and attach them to the
|
||||
# existing GitHub Release. Runs hourly (sweeps the latest release) and on
|
||||
# manual dispatch (targets inputs.tag). Never creates or edits the release.
|
||||
attach:
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install curl + jq
|
||||
run: sudo apt-get update && sudo apt-get install -y curl jq
|
||||
|
||||
- name: Resolve target tag
|
||||
id: tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
tag="${{ inputs.tag }}"
|
||||
else
|
||||
# schedule: sweep the most recent published release.
|
||||
tag=$(gh release list --repo "${{ github.repository }}" --limit 1 \
|
||||
--json tagName --jq '.[0].tagName' 2>/dev/null || echo "")
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "No target tag (no releases yet?) — nothing to attach."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
version="${tag#v}"
|
||||
version="${version%%-RTM*}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Skip if release already has OBS RPMs attached
|
||||
id: gate
|
||||
if: steps.tag.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${{ steps.tag.outputs.tag }}"
|
||||
# release.yml owns creation — only attach to a release that exists.
|
||||
if ! gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release $tag does not exist yet (release.yml owns creation) — nothing to attach."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Tumbleweed is always built and is the canary for "OBS RPMs already
|
||||
# attached". If its asset is present, the sweep already ran for this
|
||||
# release — exit green without re-uploading (no edit churn).
|
||||
assets=$(gh release view "$tag" --repo "${{ github.repository }}" \
|
||||
--json assets --jq '.assets[].name' 2>/dev/null || echo "")
|
||||
if echo "$assets" | grep -q "openSUSE_Tumbleweed\.rpm$"; then
|
||||
echo "OBS RPMs already attached to $tag — no-op."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Discover repos and download RPMs
|
||||
id: download
|
||||
if: steps.gate.outputs.skip == 'false'
|
||||
run: |
|
||||
mkdir -p dist-rpms
|
||||
version="${{ steps.tag.outputs.version }}"
|
||||
|
||||
# Parse enabled repos from project _meta (public, no auth)
|
||||
meta=$(curl -sS -f "$OBS_PUBLIC_API/source/$OBS_PROJECT/_meta")
|
||||
repos=$(echo "$meta" | grep -oP '(?<=<repository name=")[^"]+')
|
||||
if [ -z "$repos" ]; then
|
||||
echo "No repositories configured on $OBS_PROJECT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for repo in $repos; do
|
||||
# openSUSE_Factory_{ARM,PowerPC,RISCV,zSystems} builds the same
|
||||
# noarch content as openSUSE_Tumbleweed — publishing all five would
|
||||
# just list four identical RPMs with misleading architecture-looking
|
||||
# names. Skip the non-x86 Factory rebuilds; Tumbleweed users on
|
||||
# aarch64/ppc64/riscv64/s390x install the Tumbleweed noarch RPM.
|
||||
case "$repo" in
|
||||
openSUSE_Factory_*) echo "Skipping $repo (noarch duplicate of Tumbleweed)"; continue ;;
|
||||
esac
|
||||
# Tumbleweed / Fedora / Slowroll all produce the same filename
|
||||
# (winpodx-<ver>-<rel>.noarch.rpm) because their release tags lack
|
||||
# a distro suffix. Without renaming, only the last-iterated repo's
|
||||
# file survives in dist-rpms/. Append the repo name to every RPM so
|
||||
# each distro's build gets a stable, unique asset name on the
|
||||
# Release — matches the RHEL convention (.el9.rpm / .el10.rpm).
|
||||
# Normalize short Leap repo names ("15.6", "16.0") to the familiar
|
||||
# openSUSE_Leap_* form so the final filename is self-describing
|
||||
# instead of ending in a bare version number like ".15.6.rpm".
|
||||
case "$repo" in
|
||||
1[0-9].[0-9]|1[0-9].[0-9][0-9]) repo_slug="openSUSE_Leap_$repo" ;;
|
||||
*) repo_slug=$(echo "$repo" | tr '/:' '__') ;;
|
||||
esac
|
||||
for arch in x86_64 noarch; do
|
||||
base="$OBS_DOWNLOAD/$repo/$arch"
|
||||
# MirrorCache listing hrefs have a "./" prefix
|
||||
# (href="./winpodx-<ver>-<rel>.noarch.rpm"); match the filename
|
||||
# directly instead of anchoring on `href="winpodx-`.
|
||||
listing=$(curl -sSL "$base/" || true)
|
||||
[ -z "$listing" ] && continue
|
||||
# Anchor the filename match to the current tag's version so a
|
||||
# stale older RPM still in the OBS publish index (Tumbleweed /
|
||||
# Slowroll rolling-release repos are slow to garbage-collect)
|
||||
# doesn't get downloaded and re-uploaded as a v${version}
|
||||
# release asset.
|
||||
rpms=$(echo "$listing" | grep -oE "winpodx-${version}-[^\"]+\.rpm" | sort -u)
|
||||
for f in $rpms; do
|
||||
case "$f" in
|
||||
*debuginfo*|*debugsource*|*.src.rpm) continue ;;
|
||||
esac
|
||||
out="dist-rpms/${f%.rpm}.${repo_slug}.rpm"
|
||||
echo "Fetching $repo/$arch/$f -> $(basename "$out")"
|
||||
curl -sS -fL -o "$out" "$base/$f" || true
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
ls -la dist-rpms/
|
||||
if ! ls dist-rpms/*.rpm >/dev/null 2>&1; then
|
||||
echo "::notice::OBS hasn't published RPMs for v${version} yet — they build asynchronously (~30-60 min). The hourly sweep will retry; or re-run this workflow once the build finishes."
|
||||
echo "have_rpms=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "have_rpms=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Attach RPMs to GitHub release
|
||||
if: steps.download.outputs.have_rpms == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ steps.tag.outputs.tag }}" dist-rpms/*.rpm --clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
echo "Attached OBS RPMs to ${{ steps.tag.outputs.tag }}."
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.download.outputs.have_rpms == 'true'
|
||||
with:
|
||||
name: rpms-${{ steps.tag.outputs.version }}
|
||||
path: dist-rpms/
|
||||
@@ -0,0 +1,40 @@
|
||||
# Deploy the winpodx.org landing page (web/) to GitHub Pages.
|
||||
#
|
||||
# Requires repo Settings -> Pages -> Source = "GitHub Actions" (one-time, done
|
||||
# in the UI) and the custom domain winpodx.org configured there. The CNAME
|
||||
# file in web/ is published with the site so the custom domain sticks.
|
||||
name: Deploy site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "web/**"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Never run two deploys at once; let an in-flight one finish (don't cancel,
|
||||
# so a partial publish can't clobber a good one).
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: web
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,195 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
# Only REL-prefixed marker tags create a public release. Version
|
||||
# tags themselves (v0.4.0, v0.4.0rc1) trigger the four packaging
|
||||
# workflows (.deb / RPM / OBS / AUR) via their `v*.*.*` globs but
|
||||
# NOT this Release workflow — that's deliberate. kernalix7 found
|
||||
# that rapid-iteration tags between v0.1.9 and v0.2.2.1 shipped
|
||||
# unverified releases that broke users' working installs
|
||||
# (2026-04-29). Releasing now requires pushing a SECOND tag pointed
|
||||
# at the same commit as the version tag, namespaced with REL- so
|
||||
# the version scheme itself stays clean (PEP 440 / SemVer):
|
||||
#
|
||||
# git tag v0.4.0 # version, triggers packaging
|
||||
# git tag REL-v0.4.0 # marker, triggers this workflow
|
||||
# git push origin v0.4.0 REL-v0.4.0
|
||||
#
|
||||
# The previous `*RTM*` glob was retired 2026-05-05 alongside the
|
||||
# PEP 440 versioning move (current versions are `0.4.0` /
|
||||
# `0.4.0rc1`, no RTM suffix anywhere). Manual workflow_dispatch
|
||||
# still works for emergency one-offs.
|
||||
tags:
|
||||
- "REL-*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# id-token: write # reserved for future PyPI trusted publisher
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
- name: Run ruff check
|
||||
run: ruff check src/ tests/
|
||||
- name: Run ruff format check
|
||||
run: ruff format --check src/ tests/
|
||||
- name: Install project
|
||||
run: pip install -e ".[dev]"
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v
|
||||
# Skip tag/pyproject version parity check on manual dispatch
|
||||
- name: Verify tag matches pyproject version
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
# Tag layout: REL-v<pep440-version>. Strip the REL- marker
|
||||
# prefix and the leading 'v' to get pyproject.toml's version
|
||||
# field (e.g., REL-v0.4.0rc1 -> 0.4.0rc1, REL-v0.4.0 -> 0.4.0).
|
||||
tag="${GITHUB_REF_NAME#REL-}"
|
||||
tag="${tag#v}"
|
||||
pyver=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
|
||||
if [ "$tag" != "$pyver" ]; then
|
||||
echo "Tag version ($tag) does not match pyproject.toml version ($pyver)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: python -m pip install build twine
|
||||
- name: Build distribution
|
||||
run: python -m build
|
||||
- name: Check distribution metadata
|
||||
run: python -m twine check dist/*
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Extract version from REL tag
|
||||
id: version
|
||||
run: |
|
||||
# REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1. Used by all
|
||||
# subsequent steps so the public Release page reads
|
||||
# "v0.4.0" / "0.4.0" instead of the internal "REL-v0.4.0"
|
||||
# marker tag name.
|
||||
tag="${GITHUB_REF_NAME#REL-}"
|
||||
tag="${tag#v}"
|
||||
echo "value=$tag" >> "$GITHUB_OUTPUT"
|
||||
- name: Extract changelog section
|
||||
id: changelog
|
||||
run: |
|
||||
# REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1.
|
||||
tag="${GITHUB_REF_NAME#REL-}"
|
||||
tag="${tag#v}"
|
||||
# Determine the previous published version so we can include
|
||||
# any intermediate unreleased sections in the notes (sections
|
||||
# the user wrote in CHANGELOG but never tagged & released).
|
||||
# On 2026-05-05, v0.3.1 was such an unreleased section: it
|
||||
# collected Mic92's PRs #62-#65 but no v0.3.1 tag was ever
|
||||
# pushed; v0.4.0rc1's release page would have lost those
|
||||
# contributor credits if it only matched ## [0.4.0].
|
||||
# Strategy: find the most recent CHANGELOG section that
|
||||
# corresponds to a published GitHub Release (i.e., has its own
|
||||
# tag). Capture from the current section back to (but not
|
||||
# including) that already-published one.
|
||||
prev=""
|
||||
while IFS= read -r section; do
|
||||
sver="${section#\#\# \[}"
|
||||
sver="${sver%%\] *}"
|
||||
# Skip the in-flight version itself and the unreleased header.
|
||||
[ "$sver" = "$tag" ] && continue
|
||||
[ "$sver" = "Unreleased" ] && continue
|
||||
# First section we hit that has a corresponding tag is the
|
||||
# previous published release. CHANGELOG is in newest-first
|
||||
# order so this is correct on the first match.
|
||||
if git rev-parse --verify "v${sver}^{commit}" >/dev/null 2>&1; then
|
||||
prev="$sver"
|
||||
break
|
||||
fi
|
||||
done < <(grep -E '^## \[' CHANGELOG.md)
|
||||
notes=$(awk -v ver="$tag" -v prev="$prev" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag=1 }
|
||||
prev != "" && $0 ~ "^## \\[" prev "\\]" { exit }
|
||||
prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit }
|
||||
flag { print }
|
||||
' CHANGELOG.md)
|
||||
# PEP 440 prereleases (0.4.0rc1) usually share notes with the
|
||||
# final (0.4.0) section in CHANGELOG. Fall back to the base
|
||||
# version section if the prerelease-specific one is missing.
|
||||
if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then
|
||||
base=$(echo "$tag" | sed -E 's/(rc|alpha|beta)[0-9]*$//')
|
||||
if [ "$base" != "$tag" ]; then
|
||||
notes=$(awk -v ver="$base" -v prev="$prev" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag=1 }
|
||||
prev != "" && $0 ~ "^## \\[" prev "\\]" { exit }
|
||||
prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit }
|
||||
flag { print }
|
||||
' CHANGELOG.md)
|
||||
fi
|
||||
fi
|
||||
if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then
|
||||
notes="See CHANGELOG.md for details."
|
||||
fi
|
||||
{
|
||||
echo "notes<<CHANGELOG_EOF"
|
||||
echo "$notes"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Detect prerelease
|
||||
id: prerelease
|
||||
run: |
|
||||
# Match both PEP 440 ("0.4.0rc1", no separator) and SemVer-
|
||||
# hyphen ("0.4.0-rc1") prerelease forms after the REL- prefix
|
||||
# is stripped.
|
||||
tag="${GITHUB_REF_NAME#REL-}"
|
||||
tag="${tag#v}"
|
||||
if echo "$tag" | grep -qE -- '(rc|beta|alpha)[0-9]*'; then
|
||||
echo "value=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# tag_name + name override the default github.ref_name
|
||||
# (REL-v0.4.0 marker) with the clean version tag (v0.4.0) so
|
||||
# the public release surface is namespace-free. The marker
|
||||
# tag still exists in git as the trigger record.
|
||||
tag_name: v${{ steps.version.outputs.value }}
|
||||
name: v${{ steps.version.outputs.value }}
|
||||
prerelease: ${{ steps.prerelease.outputs.value }}
|
||||
# The hand-written CHANGELOG section is the entire release body —
|
||||
# including its "### Contributors" subsection, which credits the
|
||||
# issue reporters and external PR authors for the release (the repo's
|
||||
# house convention; see CONTRIBUTING.md). generate_release_notes is
|
||||
# deliberately NOT enabled: on this maintainer-authored repo it dumps
|
||||
# a ~120-line "What's Changed" list that credits the owner on nearly
|
||||
# every PR and buries the real contributor credits behind it. This
|
||||
# step is the SOLE writer of the release body: the packaging
|
||||
# workflows (.deb / RPM / OBS / AppImage) only attach assets, never
|
||||
# touch the notes, so the release isn't edited/rewritten repeatedly.
|
||||
body: ${{ steps.changelog.outputs.notes }}
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.whl
|
||||
@@ -0,0 +1,206 @@
|
||||
name: RHEL/AlmaLinux Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (e.g. v0.1.0)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# AlmaLinux 9 ships python3 == 3.9. The spec pulls in python3-tomli
|
||||
# on rhel<=9 so the stdlib-tomllib-absent case is covered. EPEL
|
||||
# provides pyproject-rpm-macros + python3-hatchling + python3-tomli.
|
||||
- image: almalinux:9
|
||||
suffix: el9
|
||||
epel: true
|
||||
# AlmaLinux 10 ships python3 >= 3.12 so tomllib is stdlib; the spec
|
||||
# conditional skips the tomli Requires on rhel >= 10. EPEL 10 is
|
||||
# still enabled because python3-hatchling lives there on el10.
|
||||
- image: almalinux:10
|
||||
suffix: el10
|
||||
epel: true
|
||||
container: ${{ matrix.image }}
|
||||
steps:
|
||||
- name: Bootstrap container (git + rpm tools)
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
dnf -y install dnf-plugins-core git rpm-build rpmdevtools tar
|
||||
|
||||
- name: Enable EPEL + CRB
|
||||
if: matrix.epel
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
# CRB (CodeReady Builder) holds the -devel and python- build deps
|
||||
# that are not in BaseOS/AppStream (needed for python3-pluggy
|
||||
# providers on el9 and some python3-* build deps on el10).
|
||||
dnf -y install epel-release
|
||||
dnf config-manager --set-enabled crb || \
|
||||
dnf config-manager --set-enabled powertools || true
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${{ github.ref_name }}"
|
||||
else
|
||||
tag="${{ inputs.tag }}"
|
||||
fi
|
||||
# Strip the RTM gate suffix when computing the package version —
|
||||
# RPM's Version: field forbids '-' (it's the Version/Release
|
||||
# delimiter), so 'v0.3.0-RTM1' would land as the illegal
|
||||
# 'Version: 0.3.0-RTM1'. The tag itself is preserved for the
|
||||
# release upload + asset naming; only the on-disk Version is
|
||||
# stripped to a plain '0.3.0'.
|
||||
version="${tag#v}"
|
||||
version="${version%%-RTM*}"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install RPM build deps
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
dnf -y install \
|
||||
python3 python3-pip python3-wheel python3-setuptools \
|
||||
python3-hatchling python3-installer \
|
||||
pyproject-rpm-macros python-rpm-macros \
|
||||
python3-pluggy
|
||||
# el9 default python3 is 3.9 → tomllib is NOT stdlib, so the runtime
|
||||
# Requires: python3-tomli must be resolvable at install time too.
|
||||
# Install here just to keep the build environment symmetric.
|
||||
if [ "${{ matrix.suffix }}" = "el9" ]; then
|
||||
dnf -y install python3-tomli || true
|
||||
fi
|
||||
|
||||
- name: Prepare rpmbuild tree
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
version="${{ steps.tag.outputs.version }}"
|
||||
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
|
||||
# git archive produces a clean tarball matching what OBS's tar_scm
|
||||
# builds from, including the @PARENT_TAG@-shaped prefix the spec's
|
||||
# %autosetup expects.
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git archive --format=tar.gz \
|
||||
--prefix="winpodx-${version}/" \
|
||||
-o "$HOME/rpmbuild/SOURCES/winpodx-${version}.tar.gz" \
|
||||
HEAD
|
||||
# Stamp the real version into the spec (OBS does this via its
|
||||
# set_version service; here we do it locally before rpmbuild).
|
||||
sed "s|^Version:.*|Version: ${version}|" \
|
||||
packaging/rpm/winpodx.spec > "$HOME/rpmbuild/SPECS/winpodx.spec"
|
||||
|
||||
- name: Build RPM
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
rpmbuild -bb "$HOME/rpmbuild/SPECS/winpodx.spec"
|
||||
|
||||
- name: Collect RPMs
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p dist
|
||||
version="${{ steps.tag.outputs.version }}"
|
||||
# BuildArch is noarch in the spec, so everything lands in noarch/.
|
||||
for f in ~/rpmbuild/RPMS/noarch/winpodx-*.rpm; do
|
||||
[ -e "$f" ] || continue
|
||||
base=$(basename "$f" .rpm)
|
||||
# Drop any existing distro tag (rpmbuild puts .el9 / .el10 here
|
||||
# via %{dist}) and replace with our matrix suffix for clarity.
|
||||
# The rpm itself is noarch so it would install on either distro;
|
||||
# the suffix is just a human hint on the release asset list.
|
||||
case "$base" in
|
||||
*debuginfo*|*debugsource*) continue ;;
|
||||
esac
|
||||
cp "$f" "dist/${base}.${{ matrix.suffix }}.rpm"
|
||||
done
|
||||
ls -la dist/
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rpm-${{ matrix.suffix }}
|
||||
path: dist/*.rpm
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-rpms
|
||||
pattern: rpm-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List collected RPMs
|
||||
run: ls -la all-rpms/
|
||||
|
||||
# Single-writer rule: release.yml (REL- marker tag) is the SOLE creator
|
||||
# of the GitHub Release and the only writer of its title/notes. This
|
||||
# packaging workflow never creates or edits the release — it waits for
|
||||
# release.yml to create it, then attaches its assets. (The old per-
|
||||
# workflow `gh release create --notes "Release artifacts…"` stubs both
|
||||
# raced to produce duplicate releases — kernalix7 hit this on v0.4.1
|
||||
# push 2026-05-05 — and churned the notes with placeholder text that
|
||||
# release.yml then overwrote, so the release showed as "edited" many
|
||||
# times. Waiting instead of creating eliminates both.)
|
||||
#
|
||||
# release.yml runs the full test suite before creating the release, so
|
||||
# it typically appears ~5-6 min after the push; poll up to 12 min. If it
|
||||
# never appears (e.g. only the v-tag was pushed, no REL- marker), skip
|
||||
# the upload gracefully — the RPMs remain available as build-job
|
||||
# artifacts — rather than failing the run.
|
||||
- name: Wait for release (release.yml owns creation)
|
||||
id: wait
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${{ steps.tag.outputs.tag }}"
|
||||
for _ in $(seq 1 48); do
|
||||
if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release $tag exists — attaching RPMs."
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Release $tag not created yet (release.yml owns creation) — waiting…"
|
||||
sleep 15
|
||||
done
|
||||
echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping RPM upload; RPMs remain as build-job artifacts."
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload RPMs to release
|
||||
if: steps.wait.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release upload "${{ steps.tag.outputs.tag }}" all-rpms/*.rpm --clobber \
|
||||
--repo "${{ github.repository }}"
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
# Stray PEP 517 build-backend wheels that a local `python -m build` / pip can
|
||||
# drop in the repo root (hatchling, build, pluggy, …). Never part of winpodx.
|
||||
/*.whl
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# AppImage build artifacts (packaging/appimage/ local builds)
|
||||
*.AppImage
|
||||
squashfs-root/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.so
|
||||
.mypy_cache/
|
||||
.hypothesis/
|
||||
env/
|
||||
|
||||
# AI config (actual files in .priv-storage/, root has symlinks)
|
||||
.priv-storage/
|
||||
CLAUDE.md
|
||||
.cursorrules
|
||||
.claude
|
||||
.vscode
|
||||
WORK_STATUS.md
|
||||
# Additional per-tool files routed via .priv-storage chain or
|
||||
# explicitly local. CLAUDE.md Section 8 ("AI Tooling Footprint Ban")
|
||||
# requires these never reach git history.
|
||||
.mcp.json
|
||||
AGENTS.md
|
||||
CLAUDE.local.md
|
||||
demo.png
|
||||
|
||||
# Backup toolkit (generic, not project-specific)
|
||||
tmp-igbkp/
|
||||
|
||||
# Per-install agent bearer token staged into the OEM bind mount.
|
||||
# Never commit — carries a per-install secret.
|
||||
config/oem/agent_token.txt
|
||||
|
||||
# Synthetic SMBIOS disguise blob, generated into the OEM bind mount at
|
||||
# compose time (#246). Runtime artefact — never commit.
|
||||
config/oem/winpodx-smbios.bin
|
||||
|
||||
# Rust reverse-open shim build tree (~21 MB of regenerable Cargo artifacts).
|
||||
# Already ignored by the nested config/oem/reverse-open/shim/.gitignore, but
|
||||
# listing it at the ROOT too keeps it out of the sdist: hatchling's sdist VCS
|
||||
# walk honors the root .gitignore, so a maintainer-local build (where the shim
|
||||
# was compiled) produces a clean tarball. Only the committed shim/bin/ prebuilt
|
||||
# .exe should ship. (The CI release builds from a fresh checkout, so target/
|
||||
# never exists there — published artifacts are clean regardless.)
|
||||
config/oem/reverse-open/shim/target/
|
||||
|
||||
# Coverage / test artefacts.
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Editor / OS scratch files.
|
||||
*.tmp
|
||||
*.bak
|
||||
*.old
|
||||
*.orig
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Secret-shaped files — defensive denylist so a stray .env / key never
|
||||
# slips into a commit. Currently no such files in tree.
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
|
||||
# Editor / assistant scratch & backup paths
|
||||
CLAUDE.md.bak
|
||||
AGENTS.md.bak
|
||||
.cursorrules.bak
|
||||
WORK_STATUS.md.bak
|
||||
.gitignore.bak
|
||||
.codex/
|
||||
.aider*
|
||||
.continue/
|
||||
.cline/
|
||||
.roo/
|
||||
uninstall-backup-*/
|
||||
.priv-storage/.allow-setup-reread
|
||||
+1123
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
**English** | [한국어](docs/CODE_OF_CONDUCT.ko.md)
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
- Demonstrating empathy and kindness
|
||||
- Respecting differing opinions and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Taking responsibility and learning from mistakes
|
||||
- Focusing on what is best for the overall community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
- Sexualized language, imagery, or attention
|
||||
- Trolling, insulting, or derogatory comments
|
||||
- Public or private harassment
|
||||
- Publishing others' private information without permission
|
||||
- Other conduct inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing standards of
|
||||
acceptable behavior and will take fair corrective action when needed.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces and also when an
|
||||
individual is officially representing the project in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Report unacceptable behavior to the maintainers through repository Discussions
|
||||
or Issues (for non-sensitive cases). For sensitive reports, contact maintainers
|
||||
privately through the channels listed in the repository profile.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community Impact Guidelines are adapted from the Contributor Covenant and may
|
||||
include:
|
||||
1. Correction
|
||||
2. Warning
|
||||
3. Temporary ban
|
||||
4. Permanent ban
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.1:
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
# Contributing to WinPodX
|
||||
|
||||
**English** | [한국어](docs/CONTRIBUTING.ko.md)
|
||||
|
||||
Thank you for your interest in contributing to WinPodX! This guide will help you get started.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+ (developed on 3.13; CI covers 3.9 / 3.10 / 3.11 / 3.12 / 3.13)
|
||||
- FreeRDP 3+
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kernalix7/winpodx.git
|
||||
cd winpodx
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
pytest tests/ -v
|
||||
|
||||
# Lint
|
||||
ruff check src/ tests/
|
||||
|
||||
# Format check
|
||||
ruff format --check src/ tests/
|
||||
```
|
||||
|
||||
`pytest` runs on a Linux CI runner and cannot exercise the Windows guest. Any
|
||||
change that touches the guest (`config/oem/`, `scripts/windows/`, the
|
||||
reverse-open shim, `compose` ports/QEMU args, the agent, install flow, RAIL
|
||||
launch) must also be smoke-tested against a real Windows guest before merge —
|
||||
see [docs/RELEASE_TESTING.md](docs/RELEASE_TESTING.md) for the guest-side smoke
|
||||
+ per-release checklist.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Fork** the repository
|
||||
2. Create a **feature branch** (`git checkout -b feat/my-feature`)
|
||||
3. Write your changes following **conventional commits**
|
||||
4. Submit a **Pull Request**
|
||||
|
||||
## PR Checklist
|
||||
|
||||
Before submitting a PR, ensure the following:
|
||||
|
||||
- [ ] `pytest tests/ -v` passes
|
||||
- [ ] `ruff check src/ tests/` reports zero errors
|
||||
- [ ] `ruff format --check src/ tests/` passes
|
||||
- [ ] Documentation is updated (if applicable)
|
||||
- [ ] No hardcoded credentials or secrets
|
||||
|
||||
## Commit Convention
|
||||
|
||||
This project follows [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
| Prefix | Purpose |
|
||||
|--------|---------|
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `docs` | Documentation changes |
|
||||
| `refactor` | Code refactoring (no feature change) |
|
||||
| `test` | Adding or updating tests |
|
||||
| `chore` | Maintenance tasks (CI, deps, etc.) |
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat: add Wayland display detection
|
||||
fix: resolve DPI scaling on multi-monitor setups
|
||||
docs: update installation instructions
|
||||
refactor: simplify backend abstraction layer
|
||||
test: add unit tests for UNC path conversion
|
||||
chore: update ruff to 0.8.x
|
||||
```
|
||||
|
||||
### No AI tool co-author trailers
|
||||
|
||||
Do **not** add `Co-authored-by:` trailers that name AI tools / coding agents. This applies to all of:
|
||||
|
||||
- `Co-authored-by: Cursor <cursoragent@cursor.com>`
|
||||
- `Co-authored-by: Claude <noreply@anthropic.com>` (and any other Anthropic email)
|
||||
- `Co-authored-by: Copilot <...>` (any GitHub Copilot variant)
|
||||
- `Co-authored-by: <any other AI tool / agent identity>`
|
||||
|
||||
You wrote the patch — the human author of record is you. AI tooling doesn't get co-authorship credit in this repo regardless of how much it contributed. If you forgot and a trailer slipped in, we'll ask you to amend (or, for already-merged PRs, propose a coordinated history-rewrite via a follow-up PR).
|
||||
|
||||
Human co-authors (e.g., a colleague who pair-programmed with you on the change) are fine and welcome — those should use real human identities + emails.
|
||||
|
||||
## Writing release notes
|
||||
|
||||
Each version section in `CHANGELOG.md` (and `docs/CHANGELOG.ko.md`) starts with `### Highlights` — a one-sentence headline followed by 3–6 scannable bullets. This is what users see at the top of the GitHub release page: `release.yml` extracts the version's section verbatim, so the first thing in the section is the first thing in the release body.
|
||||
|
||||
The detailed `### Added` / `### Changed` / `### Fixed` bullets follow underneath. They're for archeology and exhaustive tracking, not first-read.
|
||||
|
||||
Skeleton:
|
||||
|
||||
```markdown
|
||||
## [X.Y.Z] - YYYY-MM-DD
|
||||
|
||||
### Highlights
|
||||
|
||||
**One-sentence headline.** Optional 1-2 sentence elaboration if needed.
|
||||
|
||||
- Most important user-visible change (one line, scannable)
|
||||
- Second most important change
|
||||
- (3-6 bullets max; no prose blocks)
|
||||
|
||||
### Added
|
||||
- (detailed bullets)
|
||||
|
||||
### Changed
|
||||
- (detailed bullets)
|
||||
|
||||
### Fixed
|
||||
- (detailed bullets)
|
||||
```
|
||||
|
||||
When cutting a release, also push the `REL-vX.Y.Z` marker tag — this is what fires `release.yml` (which builds the `wheel` + `sdist`, extracts the CHANGELOG section, and updates the GitHub release body). Without the REL- marker, the version tag (`vX.Y.Z`) triggers only the four packaging workflows (`obs-publish.yml`, `rhel-publish.yml`, `debs-publish.yml`, `aur-publish.yml`) but no `wheel` / `sdist` and no auto-extracted release body.
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z <commit>
|
||||
git tag REL-vX.Y.Z vX.Y.Z^{} # dereference to commit to avoid a nested-tag warning
|
||||
git push origin vX.Y.Z REL-vX.Y.Z
|
||||
```
|
||||
|
||||
### Crediting contributors in Highlights
|
||||
|
||||
When a Highlights bullet covers work that came from outside the
|
||||
maintainer (external PR or external bug report / feature request),
|
||||
credit the contributor inline. The convention:
|
||||
|
||||
| Source | Suffix |
|
||||
|---|---|
|
||||
| External PR (someone else's commits) | `(by @username, #PR)` |
|
||||
| External issue / feature request (maintainer wrote the code) | `(reported by @username, #issue)` |
|
||||
| Both — external report **and** external PR by the same person | `(by @username, #PR / #issue)` |
|
||||
|
||||
GitHub auto-renders both forms as the user's avatar + handle on the
|
||||
release page, so the recognition surfaces without extra work.
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
### Highlights
|
||||
|
||||
- Atomic Fedora flavours (Silverblue / Kinoite / Bazzite) now ship via the
|
||||
OBS repo with `rpm-ostree install --apply-live`. (by @Zeik0s, #163)
|
||||
- LTSC IoT and Win10 LTSC pickable from Settings or `--win-version`.
|
||||
(reported by @gabe39, #178)
|
||||
- Dynamic Desktop Window Resolution — Full Desktop sessions now resize
|
||||
the FreeRDP client window automatically. (by @Zeik0s, #202)
|
||||
- Ubuntu 26.04 build target + Wayland-friendly Recommends split.
|
||||
(by @juampe, #206)
|
||||
- `winpodx setup` rerun no longer overwrites the working Windows password
|
||||
and locks the user out of their guest. (reported by @tolistim, #216)
|
||||
- Host-adaptive Windows-on-KVM tuning — `cfg.pod.tuning_profile = "auto"`
|
||||
detects host capability and applies `+invtsc` on x86_64 invariant-TSC
|
||||
CPUs. (reported by @ismikes, #215)
|
||||
- Fedora 42 / 43 / 44 install snippet now uses dnf5 syntax (`dnf
|
||||
config-manager addrepo --from-repofile=<URL>`); the dnf4 form
|
||||
failed on Fedora 41 +. (reported by @payayas, #228)
|
||||
- `install.sh` re-verifies `/dev/kvm` after the package install loop
|
||||
and refuses to proceed with an actionable BIOS / kernel-module /
|
||||
kvm-group diagnostic when hardware virtualisation is off. README
|
||||
gains a "Minimum requirements" section so users see the gating
|
||||
checks before they curl-install. (reported by @pnogaret2019-code,
|
||||
#220)
|
||||
- `install.sh` picks `qemu-system-x86` (or `qemu-system-x86-hwe`)
|
||||
on Ubuntu 24.04+ / Debian 13 where `qemu-kvm` became a virtual
|
||||
package with no install candidate. Same probe pattern as the
|
||||
freerdp2 → freerdp3 selector. (reported by @n-osennij, #200)
|
||||
- Tray UX overhaul: auto-spawned from GUI / CLI, bundled SVG so the
|
||||
indicator actually renders, Open Dashboard menu, Quit confirms +
|
||||
stops pod, install.sh marker suppresses spurious recovery
|
||||
notifications during Sysprep.
|
||||
```
|
||||
|
||||
The "no AI tool co-author trailers" rule above is unrelated: it bans
|
||||
machine-generated attribution. Human contributors are credited
|
||||
liberally and explicitly.
|
||||
|
||||
## Security
|
||||
|
||||
If you discover a security vulnerability, please follow the process described in [SECURITY.md](SECURITY.md). **Do NOT open a public issue.**
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Kim DaeHyun (kernalix7@kodenet.io)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,304 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="docs/images/CI.svg" alt="WinPodX" width="320">
|
||||
|
||||
### Click an app. Word opens. That's it.
|
||||
|
||||
<p>Native Linux windows for every Windows app — real icons, real <code>WM_CLASS</code>,<br>
|
||||
pin-to-taskbar. FreeRDP RemoteApp + dockur/windows. Zero config.</p>
|
||||
|
||||
<pre><code># Latest stable release (default)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
|
||||
# Latest main HEAD (development; may be unstable)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --main
|
||||
|
||||
# Uninstall (keeps Windows VM data; pass --purge to wipe everything)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --confirm</code></pre>
|
||||
|
||||
<a href="docs/images/demo.png">
|
||||
<img src="docs/images/demo.png" alt="WinPodX in action — Windows apps as native Linux windows on KDE" width="720">
|
||||
</a>
|
||||
|
||||
<sub>Windows About / Task Manager / PowerShell each in their own Linux window, alongside the WinPodX Dashboard (live Pod / RAM / CPU gauges, workspace tiles).</sub>
|
||||
|
||||
[](#status-beta)
|
||||
[](https://github.com/kernalix7/winpodx/releases)
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[](#testing)
|
||||
[](https://github.com/kernalix7/winpodx/actions/workflows/ci.yml)
|
||||
[](https://github.com/kernalix7/winpodx/stargazers)
|
||||
[](https://github.com/kernalix7/winpodx/releases)
|
||||
|
||||
###### Works on
|
||||
|
||||
[](https://www.opensuse.org/)
|
||||
[](https://fedoraproject.org/)
|
||||
[](https://fedoraproject.org/atomic-desktops/)
|
||||
[](https://www.debian.org/)
|
||||
[](https://ubuntu.com/)
|
||||
[](https://www.redhat.com/)
|
||||
[](https://archlinux.org/)
|
||||
[](docs/INSTALL.md#nix)
|
||||
[](docs/INSTALL.md)
|
||||
|
||||
<sub>**English** · [한국어](docs/README.ko.md) · [Install](docs/INSTALL.md) · [Usage](docs/USAGE.md) · [Features](docs/FEATURES.md) · [Architecture](docs/ARCHITECTURE.md) · [Comparison](docs/COMPARISON.md)</sub>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> ### Status: Beta
|
||||
> WinPodX is in active development (**v0.9.0**). **v0.7.0** introduced the **bare-metal disguise** (#246, opt-in / off by default): with `pod.disguise_level balanced | max` the Windows guest reads like a physical machine to VM-detection software (Nvidia GPU-passthrough "code 43", launch-gate VM checks, VM-hostile installers) — verified against al-khaser 0.82 — and the default guest username became `WPX-User`. **v0.7.1** is a UX + integration release: discovered Windows apps now register **automatic file associations** so they appear in your file manager's "Open with" menu (#545, on by default, only *added* — never set as the default handler); the GUI gains **app management** — reset-to-detected, a custom-icon picker, multi-select bulk hide/remove, and a restore list for deleted apps (#530); a **quick app launcher** (`winpodx launch`, #561) gives a Start-menu-style picker bindable to a DE hotkey; **`winpodx gui` no longer blocks the terminal** (#549); `winpodx doctor` **warns on an old FreeRDP** with broken RemoteApp windows (#546); and `install.sh --main` is now honoured on **Atomic Fedora** (#548). **v0.7.2** is a bug-fix release: it fixes a GUI crash on *Refresh Apps* (#567) and the tray *Terminate Session* / *USB Devices* submenus (#573) on KDE/Plasma, discovers apps with Chinese/Japanese/Korean names (#553), keeps the container across `pod stop` (no more recreate-on-update), and gives a clear error when Windows credentials are missing (#569). **v0.7.3** adds **reverse-open of files on the Windows VM itself** — not just your shared Home, the guest `C:` is shared over SMB and mounted with kio-fuse so a host app opens the real guest file and edits save back (#616, KDE) — an opt-in **idle auto-stop** that frees the VM's RAM (#622), and a **`+multitouch`** flag for touchscreen / stylus / pen passthrough (#623); it also fixes `winpodx app refresh` timing out on a slow guest (#619), the Dashboard RAM / Disk gauges sticking on "n/a" (#634), and removes the USB drive-letter auto-mapper that was destabilizing installs (#613, #638). **v0.7.4** lets you **choose where the Windows VM lives** (`install.sh --storage-dir`, #646) and **install from a local Windows ISO** to skip the download (`--win-iso`, #647), defaults a 24 GB+ host to **8 GB VM RAM** (#630); plus fixes — opening a second document in an already-running app, *Open with* on stripped-PATH desktops like Deepin, a clear error on a missing RDP password (#569), the GUI Logs buttons honouring the Docker backend, the `.deb` pulling in `podman-compose` (#644), and the maintenance dialog size (#550). **v0.8.0** makes app discovery **Start-Menu-only by default** — only the apps your Windows Start Menu actually shows, grouped into nested folder sub-groups, with a `desktop.full_app_scan` opt-in for the old scan-everything behaviour (#581); passes your `[pod] keyboard` to FreeRDP as the session layout (#660); stages `--win-iso` *before* compose-up so dockur installs from it instead of downloading (#647); self-heals the reverse-open listener; bumps bundled rdprrap to **0.3.0**; and fixes several GUI crashes — the *Refresh Apps* SIGSEGV, an off-thread display-scale SIGABRT, the Debloat task dialog not auto-closing (#550), and a layout-recursion SIGSEGV with long / CJK app names (#553). **v0.9.0** lets Windows apps handle **URL-scheme links** from Linux — click a `mailto:` link and Outlook opens; app schemes like `slack:` / `vnc:` route to the right Windows app, auto-harvested during discovery and registered as `x-scheme-handler`s (#421, #694). It also **hardens the trust boundary**: a discovered guest app can no longer silently become your default `http`/`https` handler (opt-in only), and the session window-reaper no longer mis-kills a just-relaunched app or mass-kills sessions on a transient scan failure (#680 follow-up); plus fixes for kio-fuse detection on multiarch/KF6 layouts (#697), the reverse-open listener autostarting with the tray (#691), the suspend/resume D-Bus subscription (#690), and the install one-liner auto-installing `git` when missing (#705). The AppImage is **Thin** (~110 MB) — only FreeRDP + Python + Qt + winpodx — and uses the host's `podman` / `docker`. The CLI surface settled in 0.6.0 stands: **`winpodx guest`** (guest-side ops), **`winpodx install`** (install / disk ops), and **`winpodx doctor`** (diagnostics with `--json` / `--quick` / `--fix`); the post-create chain is the single **`winpodx provision`**. First install still takes ~5–10 minutes (Windows VM ISO download + Sysprep + OEM apply); `winpodx pod wait-ready --logs` shows live progress. Please file issues at <https://github.com/kernalix7/winpodx/issues> if something breaks.
|
||||
|
||||
**No full-screen RDP.** Each Windows app becomes its own Linux window with its real icon — pinnable, alt-tabbable, file-associated, both directions. Drop into a full Windows desktop only when you actually want one (`winpodx app run desktop`).
|
||||
|
||||
WinPodX runs a Windows container (via [dockur/windows](https://github.com/dockur/windows)) in the background and presents Windows apps as native Linux applications through FreeRDP RemoteApp, while a bearer-authed HTTP agent inside the guest handles the host→guest command channel without flashing a PowerShell window. The reverse direction — Linux apps surfaced in the Windows "Open with…" menu — is handled by a host-side listener that consumes JSON requests written by per-slug Rust shims inside the guest. **Near-zero external Python dependencies** (stdlib only on Python 3.11+; one pure-Python `tomli` fallback on 3.9/3.10).
|
||||
|
||||
## Minimum requirements
|
||||
|
||||
**Before installing**, make sure your machine actually supports virtualisation. WinPodX runs Windows in a KVM-backed container; without these three, the install will run to completion but Windows will never boot.
|
||||
|
||||
| Requirement | How to check | Fix |
|
||||
|---|---|---|
|
||||
| **Intel VT-x or AMD-V enabled in BIOS / UEFI** | `lscpu \| grep -i virtualization` shows `VT-x` or `AMD-V` | Reboot → firmware setup → enable "Intel Virtualization Technology" / "SVM Mode" / "VT-x". OFF by default on many laptops. |
|
||||
| **kvm kernel module loaded** | `lsmod \| grep kvm` lists `kvm_intel` or `kvm_amd` | `sudo modprobe kvm_intel` (Intel) or `sudo modprobe kvm_amd` (AMD). Auto-loads on next boot once BIOS allows it. |
|
||||
| **Your user is in the `kvm` group** | `id -nG \| tr ' ' '\n' \| grep kvm` returns `kvm` | `sudo usermod -aG kvm $USER`, then log out + back in. |
|
||||
|
||||
Hardware: x86_64 or aarch64 CPU with virtualisation extensions, 8 GB+ RAM (12 GB+ recommended), ~30 GB free disk for the Windows image. `install.sh` aborts with the same diagnostic if `/dev/kvm` is missing after the package install step — most "install ran fine but Windows never boots" bug reports trace back to one of the rows above.
|
||||
|
||||
## Quick install
|
||||
|
||||
One-liner (any supported Linux distro):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Or via a native package manager:
|
||||
|
||||
```bash
|
||||
# openSUSE Tumbleweed / Leap / Slowroll
|
||||
sudo zypper addrepo https://download.opensuse.org/repositories/home:/Kernalix7/openSUSE_Tumbleweed/home:Kernalix7.repo
|
||||
sudo zypper install winpodx
|
||||
|
||||
# Fedora 42 / 43 / 44 (dnf5 — Fedora 41+)
|
||||
sudo dnf config-manager addrepo --from-repofile=https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_43/home:Kernalix7.repo
|
||||
sudo dnf install winpodx
|
||||
|
||||
# Debian / Ubuntu — grab the matching .deb from the latest release
|
||||
sudo apt install ./winpodx_<version>_all_debian13.deb
|
||||
|
||||
# AlmaLinux / Rocky / RHEL 9 / 10 — grab the matching .rpm
|
||||
sudo dnf install ./winpodx-<version>-0.noarch.el10.rpm
|
||||
|
||||
# Arch
|
||||
yay -S winpodx
|
||||
|
||||
# Nix
|
||||
nix run github:kernalix7/winpodx
|
||||
|
||||
# AppImage (distro-agnostic, single file)
|
||||
# Download winpodx-<version>-x86_64.AppImage from the latest GitHub release
|
||||
chmod +x winpodx-*-x86_64.AppImage
|
||||
./winpodx-*-x86_64.AppImage setup
|
||||
```
|
||||
|
||||
> **After a package-manager / AppImage install:** run `winpodx setup` once to generate `~/.config/winpodx/winpodx.toml` + compose.yaml. The curl one-liner does this for you (and waits ~5–10 min for the Windows first boot); package installs ship the binary only so `apt install` / `dnf install` / `yay -S` / first AppImage launch don't trigger a 10-minute Windows ISO download out of the blue. After setup, just launching an app (`winpodx app run desktop`) auto-provisions the pod the first time.
|
||||
>
|
||||
> The Thin AppImage (0.6.0) bundles Python + Qt + winpodx + FreeRDP only — the container runtime lives on the host (`podman` ≥ 4 recommended, `docker` also supported) so the AppImage no longer fights a host stack you already have (#357, #363). Pre-0.6.0 fat AppImages bundled the whole podman stack and shadowed the host's. Host-side requirements left: a container runtime via your package manager, `/dev/kvm`, `kvm` group membership, and `/etc/subuid` / `/etc/subgid` for rootless Podman. `winpodx setup-host` fixes the kvm / subuid bits via a single `pkexec` prompt; `winpodx doctor` surfaces anything still missing.
|
||||
|
||||
See [docs/INSTALL.md](docs/INSTALL.md) for offline / air-gapped builds, source installs, version pinning, and uninstall.
|
||||
|
||||
## First-time setup
|
||||
|
||||
If you used the `curl install.sh` one-liner, setup already ran and the Windows VM is booting -- skip to [Launch](#launch). For every other install path (package managers, AppImage, source, pip) run setup once before the first app launch:
|
||||
|
||||
```bash
|
||||
# Auto setup -- host-detected defaults, no prompts
|
||||
winpodx setup
|
||||
|
||||
# Interactive wizard -- pick backend, cores, RAM, edition, language, timezone, debloat preset
|
||||
winpodx setup --customize
|
||||
```
|
||||
|
||||
Setup writes `~/.config/winpodx/winpodx.toml` + `compose.yaml`, registers the GUI launcher, and confirms the host has FreeRDP + Podman / Docker + KVM. If any of those are missing, the output ends with a per-distro install command (e.g. `sudo apt install xfreerdp3 podman podman-compose` on Debian / Ubuntu, `sudo dnf install ...` on Fedora) -- run it and re-run `winpodx setup`.
|
||||
|
||||
The first app launch then provisions the pod, pulls the dockur image, runs the Windows ISO download + Sysprep + OEM apply, and reaches a usable RDP session in ~5-10 min. `winpodx pod wait-ready --logs` tails container progress live so you can watch each phase:
|
||||
|
||||
```bash
|
||||
winpodx app run desktop # First launch -- ~5-10 min, subsequent launches near-instant
|
||||
winpodx pod wait-ready --logs # Optional: watch first-boot progress live
|
||||
```
|
||||
|
||||
Run `winpodx doctor` any time afterwards to re-check host state and surface the next fix command if something drifts:
|
||||
|
||||
```bash
|
||||
winpodx doctor # Read-only -- prints what would need fixing
|
||||
winpodx guest apply-fixes # Re-applies guest-side runtime fixes (RDP timeouts, NIC power-save, etc.)
|
||||
```
|
||||
|
||||
## Launch
|
||||
|
||||
```bash
|
||||
winpodx app run word # Launch Word
|
||||
winpodx app run word ~/doc.docx # Open a file
|
||||
winpodx app run desktop # Full Windows desktop
|
||||
winpodx launch # Quick app launcher (Start-menu style picker)
|
||||
```
|
||||
|
||||
Or just click an app icon in your application menu. `winpodx launch` opens a searchable picker of your Windows apps — bind it to a desktop-environment custom shortcut (KDE: *System Settings → Shortcuts → Custom*; GNOME: *Settings → Keyboard → Custom Shortcuts*) for a system-wide hotkey. See [docs/USAGE.md](docs/USAGE.md) for the full CLI, the Qt6 GUI, health checks, and configuration.
|
||||
|
||||
## Key features
|
||||
|
||||
<table>
|
||||
<tr><td colspan="2">
|
||||
|
||||
**Bare-metal disguise (VM-detection avoidance)** — new in 0.7.0 · opt-in, off by default
|
||||
- Makes the Windows guest read as a **physical machine** to software that refuses to run under a detected hypervisor — Nvidia GPU-passthrough "code 43", launch-gate VM checks, VM-hostile installers
|
||||
- `pod.disguise_level balanced | max`: **balanced** hides the CPUID hypervisor bit + KVM signature and mirrors the host's real SMBIOS/DMI; **max** ("Hardened") adds a locally-built patched-QEMU image (`winpodx disguise build-image`) that rewrites the ACPI / disk / sensor / USB fingerprints and drops the virtio + Red-Hat PCI tells (keeps USB3)
|
||||
- Host-derived strings stay in the **local image only** (never committed to git); serial / UUID / asset-tag are never read
|
||||
- **al-khaser 0.82-verified** — enable with `winpodx config set pod.disguise_level max` or the GUI Settings "Bare-metal" selector
|
||||
- [Details →](docs/FEATURES.md#bare-metal-disguise-vm-detection-avoidance)
|
||||
|
||||
</td></tr>
|
||||
<tr><td width="50%">
|
||||
|
||||
**Reverse-open**
|
||||
- Linux apps appear in the Windows guest's right-click "Open with…" menu by default
|
||||
- Correct per-app icons in both the short menu and the long "Choose another app" dialog
|
||||
- Selecting one round-trips the file open to host `xdg-open`
|
||||
- Auto-discovers host-side Linux apps + their MIME associations from freedesktop standards
|
||||
- Manage via `winpodx host-open` CLI or the GUI Settings panel
|
||||
- [Details →](docs/FEATURES.md#reverse-open-linux-apps-in-windows-open-with)
|
||||
|
||||
</td><td width="50%">
|
||||
|
||||
**Seamless app windows**
|
||||
- RemoteApp (RAIL) renders each Windows app as a native Linux window — no full desktop
|
||||
- Per-app taskbar icons via `WM_CLASS` matching (`/wm-class:<stem>` + `StartupWMClass`)
|
||||
- Bidirectional file associations: double-click `.docx` in your file manager → Word opens
|
||||
- Multi-session RDP: bundled [rdprrap](https://github.com/kernalix7/rdprrap) auto-enables up to 10 independent sessions
|
||||
- Multi-monitor RAIL (0.6.0): a remote-app window keeps working input when dragged onto a second monitor — on by default (`cfg.rdp.multimon`, default `span`)
|
||||
- RAIL prerequisites set automatically during unattended install
|
||||
|
||||
</td></tr>
|
||||
<tr><td width="50%">
|
||||
|
||||
**Zero-config launch**
|
||||
- First app click auto-provisions everything: config, container, desktop entries
|
||||
- Auto-discovery on first boot scans the running Windows guest and registers every installed app with its real icon (Registry App Paths, Start Menu, UWP/MSIX, Chocolatey, Scoop)
|
||||
- Manual rescan any time via `winpodx app refresh` or the GUI Refresh button
|
||||
- Multi-backend: Podman (default), Docker, manual RDP (the libvirt backend was dropped in 0.6.0 — stay on ≤0.5.x or use the manual backend for your own libvirt domain)
|
||||
|
||||
</td><td width="50%">
|
||||
|
||||
**Peripherals & sharing**
|
||||
- **Clipboard**: bidirectional copy-paste (text + images) — on by default
|
||||
- **Sound**: RDP audio streaming (`/sound:sys:alsa`) — on by default
|
||||
- **Printer**: Linux printers shared to Windows — on by default
|
||||
- **Home directory**: shared as `\\tsclient\home`
|
||||
- **USB drives**: auto-mapped to drive letters (E:, F:, …) via FileSystemWatcher; subfolders work for drives plugged in after session start; the USB desktop shortcut (`\\tsclient\media`) always resolves, opening an empty folder when nothing is mounted instead of erroring
|
||||
- **Host USB / PCI device passthrough** (0.6.0): pass real host devices into the Windows guest — `winpodx device list / attach <id> / detach <id>`, a GUI "Devices" tab (two-column host↔guest mover), and a system-tray USB switcher. USB hot-plugs live (`cfg.pod.usb_live`, default on); PCI is boot-added and needs a guest restart plus a `--force` / dialog confirmation
|
||||
|
||||
</td></tr>
|
||||
<tr><td width="50%">
|
||||
|
||||
**Automation & security**
|
||||
- Auto suspend / resume: container pauses when idle, resumes on next launch
|
||||
- Pod auto-start on login (v0.5.9, opt-in): `winpodx autostart on` installs a tray autostart entry so the pod starts/resumes at login — off by default (`autostart off|status`, or a GUI Settings checkbox)
|
||||
- UNRESPONSIVE → recover (v0.5.5): stalled RDP guest is detected on `RUNNING → UNRESPONSIVE` and self-healed via in-guest TermService cycle, no `pod restart` needed
|
||||
- Host-adaptive Windows-on-KVM tuning profile (v0.5.5): `+invtsc`, `platform_tick` and more, gated by host capability — `tuning_profile = auto|safe|off`
|
||||
- Password auto-rotation: 20-char cryptographic password, 7-day cycle with atomic rollback
|
||||
- Smart DPI scaling: auto-detects from GNOME, KDE, Sway, Hyprland, Cinnamon, xrdb
|
||||
- Windows debloat: telemetry, ads, Cortana, search indexing disabled by default
|
||||
- FreeRDP `extra_flags` allowlist (regex-validated) as the user-input safety boundary
|
||||
- Time sync: force Windows clock resync after host sleep/wake
|
||||
|
||||
</td><td width="50%">
|
||||
|
||||
**Operations & resilience**
|
||||
- Multilingual UI (v0.5.9): tray / GUI / CLI fully translated to 7 languages (en / ko / zh / ja / de / fr / it), auto-detected from `$LANG` — override with `winpodx language <code>` or GUI Settings → "WinPodX UI language"
|
||||
- Windows disk auto-grow (v0.5.9): C: grows itself when it fills past a threshold while idle, bounded by host free space — or grow on demand (`winpodx install grow-disk [SIZE]`, `winpodx install disk-usage`, GUI Tools → Grow Disk)
|
||||
- Guest sync (v0.5.9): push updated agent / urlacl / rdprrap / fixes into a running guest after a host upgrade — automatic once per pod start, or `winpodx guest sync [--force]`
|
||||
- Offline / air-gapped install (`--source` + `--image-tar`)
|
||||
- One-line uninstall (keeps Windows VM data unless `--purge`)
|
||||
- Health checks via `winpodx doctor` (deps / pod / RDP / agent / disk / round-trip / password age; `--json` for machine-readable, `--quick` for cheap subset, `--fix` for idempotent auto-remediation of common findings)
|
||||
- Redesigned Qt6 GUI (0.6.0): a left Start-menu-style navigation sidebar + a new **Dashboard** home with live Pod / RAM / CPU ring gauges, disk usage, an auto-recovery status card, pinned/recent workspace tiles, and a reverse-open toggle; the app launcher is now the "All apps" page, alongside Devices / Settings / Tools / Terminal / Info — plus a lighter system tray. In-house SVG icon set, responsive reflow, and a hero search that doubles as a command bar
|
||||
- Stdlib-leaning Python (no pip-deps on 3.11+; one `tomli` fallback on 3.9 / 3.10)
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
See [docs/FEATURES.md](docs/FEATURES.md) for deep dives, including multi-session RDP internals, app profile schema, and the reverse-open architecture.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | What's inside |
|
||||
|----------|---------------|
|
||||
| [INSTALL.md](docs/INSTALL.md) | Every install path — one-liner, package managers, AppImage, offline, Nix, source |
|
||||
| [USAGE.md](docs/USAGE.md) | CLI reference, Qt6 GUI tour, health checks, configuration file |
|
||||
| [FEATURES.md](docs/FEATURES.md) | Reverse-open, multi-session RDP, peripherals, app profiles, auto-discovery |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | How it works (diagram), tech stack, source tree, data flows |
|
||||
| [COMPARISON.md](docs/COMPARISON.md) | WinPodX vs winapps / LinOffice / winboat, and WinPodX vs Wine |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Full version history |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup and workflow |
|
||||
| [SECURITY.md](SECURITY.md) | Security disclosure process |
|
||||
|
||||
## Supported distros
|
||||
|
||||
| Distro | Package manager | Status |
|
||||
|--------|-----------------|--------|
|
||||
| openSUSE Tumbleweed / Leap 15.6 / Leap 16.0 / Slowroll | zypper | Tested |
|
||||
| Fedora 42 / 43 / 44 / Rawhide | dnf | Supported |
|
||||
| Fedora Silverblue / Kinoite / Sericea / Bluefin / Bazzite (42 / 43 / 44) | rpm-ostree (OBS, `--apply-live`) | Supported |
|
||||
| Debian 12 / 13, Ubuntu 24.04 / 25.04 / 25.10 / 26.04 | apt | Supported |
|
||||
| AlmaLinux / Rocky / RHEL 9 / 10 | dnf | Supported |
|
||||
| Arch / Manjaro | pacman + `yay -S winpodx` | Supported |
|
||||
| NixOS (and Nix on any distro) | nix flake | Supported |
|
||||
|
||||
Each tag push (`v*.*.*`) publishes to all channels automatically — see [packaging/](packaging/) for maintainer details.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# From repo root (no install needed)
|
||||
export PYTHONPATH="$PWD/src"
|
||||
python3 -m pytest tests/ # 1800+ tests
|
||||
ruff check src/ tests/ # Lint
|
||||
ruff format --check src/ tests/
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, branch naming, commit conventions, and CI expectations.
|
||||
|
||||
## Security
|
||||
|
||||
For security issues, follow the process in [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#kernalix7/winpodx&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kernalix7/winpodx&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kernalix7/winpodx&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kernalix7/winpodx&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Support
|
||||
|
||||
If WinPodX makes your Linux desktop a little nicer:
|
||||
|
||||
[](https://github.com/sponsors/kernalix7)
|
||||
[](https://ko-fi.com/kernalix7)
|
||||
[](https://fairy.hada.io/@kernalix7)
|
||||
|
||||
GitHub Sponsors supports recurring or one-time sponsorship; Ko-fi handles international cards and PayPal; fairy.hada.io is a Korean tipping platform. Bug reports, PRs, and stars on the repo are equally appreciated and free.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) — Kim DaeHyun (kernalix7@kodenet.io)
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`kernalix7/winpodx`
|
||||
- 原始仓库:https://github.com/kernalix7/winpodx
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Security Policy
|
||||
|
||||
**English** | [한국어](docs/SECURITY.ko.md)
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| Latest | Yes |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities through GitHub Security Advisories:
|
||||
|
||||
**[Report a vulnerability](https://github.com/kernalix7/winpodx/security/advisories/new)**
|
||||
|
||||
**Do NOT open a public issue for security vulnerabilities.**
|
||||
|
||||
### What to Include
|
||||
|
||||
- **Description**: A clear description of the vulnerability
|
||||
- **Steps to Reproduce**: Detailed steps to reproduce the issue
|
||||
- **Impact**: The potential impact of the vulnerability
|
||||
- **Affected Components**: Which modules or files are affected
|
||||
- **Environment**:
|
||||
- Operating System and version
|
||||
- Python version
|
||||
- FreeRDP version
|
||||
- Desktop Environment
|
||||
- Display Server (X11/Wayland)
|
||||
|
||||
## Response Timeline
|
||||
|
||||
| Step | Timeframe |
|
||||
|------|-----------|
|
||||
| Acknowledgment | Within 48 hours |
|
||||
| Assessment | Within 7 days |
|
||||
| Fix | Within 30 days |
|
||||
|
||||
## Scope
|
||||
|
||||
The following areas are considered in scope for security reports:
|
||||
|
||||
- **Command injection in subprocess calls**: Unsanitized input passed to shell commands
|
||||
- **Credential exposure in config files**: Passwords or secrets stored in plaintext
|
||||
- **RDP session hijacking**: Unauthorized access to active RDP sessions
|
||||
- **Path traversal in UNC conversion**: Manipulation of Windows/Linux path translation
|
||||
- **Privilege escalation via backend management**: Unauthorized privilege gain through container or VM backends
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are considered out of scope:
|
||||
|
||||
- Attacks requiring physical access to the machine
|
||||
- Social engineering attacks
|
||||
- Vulnerabilities in third-party dependencies (report these to the upstream project)
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
This project follows these security practices:
|
||||
|
||||
- **Input validation on subprocess arguments**: All arguments passed to subprocess calls are validated and sanitized
|
||||
- **Askpass preferred over plaintext passwords**: Interactive password prompts (askpass) are used instead of storing passwords in plaintext
|
||||
- **XDG-compliant file permissions**: Configuration files follow XDG Base Directory Specification with appropriate file permissions
|
||||
- **No secrets in code or git**: Secrets, credentials, and API keys are never committed to the repository
|
||||
- **TLS-only RDP**: SecurityLayer=2 (TLS) enforced on the RDP channel; NLA disabled only because RDP is bound to 127.0.0.1
|
||||
- **Windows build pinning**: Feature updates blocked via registry policy; security updates install normally
|
||||
- **Container-isolated RDP**: RDP port is bound to 127.0.0.1 only; not exposed to the network
|
||||
|
||||
## Host <-> Guest Trust Model
|
||||
|
||||
WinPodX treats the Windows guest as a **semi-trusted** component, not a trusted
|
||||
extension of the host. This section documents how that trust boundary is drawn
|
||||
and enforced, specifically for the app-discovery channel introduced in v0.1.7.
|
||||
|
||||
### Provisioning scope and threat assumptions
|
||||
|
||||
WinPodX provisions the Windows guest itself inside a rootless Podman container
|
||||
(or, optionally, Docker / libvirt). Because the host controls the image, the
|
||||
unattended-install answer file, and the OEM post-install scripts, a freshly
|
||||
provisioned guest starts in a known-good state.
|
||||
|
||||
However, JSON emitted by `scripts/windows/discover_apps.ps1` is treated as
|
||||
**semi-trusted input**: it is not attacker-controlled network data, but it is
|
||||
also not implicitly trusted code. The guest can be corrupted through entirely
|
||||
legitimate Windows-side activity, for example:
|
||||
|
||||
- A malicious `.lnk` dropped into the user's Start Menu folder.
|
||||
- A rogue `AppxManifest.xml` from a sideloaded UWP package.
|
||||
- Supply-chain compromise of a Windows application the user installed
|
||||
themselves (installer trojan, auto-update hijack, etc.).
|
||||
|
||||
Any of these can cause the discovery script to emit hostile metadata. The host
|
||||
side must therefore validate everything the guest sends before it is allowed to
|
||||
influence the host filesystem or any executed command line.
|
||||
|
||||
### Host-side validation pipeline
|
||||
|
||||
The host enforces the trust boundary with a layered set of guards. Each of
|
||||
these rejects malformed guest output before it can be written to disk or
|
||||
interpolated into a subprocess argument list:
|
||||
|
||||
- **`_CONTAINER_NAME_RE`** — the container name pulled from the host config is
|
||||
scrubbed against an allowlist regex before it is passed as a subprocess arg.
|
||||
- **`_AUMID_RE`** — the UWP launch URI must match
|
||||
`^[A-Za-z0-9._-]+![A-Za-z0-9._-]+$`, i.e. a bare
|
||||
`PackageFamilyName!AppId`. Anything else is refused.
|
||||
- **`_WM_CLASS_RE`** — the WM class hint used for desktop-entry
|
||||
`StartupWMClass=` must be a safe Linux identifier.
|
||||
- **Slug regex** — every slug derived from a guest-supplied app name is
|
||||
sanitized to `[a-z0-9_-]{1,64}` before it is used as a filesystem path
|
||||
component.
|
||||
- **`_MAX_APPS` (500)** — a hard cap on the total number of discovered
|
||||
entries accepted per run.
|
||||
- **`_MAX_ICON_BYTES` (1 MiB per icon)** — a per-icon size cap.
|
||||
- **`_MAX_PATH_LEN`** — a cap on the length of guest-provided path strings.
|
||||
- **Host-side stdout cap (64 MiB)** — the subprocess reader bounds the total
|
||||
bytes consumed from the guest, so a misbehaving guest cannot OOM the host.
|
||||
- **PNG magic byte + `QImage.loadFromData` sanity check** — every icon must
|
||||
begin with the PNG signature and must successfully parse through Qt's image
|
||||
decoder before it is written under `~/.local/share/icons/hicolor/`.
|
||||
- **`_safe_rmtree`** — refuses to delete any path outside
|
||||
`~/.local/share/winpodx/apps/`.
|
||||
- **`_is_within`** — refuses to write any path outside the WinPodX user data
|
||||
directory.
|
||||
- **List-args subprocess only** — all subprocess invocations pass an argv
|
||||
list; `shell=True` is never used on guest-derived strings.
|
||||
|
||||
### Secret flow direction
|
||||
|
||||
Secrets flow **host -> guest only**. The RDP password is generated on the host
|
||||
by `core/compose.py` `generate_password`, written into the guest through
|
||||
unattended-install compose environment variables, and rotated on a
|
||||
host-controlled schedule.
|
||||
|
||||
The discovery JSON carries **no credentials** in either direction. The
|
||||
`guest -> host` channel is strictly typed as `{app metadata, icon bytes}` and
|
||||
nothing else, so a compromised guest cannot exfiltrate host secrets through
|
||||
this channel because the channel has no field that could carry them.
|
||||
|
||||
### Guest-compromise impact (possible vs. not possible)
|
||||
|
||||
If an attacker fully compromises the Windows guest, the bounded impact via the
|
||||
discovery channel is:
|
||||
|
||||
**Possible:**
|
||||
|
||||
- Bogus `.desktop` entries appearing in the user's app menu (with names
|
||||
already sanitized through the slug regex).
|
||||
- A PNG crafted specifically to exercise the `QImage` parser.
|
||||
- Broken launches for malicious AUMIDs that fail `_AUMID_RE` validation (the
|
||||
launch simply never happens).
|
||||
|
||||
**NOT possible via the discovery channel:**
|
||||
|
||||
- Host code execution outside the PNG parser itself (which runs inside Qt's
|
||||
or the stdlib's own image-decoding sandbox).
|
||||
- Filesystem writes outside `~/.local/share/winpodx/apps/` and
|
||||
`~/.local/share/icons/hicolor/`.
|
||||
- Command injection into FreeRDP, Podman, Docker, or virsh argument lists.
|
||||
- Credential exfiltration (no credentials traverse the guest -> host
|
||||
direction).
|
||||
|
||||
Additionally, the Windows guest runs under **rootless Podman** by default, so
|
||||
even a full guest-kernel RCE does not grant host root. A guest-compromise
|
||||
attacker is confined to the unprivileged user namespace that hosts the guest
|
||||
container.
|
||||
|
||||
## Attribution
|
||||
|
||||
We appreciate responsible disclosure and will credit reporters in release notes (unless anonymity is preferred).
|
||||
@@ -0,0 +1,137 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
WinPodX is MIT-licensed (see [LICENSE](LICENSE)). This document lists the
|
||||
third-party components redistributed inside the source tree or pulled in as
|
||||
runtime/optional dependencies, together with their upstream licenses.
|
||||
|
||||
## Bundled binaries
|
||||
|
||||
### rdprrap
|
||||
|
||||
- Upstream: https://github.com/kernalix7/rdprrap
|
||||
- Version: 0.3.0 (pinned by `config/oem/rdprrap_version.txt`, SHA256-verified)
|
||||
- License: MIT
|
||||
- Bundled as: `config/oem/rdprrap-0.3.0-windows-x64.zip`
|
||||
- Role: enables multi-session RDP on the Windows guest during first-boot OEM
|
||||
install. Same copyright holder as WinPodX.
|
||||
|
||||
rdprrap's own source tree ports code from three upstream projects whose
|
||||
licenses require attribution / license-text redistribution. The bundled ZIP
|
||||
therefore ships:
|
||||
|
||||
- `LICENSE` — rdprrap's own MIT terms.
|
||||
- `NOTICE` — names each upstream project and lists the rdprrap source files
|
||||
derived from it: `stascorp/rdpwrap` (Apache-2.0), `llccd/TermWrap` (MIT),
|
||||
`llccd/RDPWrapOffsetFinder` (MIT).
|
||||
- `vendor/licenses/` — verbatim copies of the three upstream license texts.
|
||||
- `THIRD_PARTY_LICENSES.txt` — compiled Rust-dependency attributions,
|
||||
auto-generated from the crate graph.
|
||||
|
||||
WinPodX redistributes the ZIP unmodified. All four attribution files are
|
||||
extracted into the Windows guest at first-boot install time
|
||||
(`C:\Program Files\RDP Wrapper\` and `C:\winpodx\rdprrap\`), which is where
|
||||
the binaries live and is the redistribution surface that the upstream
|
||||
licenses govern.
|
||||
|
||||
> **Historical note.** WinPodX 0.1.6 bundled rdprrap 0.1.0, which upstream
|
||||
> later withdrew because the 0.1.0 / 0.1.1 ZIPs were missing `NOTICE` and
|
||||
> `vendor/licenses/`. 0.1.7 onward bundles 0.1.3 and is the first
|
||||
> license-compliant WinPodX release for this component. WinPodX 0.8.0 bumps the
|
||||
> bundled component to rdprrap 0.3.0 (same MIT terms, same copyright holder;
|
||||
> 0.3.0 derives the `termsrv.dll` patch sites dynamically).
|
||||
|
||||
### rcedit
|
||||
|
||||
- Upstream: https://github.com/electron/rcedit
|
||||
- License: MIT (`Copyright (c) 2013 GitHub Inc.`)
|
||||
- Bundled as: `config/oem/reverse-open/shim/bin/rcedit.exe`
|
||||
- Role: patches PE metadata on the per-app reverse-open shim during OEM
|
||||
install. `LICENSE-rcedit.txt` ships beside the binary in the same
|
||||
directory.
|
||||
|
||||
### winpodx-reverse-open-shim
|
||||
|
||||
- Own code (`config/oem/reverse-open/shim/`, `Cargo.toml` declares MIT).
|
||||
- License: MIT (same as WinPodX).
|
||||
- Bundled as: `config/oem/reverse-open/shim/bin/winpodx-reverse-open-shim.exe`
|
||||
- Role: stub Windows Explorer invokes from "Open with" to relay a
|
||||
file-open request back to the host's reverse-open listener.
|
||||
|
||||
## Runtime dependency (always required)
|
||||
|
||||
| Package | License | When | Notes |
|
||||
|---------|---------|------|-------|
|
||||
| [tomli](https://pypi.org/project/tomli/) | MIT | Python 3.9 / 3.10 only | Back-fills stdlib `tomllib` (3.11+). Pure Python. |
|
||||
|
||||
## Optional dependencies (only installed with matching extras)
|
||||
|
||||
| Package | License | Extra | Linkage |
|
||||
|---------|---------|-------|---------|
|
||||
| [PySide6](https://pypi.org/project/PySide6/) | LGPL-3.0-or-later (with [Qt for Python FAQ exceptions](https://www.qt.io/qt-for-python)) | `winpodx[gui]` | Dynamic — imported at runtime. Not redistributed by WinPodX. |
|
||||
| [docker](https://pypi.org/project/docker/) (docker-py) | Apache-2.0 | `winpodx[docker]` | Dynamic — imported at runtime. |
|
||||
| [pyxdg](https://pypi.org/project/pyxdg/) | LGPL-2.0-or-later | (none — soft optional) | Dynamic — try-imported at runtime by `core/reverse_open/mime.py` for the long-tail MIME→extension fallback when a type isn't in the curated 86-entry table. WinPodX works without it (fallback simply returns the curated entry only). Not vendored. |
|
||||
|
||||
LGPL compliance: WinPodX does not statically link, vendor, or redistribute
|
||||
the PySide6 binaries. Users install them from PyPI (or their distro) at their
|
||||
own discretion; the LGPL reverse-engineering / replacement rights are preserved
|
||||
because the libraries remain swappable at the Python import level.
|
||||
|
||||
## Development-only dependencies (`winpodx[dev]`)
|
||||
|
||||
| Package | License |
|
||||
|---------|---------|
|
||||
| pytest | MIT |
|
||||
| ruff | MIT |
|
||||
| pip-audit | Apache-2.0 |
|
||||
| hatchling (build backend) | MIT |
|
||||
|
||||
Dev dependencies are not shipped in the wheel / sdist / distro packages.
|
||||
|
||||
## Fat AppImage release artifact (DOES redistribute the components below)
|
||||
|
||||
The **source tree, wheel, `.deb`, and `.rpm` do not vendor** FreeRDP / Podman
|
||||
/ Qt / Python — they are runtime dependencies the host provides (see the next
|
||||
section). **The fat AppImage release artifact is the exception**: since v0.5.8,
|
||||
`winpodx-fat-x86_64.AppImage` bundles, for self-contained operation on
|
||||
immutable distros:
|
||||
|
||||
- **Python 3.11** (astral-sh python-build-standalone) — PSF
|
||||
- **PySide6 / Qt6** — LGPL-3.0 (dynamically loaded; the AppImage SquashFS is
|
||||
user-extractable via `--appimage-extract`, satisfying LGPL relinking)
|
||||
- **Pillow** (HPND), **cairosvg** (LGPL-3.0), **pyxdg** (LGPL-2.0)
|
||||
- **FreeRDP** (xfreerdp / wlfreerdp / sdl-freerdp) — Apache-2.0
|
||||
- **Podman**, **conmon**, **crun**, **netavark**, **slirp4netns**, **passt** —
|
||||
Apache-2.0
|
||||
- **podman-compose** — GPL-2.0, invoked by WinPodX as a **separate executable
|
||||
via subprocess** (mere aggregation under GPLv2 §2 — WinPodX itself stays
|
||||
MIT; the GPL does not reach across the exec boundary)
|
||||
|
||||
Each bundled component's license + NOTICE text is shipped inside the AppImage
|
||||
at `usr/share/doc/winpodx/third-party/`, alongside WinPodX's own `LICENSE` and
|
||||
this file at `usr/share/doc/winpodx/`. A GPL-2.0 source offer for
|
||||
podman-compose is included there too. The CI build step that collects these is
|
||||
in `.github/workflows/appimage-publish.yml`.
|
||||
|
||||
## Runtime system dependencies (not vendored)
|
||||
|
||||
Installed by `install.sh` via the host's package manager, or by the user
|
||||
(this is the default for the wheel / `.deb` / `.rpm` / curl install — only
|
||||
the fat AppImage above bundles them):
|
||||
|
||||
- **FreeRDP 3+** — Apache-2.0
|
||||
- **Podman** / Docker — Apache-2.0 / Apache-2.0
|
||||
- **Microsoft Windows** — EULA-governed; the user supplies their own license
|
||||
via the dockur/windows image, which WinPodX pulls at setup time.
|
||||
- **dockur/windows container image** — MIT
|
||||
(https://github.com/dockur/windows). WinPodX orchestrates but does not
|
||||
redistribute this image.
|
||||
|
||||
## Reference projects (inspiration only, no code redistributed)
|
||||
|
||||
- **winapps** (https://github.com/winapps-org/winapps) — independent
|
||||
predecessor that also wraps FreeRDP RemoteApp. WinPodX's CLI shape and
|
||||
`.cproc` tracking concepts are compatible with winapps configuration
|
||||
conventions for migration, but WinPodX does not copy winapps source code.
|
||||
- **LinOffice** — concept reference only; no source derivation.
|
||||
|
||||
If you find any attribution gap, please open an issue.
|
||||
@@ -0,0 +1,23 @@
|
||||
# WinPodX OEM post-install
|
||||
|
||||
Files in this directory are mounted read-only into the guest at `/oem` by
|
||||
`config/oem:/oem:Z` in the generated compose.yaml. The dockur/windows image
|
||||
executes `install.bat` once, on first boot, after Windows OOBE finishes.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `install.bat` | One-shot first-boot configurator: DNS, RDP/NLA, RemoteApp, firewall, power plan, telemetry lockdown, desktop shortcuts to the `\\tsclient` shares. |
|
||||
| `toggle_updates.ps1` | Runtime toggle for Windows Update (`enable`/`disable`/`status`). Edits `hosts` with `-Encoding ASCII` (PS 5.1 ANSI default and PS 7 UTF-8-BOM both break the Windows DNS client's `hosts` parser). |
|
||||
|
||||
## USB media
|
||||
|
||||
There is no drive-letter auto-mapper. Removable media plugged into the host is
|
||||
redirected by FreeRDP and reachable in every session at `\\tsclient\media\<LABEL>`;
|
||||
`install.bat` puts a desktop **USB** shortcut pointing at `\\tsclient\media`. A
|
||||
real block device (its own drive letter, raw access) is available through USB
|
||||
passthrough. The old `media_monitor.ps1` that mapped each volume to a drive
|
||||
letter was removed (#613/#638): it could not surface a letter reliably in
|
||||
RemoteApp (RAIL) sessions and, when shipped in the OEM bundle, re-triggered the
|
||||
intermittent Defender/rdprrap first-boot install deadlock.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Last-known-good versions of bundled / pulled dependencies.
|
||||
# The check-windows-updates GitHub workflow reads this file weekly and
|
||||
# opens a tracking issue when upstream state has drifted from these
|
||||
# values. Two drift signals are tracked:
|
||||
#
|
||||
# - Release tag (``dockur=`` / ``dockur-arm=``): a new dockur release on
|
||||
# GitHub. The issue links the release notes.
|
||||
# - ``:latest`` digest (``dockur-digest=`` / ``dockur-arm-digest=``):
|
||||
# the image was rebuilt without a release tag bump. This is usually
|
||||
# an upstream security patch baked into the same tag; the issue
|
||||
# nudges a deliberate pin update.
|
||||
#
|
||||
# When you bump a value here, also refresh the matching pin in
|
||||
# ``src/winpodx/core/config.py`` (``DOCKUR_IMAGE_PIN`` /
|
||||
# ``DOCKUR_IMAGE_ARM_PIN``) if you intend to roll the new image
|
||||
# forward. The pin and these baselines are decoupled on purpose:
|
||||
# baselines describe what the upstream currently ships; the pin
|
||||
# describes what winpodx deliberately uses.
|
||||
#
|
||||
# Format: <key>=<value>
|
||||
dockur=v5.16
|
||||
dockur-digest=sha256:3633f055f31aadf76bb650b1ca86897ab45b76ad8eb2cf81e86389ace5eb45ac
|
||||
dockur-arm=v5.16
|
||||
dockur-arm-digest=sha256:ece93263254567c6cd4ce420b1fff793d2326f4535555bdf592f40ab173a7bed
|
||||
@@ -0,0 +1,128 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# agent-keepalive.ps1 -- idempotent "ensure the winpodx guest agent is
|
||||
# running" watchdog, driven by the WinpodxAgentKeepAlive scheduled task.
|
||||
#
|
||||
# Problem this solves
|
||||
# -------------------
|
||||
# The agent's only autostart is an HKCU\Run entry, which fires exactly
|
||||
# once per interactive logon. agent.ps1 runs as a child of the autologon
|
||||
# interactive session. When that session is torn down -- e.g. RDP
|
||||
# single-session enforcement kicks it when a FreeRDP connection arrives
|
||||
# while rdprrap multi-session is not (yet) active, or a TermService cycle
|
||||
# during rdprrap (re)activation -- the agent process dies with the
|
||||
# session and HKCU\Run does NOT re-fire. The agent then stays dead until
|
||||
# the pod reboots (re-running autologon -> HKCU\Run). Reproduced
|
||||
# repeatedly: /health times out on a pod that has been up for hours;
|
||||
# `pod restart` revives it.
|
||||
#
|
||||
# This script is the persistent watchdog HKCU\Run never was. The
|
||||
# WinpodxAgentKeepAlive scheduled task runs it AtLogOn and every 1 minute
|
||||
# indefinitely; each run is a cheap idempotent check:
|
||||
#
|
||||
# * If an agent.ps1 process is already running AND :8765 is listening,
|
||||
# no-op. We NEVER kill a healthy agent -- this is a starter, not a
|
||||
# respawner (agent-respawn.ps1 stays the kill+relaunch path used by
|
||||
# the apply chain / guest-sync when the agent.ps1 *source* changed).
|
||||
# * Otherwise, (re)launch agent.ps1 via the existing
|
||||
# hidden-launcher.vbs wrapper so there is no PowerShell/console flash
|
||||
# -- the exact same windowless launch HKCU\Run / install.bat use.
|
||||
#
|
||||
# Principal
|
||||
# ---------
|
||||
# This script is registered to run as the INTERACTIVE autologon user
|
||||
# (the same account the agent has always run as), NOT as SYSTEM / S4U.
|
||||
# Reasoning (see PR fix/agent-keepalive):
|
||||
# * The agent's /exec runs PowerShell that callers expect in the
|
||||
# user's context: app discovery enumerates the user's Start Menu /
|
||||
# per-user installed apps, and reverse-open registers per-user HKCU
|
||||
# handlers. A SYSTEM / session-0 principal would silently change
|
||||
# HKCU and the Start Menu view out from under those callers.
|
||||
# * Keeping the agent in the user context means the World-SID urlacl
|
||||
# reservation (sddl WD) and the World-readable C:\OEM\agent_token.txt
|
||||
# remain reachable exactly as before -- no security or auth change.
|
||||
# The cost: a user-context task only runs while an interactive session
|
||||
# exists, so it covers (1) a crashed-but-session-alive agent (back within
|
||||
# ~1 min via the repeating trigger) and (2) re-logon (AtLogOn trigger).
|
||||
# It does NOT by itself cover a session kick with no re-logon -- that
|
||||
# case is handled by making rdprrap activation idempotent so the kick
|
||||
# does not happen in the first place (see _apply_multi_session +
|
||||
# rdprrap-activate.ps1). The keep-alive's 1-minute repetition is also the
|
||||
# backstop that brings the agent back AFTER a TermService cycle settles.
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
$agentScript = 'C:\OEM\agent.ps1'
|
||||
$launcher = 'C:\Users\Public\winpodx\launchers\hidden-launcher.vbs'
|
||||
$port = 8765
|
||||
|
||||
# 1. Is an agent.ps1 process alive? Match the command line the same way
|
||||
# agent-respawn.ps1 does (classic powershell.exe + pwsh.exe), and
|
||||
# exclude this keep-alive script + the respawn helper so we never
|
||||
# treat our own process tree as "the agent".
|
||||
$agentRunning = $false
|
||||
try {
|
||||
$procs = @(
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like '*agent.ps1*' -and
|
||||
$_.CommandLine -notlike '*agent-keepalive*' -and
|
||||
$_.CommandLine -notlike '*agent-respawn*' -and
|
||||
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe')
|
||||
}
|
||||
)
|
||||
if ($procs.Count -gt 0) { $agentRunning = $true }
|
||||
} catch { }
|
||||
|
||||
# 2. Is the listener actually up? A process can be alive but wedged before
|
||||
# HttpListener.Start() (e.g. mid Wait-Token); treat "process present
|
||||
# but port not listening" as healthy enough to leave alone for THIS
|
||||
# pass -- killing it is agent-respawn's job, not ours. We only launch
|
||||
# when there is NO agent process at all, OR the port is dead with no
|
||||
# owning agent process. The conservative rule: launch only when no
|
||||
# agent.ps1 process is running.
|
||||
$portListening = $false
|
||||
try {
|
||||
$conn = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($conn) { $portListening = $true }
|
||||
} catch {
|
||||
# Get-NetTCPConnection missing on very old builds -- fall back to netstat.
|
||||
try {
|
||||
$ns = netstat -ano | Select-String -Pattern (":" + $port + "\s.*LISTENING")
|
||||
if ($ns) { $portListening = $true }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
# Healthy: an agent process exists AND the port is listening. No-op.
|
||||
if ($agentRunning -and $portListening) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# A wedged-but-alive agent (process present, port not yet up) is left for
|
||||
# its own bind-retry loop / agent-respawn; we don't double-launch on top
|
||||
# of a live process (that would race two HttpListener binds on :8765).
|
||||
if ($agentRunning) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# No agent process at all -> (re)launch via the windowless wrapper.
|
||||
if (Test-Path -LiteralPath $launcher) {
|
||||
Start-Process wscript.exe -ArgumentList @(
|
||||
$launcher,
|
||||
'powershell.exe',
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', $agentScript
|
||||
) | Out-Null
|
||||
} else {
|
||||
# Launcher missing (manual delete / pre-OEM-v14 pod that somehow lost
|
||||
# it). Direct hidden powershell flashes a ~50ms conhost, but a brief
|
||||
# flash beats a dead agent. The apply chain's vbs_launchers step
|
||||
# re-stages the wrapper, so this fallback is transient.
|
||||
Start-Process powershell.exe -WindowStyle Hidden -ArgumentList @(
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', $agentScript
|
||||
) | Out-Null
|
||||
}
|
||||
exit 0
|
||||
@@ -0,0 +1,74 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# agent-respawn.ps1 -- kill the running agent.ps1 process and spawn a fresh
|
||||
# one via the hidden-launcher.vbs wrapper.
|
||||
#
|
||||
# Background: HKCU\Run only fires once per user session, so the new wscript
|
||||
# wrapper installed by `_apply_vbs_launchers` doesn't take effect until the
|
||||
# user logs out and back in (or the pod restarts). That's a hard ask for an
|
||||
# apply-fixes / migrate step. This script lets the migration close the loop
|
||||
# itself: kill the old agent (started under the legacy `powershell.exe
|
||||
# -WindowStyle Hidden -File agent.ps1` invocation), wait for the listener
|
||||
# port to free, then start the new agent under the wscript wrapper.
|
||||
#
|
||||
# Spawned by the migration's last action with
|
||||
#
|
||||
# Start-Process wscript.exe -ArgumentList @(
|
||||
# 'C:\Users\Public\winpodx\launchers\hidden-launcher.vbs',
|
||||
# 'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass',
|
||||
# '-File', 'C:\Users\Public\winpodx\launchers\agent-respawn.ps1'
|
||||
# )
|
||||
#
|
||||
# so this script itself runs windowless under wscript+hidden-launcher.
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
# Give the parent /exec call ~3s to finish delivering its response to the
|
||||
# host. Killing the agent before its /exec reply lands would surface as a
|
||||
# spurious "channel failure" on `winpodx pod apply-fixes`.
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
$ourPid = $PID
|
||||
|
||||
# Find the running agent. Match on the cmdline mentioning agent.ps1 and
|
||||
# exclude this respawn script to avoid suicide. Both classic powershell.exe
|
||||
# and the newer pwsh.exe count.
|
||||
$victims = @(
|
||||
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.ProcessId -ne $ourPid -and
|
||||
$_.CommandLine -and
|
||||
$_.CommandLine -like '*agent.ps1*' -and
|
||||
$_.CommandLine -notlike '*agent-respawn*' -and
|
||||
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe')
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($v in $victims) {
|
||||
try { Stop-Process -Id $v.ProcessId -Force -ErrorAction SilentlyContinue } catch { }
|
||||
}
|
||||
|
||||
# Wait briefly for port 8765 to release. HttpListener doesn't always release
|
||||
# immediately -- give the kernel a beat.
|
||||
Start-Sleep -Milliseconds 800
|
||||
|
||||
$launcher = 'C:\Users\Public\winpodx\launchers\hidden-launcher.vbs'
|
||||
if (-not (Test-Path -LiteralPath $launcher)) {
|
||||
# No hidden-launcher.vbs available -> we cannot respawn cleanly. The
|
||||
# legacy `Start-Process powershell.exe -WindowStyle Hidden` fallback
|
||||
# used to live here, but it leaks a ~50ms conhost flash AND only
|
||||
# fires in scenarios where the wrapper file went missing -- which
|
||||
# post-OEM-v13 means a manual delete or filesystem corruption, not
|
||||
# a normal install. Better to fail loudly: HKCU\Run will refire
|
||||
# (via wscript+hidden-launcher.vbs) on the next user logon and
|
||||
# bring the agent back without the flash.
|
||||
exit 1
|
||||
}
|
||||
# wscript.exe is GUI-subsystem so the spawned PowerShell child starts
|
||||
# windowless from the very first instant. No flash.
|
||||
Start-Process wscript.exe -ArgumentList @(
|
||||
$launcher,
|
||||
'powershell.exe',
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', 'C:\OEM\agent.ps1'
|
||||
) | Out-Null
|
||||
@@ -0,0 +1,357 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# =====================================================================
|
||||
# winpodx guest agent (Phase 2) -- /health + bearer-auth /exec
|
||||
# =====================================================================
|
||||
#
|
||||
# Purpose
|
||||
# -------
|
||||
# HTTP server inside the Windows guest. Phase 1 shipped the bare
|
||||
# /health readiness probe. Phase 2 adds bearer-auth and a POST /exec
|
||||
# endpoint that runs base64-encoded PowerShell snippets -- replacing
|
||||
# FreeRDP RemoteApp PowerShell calls for non-sensitive host -> guest
|
||||
# traffic so the host can stop emitting visible PS-window flashes for
|
||||
# every registry tweak / discovery roundtrip.
|
||||
#
|
||||
# Invariants
|
||||
# ----------
|
||||
# * Bind: ``http://+:8765/`` (all interfaces inside the Windows VM).
|
||||
# QEMU's user-mode NAT forwards from the container to the VM's
|
||||
# slirp interface (10.0.2.15:8765, NOT 127.0.0.1:8765 -- slirp
|
||||
# hostfwd targets the VM's main NIC, not loopback). Binding only
|
||||
# to 127.0.0.1 inside Windows would mean slirp's forwarded packets
|
||||
# hit a closed port -- kernalix7 saw "Connection reset by peer" on
|
||||
# 2026-04-30 from exactly this. Binding to ``+`` covers all
|
||||
# interfaces. The agent is still externally unreachable: compose's
|
||||
# ``127.0.0.1:8765:8765/tcp`` mapping is loopback-only on the host,
|
||||
# and QEMU slirp is private to the container.
|
||||
# * /health takes NO authentication. It is the readiness signal; the
|
||||
# host may probe it before the token has even been delivered.
|
||||
# * Every other endpoint requires `Authorization: Bearer <token>`.
|
||||
# Mismatch returns 401 with JSON {"error":"unauthorized"}. The
|
||||
# compare is constant-time so timing leaks can't recover the token.
|
||||
# * Wait-Token loop polls C:\OEM\agent_token.txt with bounded backoff,
|
||||
# never throws. Anti-goal #6 in AGENT_V2_DESIGN: throwing kills the
|
||||
# process and HKCU\Run does not auto-restart.
|
||||
# * The token is never logged or echoed back. /exec script content
|
||||
# lands in C:\OEM\agent.log only as a SHA256 hash, never the raw
|
||||
# payload -- sensitive payloads (registry keys, credentials touched
|
||||
# by self-heal) must not survive in the log.
|
||||
# =====================================================================
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$script:AgentVersion = '0.2.2-rev4'
|
||||
$script:StartedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
$script:OemDir = 'C:\OEM'
|
||||
$script:TokenPath = 'C:\OEM\agent_token.txt'
|
||||
$script:LogPath = 'C:\OEM\agent.log'
|
||||
$script:RunsDir = 'C:\OEM\agent-runs'
|
||||
$script:Prefix = 'http://+:8765/'
|
||||
$script:ExecDefaultTimeoutSec = 60
|
||||
$script:ExecMaxTimeoutSec = 300
|
||||
|
||||
if (-not (Test-Path $script:RunsDir)) {
|
||||
try { New-Item -ItemType Directory -Path $script:RunsDir -Force | Out-Null } catch { }
|
||||
}
|
||||
|
||||
function Read-Token {
|
||||
if (-not (Test-Path $script:TokenPath)) { return $null }
|
||||
try {
|
||||
$t = (Get-Content -Path $script:TokenPath -TotalCount 1 -ErrorAction Stop).Trim()
|
||||
} catch { return $null }
|
||||
if (-not $t) { return $null }
|
||||
return $t
|
||||
}
|
||||
|
||||
# Poll for the token file with backoff capped at 30s. The token is
|
||||
# delivered via the OEM bind mount (config/oem/agent_token.txt staged
|
||||
# at setup time -> /oem in the container -> C:\OEM\ inside Windows by
|
||||
# dockur's first-boot copy), but we cannot assume any particular order
|
||||
# between HKCU\Run firing and the OEM stage completing. We never throw
|
||||
# here: throwing would kill the process and HKCU\Run does not respawn.
|
||||
function Wait-Token {
|
||||
$delay = 2
|
||||
while ($true) {
|
||||
$t = Read-Token
|
||||
if ($t) { return $t }
|
||||
Start-Sleep -Seconds $delay
|
||||
if ($delay -lt 30) { $delay = [Math]::Min(30, $delay * 2) }
|
||||
}
|
||||
}
|
||||
|
||||
# Constant-time string compare. Both inputs are ASCII hex on the happy
|
||||
# path; we still walk the full length to avoid leaking string length
|
||||
# via early-return timing. Returns $false on null / length mismatch
|
||||
# without short-circuiting on content.
|
||||
function Compare-Constant([string]$a, [string]$b) {
|
||||
if ($null -eq $a -or $null -eq $b) { return $false }
|
||||
if ($a.Length -ne $b.Length) { return $false }
|
||||
$diff = 0
|
||||
for ($i = 0; $i -lt $a.Length; $i++) {
|
||||
$diff = $diff -bor ([int][char]$a[$i] -bxor [int][char]$b[$i])
|
||||
}
|
||||
return ($diff -eq 0)
|
||||
}
|
||||
|
||||
# Read the Authorization header off an HttpListener request, strip the
|
||||
# "Bearer " prefix, and constant-time compare against $script:Token.
|
||||
# Returns $true / $false -- never throws, never logs the supplied value.
|
||||
function Test-Auth($req) {
|
||||
$h = $req.Headers['Authorization']
|
||||
if (-not $h) { return $false }
|
||||
if (-not $h.StartsWith('Bearer ')) { return $false }
|
||||
return (Compare-Constant -a $h.Substring(7) -b $script:Token)
|
||||
}
|
||||
|
||||
function Read-Body($req) {
|
||||
if (-not $req.HasEntityBody) { return '' }
|
||||
$sr = [IO.StreamReader]::new($req.InputStream, $req.ContentEncoding)
|
||||
try { return $sr.ReadToEnd() } finally { $sr.Dispose() }
|
||||
}
|
||||
|
||||
# SHA256 hex of arbitrary bytes -- used to log a fingerprint of /exec
|
||||
# script payloads without spilling the script itself into agent.log.
|
||||
function Get-BytesHash([byte[]]$bytes) {
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$hash = $sha.ComputeHash($bytes)
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
return ([BitConverter]::ToString($hash) -replace '-','').ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Write-Log([string]$method, [string]$path, [string]$auth, [int]$code, [int]$ms, [string]$extra) {
|
||||
$ts = (Get-Date).ToUniversalTime().ToString('o')
|
||||
$line = "$ts $method $path auth=$auth code=$code ${ms}ms"
|
||||
if ($extra) { $line = "$line $extra" }
|
||||
try { Add-Content -Path $script:LogPath -Value $line -ErrorAction SilentlyContinue } catch { }
|
||||
}
|
||||
|
||||
function Send-Json($resp, [int]$code, $obj) {
|
||||
$resp.StatusCode = $code
|
||||
$resp.ContentType = 'application/json; charset=utf-8'
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -Compress -Depth 6 $obj))
|
||||
$resp.ContentLength64 = $bytes.Length
|
||||
$resp.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
$resp.OutputStream.Close()
|
||||
}
|
||||
|
||||
# Run a base64-encoded PowerShell snippet via Start-Process, with a
|
||||
# server-side timeout enforced by WaitForExit. Returns a hashtable with
|
||||
# rc / stdout / stderr / hash, or { error = '...' } on spawn failure.
|
||||
# We deliberately use -File against a temp .ps1 (not -EncodedCommand)
|
||||
# because Start-Process does not support -EncodedCommand cleanly with
|
||||
# stdout/stderr redirection; the temp file is cleaned up after the run.
|
||||
function Invoke-ExecScript([string]$scriptB64, [int]$timeoutSec) {
|
||||
$tempBase = Join-Path $script:RunsDir ([Guid]::NewGuid().ToString('N'))
|
||||
$tempFile = "$tempBase.ps1"
|
||||
try {
|
||||
try {
|
||||
$bytes = [Convert]::FromBase64String($scriptB64)
|
||||
} catch {
|
||||
return @{ error = 'bad_base64'; detail = $_.Exception.Message }
|
||||
}
|
||||
$hash = Get-BytesHash $bytes
|
||||
try {
|
||||
[IO.File]::WriteAllBytes($tempFile, $bytes)
|
||||
} catch {
|
||||
return @{ error = 'temp_write_failed'; detail = $_.Exception.Message; hash = $hash }
|
||||
}
|
||||
# Spawn the child via [Diagnostics.Process] + ProcessStartInfo with
|
||||
# CreateNoWindow=$true. Start-Process -NoNewWindow re-opens a console
|
||||
# for the child when the parent (agent.ps1) was launched with
|
||||
# -WindowStyle Hidden -- kernalix7 saw flashing PS windows on every
|
||||
# /exec call on 2026-04-30. CreateNoWindow + UseShellExecute=$false
|
||||
# is the canonical "run windowless and capture stdio" combination.
|
||||
$proc = $null
|
||||
try {
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = 'powershell.exe'
|
||||
$psi.Arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $tempFile + '"'
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$proc = New-Object System.Diagnostics.Process
|
||||
$proc.StartInfo = $psi
|
||||
[void]$proc.Start()
|
||||
# Drain stdio asynchronously so a child writing >64KB of output
|
||||
# doesn't deadlock against the OS pipe buffer while we're still
|
||||
# blocked in WaitForExit (Microsoft's documented gotcha).
|
||||
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $proc.StandardError.ReadToEndAsync()
|
||||
} catch {
|
||||
return @{ error = 'spawn_failed'; detail = $_.Exception.Message; hash = $hash }
|
||||
}
|
||||
$rc = 0
|
||||
$timedOut = $false
|
||||
if (-not $proc.WaitForExit([int]([Math]::Min($timeoutSec, $script:ExecMaxTimeoutSec)) * 1000)) {
|
||||
$timedOut = $true
|
||||
try { $proc.Kill() } catch { }
|
||||
try { [void]$proc.WaitForExit(2000) } catch { }
|
||||
$rc = 124
|
||||
} else {
|
||||
# Start-Process -PassThru can leave ExitCode as $null even after
|
||||
# WaitForExit() returns true (Windows quirk: handle isn't kept open
|
||||
# for fast-exiting children unless the StartInfo enables it). The
|
||||
# process did terminate cleanly, so treat null as 0 -- and never
|
||||
# emit a non-int rc, since the host AgentClient parses it as int.
|
||||
$exitCode = $proc.ExitCode
|
||||
if ($null -eq $exitCode) { $rc = 0 } else { $rc = [int]$exitCode }
|
||||
}
|
||||
$stdoutText = ''
|
||||
$stderrText = ''
|
||||
# Pull from the async ReadToEndAsync tasks queued at spawn time.
|
||||
# On timeout the streams may already be closed by Kill(); guard each.
|
||||
try { if ($stdoutTask) { $stdoutText = $stdoutTask.GetAwaiter().GetResult() } } catch { }
|
||||
try { if ($stderrTask) { $stderrText = $stderrTask.GetAwaiter().GetResult() } } catch { }
|
||||
if ($timedOut -and -not $stderrText) { $stderrText = 'timeout' }
|
||||
return @{
|
||||
rc = $rc
|
||||
stdout = $stdoutText
|
||||
stderr = $stderrText
|
||||
hash = $hash
|
||||
timedOut = $timedOut
|
||||
}
|
||||
} finally {
|
||||
if ($tempFile -and (Test-Path $tempFile)) {
|
||||
try { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Wait for the token before binding. /health is no-auth, but every
|
||||
# other endpoint compares against $script:Token, so binding before the
|
||||
# token lands would race the first auth check on a real /exec call.
|
||||
$script:Token = Wait-Token
|
||||
|
||||
# Bind with a bounded retry loop. install.bat (#269 fix) reserves the
|
||||
# urlacl for the World SID (S-1-1-0 / sddl WD) just before spawning the
|
||||
# agent, but the agent's first spawn can race that reservation landing
|
||||
# in HTTP.sys, and an autologon-retry session can re-spawn before the
|
||||
# OS finished applying the ACL. A few short retries absorb that race
|
||||
# without masking a genuine persistent conflict (which still ends in a
|
||||
# FATAL + the full urlacl state dumped to agent.log so the real owner
|
||||
# of the conflicting reservation is visible).
|
||||
$listener = [System.Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add($script:Prefix)
|
||||
$bindAttempts = 5
|
||||
$bound = $false
|
||||
for ($i = 1; $i -le $bindAttempts; $i++) {
|
||||
try {
|
||||
$listener.Start()
|
||||
$bound = $true
|
||||
break
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
try {
|
||||
Add-Content -Path $script:LogPath -Value (
|
||||
"$((Get-Date).ToUniversalTime().ToString('o')) WARN " +
|
||||
"HttpListener.Start() attempt $i/$bindAttempts failed: $msg"
|
||||
) -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
if ($i -lt $bindAttempts) {
|
||||
Start-Sleep -Seconds 3
|
||||
# Re-create the listener -- a failed Start() can leave the
|
||||
# instance in a state that rejects a second Start().
|
||||
$listener = [System.Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add($script:Prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $bound) {
|
||||
# Persistent failure. Dump the actual urlacl reservation state so the
|
||||
# next debugging round sees WHICH SID owns the conflicting prefix --
|
||||
# the agent runs as a non-admin User and cannot re-register the ACL
|
||||
# itself (needs admin), so the fix has to land in install.bat.
|
||||
$aclState = ''
|
||||
try {
|
||||
$aclState = (& netsh http show urlacl url=$($script:Prefix) 2>&1 | Out-String).Trim()
|
||||
} catch { }
|
||||
try {
|
||||
Add-Content -Path $script:LogPath -Value (
|
||||
"$((Get-Date).ToUniversalTime().ToString('o')) FATAL " +
|
||||
"HttpListener.Start() failed after $bindAttempts attempts on $($script:Prefix)." +
|
||||
" hint: install.bat should have reserved this via " +
|
||||
"'netsh http add urlacl url=$($script:Prefix) sddl=D:(A;;GX;;;WD)' as admin." +
|
||||
" current urlacl state: $aclState"
|
||||
) -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
throw "HttpListener.Start() failed after $bindAttempts attempts on $($script:Prefix)"
|
||||
}
|
||||
|
||||
try {
|
||||
while ($listener.IsListening) {
|
||||
$ctx = $null
|
||||
try { $ctx = $listener.GetContext() } catch { continue }
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
$req = $ctx.Request
|
||||
$resp = $ctx.Response
|
||||
$method = $req.HttpMethod
|
||||
$path = $req.Url.AbsolutePath
|
||||
$code = 500
|
||||
$authTag = 'none'
|
||||
$extraLog = ''
|
||||
try {
|
||||
if ($method -eq 'GET' -and $path -eq '/health') {
|
||||
$payload = @{
|
||||
version = $script:AgentVersion
|
||||
ok = $true
|
||||
started_at = $script:StartedAt
|
||||
}
|
||||
Send-Json $resp 200 $payload
|
||||
$code = 200
|
||||
} elseif (-not (Test-Auth $req)) {
|
||||
Send-Json $resp 401 @{ error = 'unauthorized' }
|
||||
$code = 401
|
||||
$authTag = 'fail'
|
||||
} elseif ($method -eq 'POST' -and $path -eq '/exec') {
|
||||
$authTag = 'ok'
|
||||
$body = Read-Body $req
|
||||
$parsed = $null
|
||||
try { $parsed = $body | ConvertFrom-Json -ErrorAction Stop } catch { $parsed = $null }
|
||||
if ($null -eq $parsed -or -not $parsed.PSObject.Properties['script']) {
|
||||
Send-Json $resp 400 @{ error = 'bad_request'; detail = 'missing script field' }
|
||||
$code = 400
|
||||
} else {
|
||||
$timeout = $script:ExecDefaultTimeoutSec
|
||||
if ($parsed.PSObject.Properties['timeout_sec']) {
|
||||
try { $timeout = [int]$parsed.timeout_sec } catch { $timeout = $script:ExecDefaultTimeoutSec }
|
||||
if ($timeout -le 0) { $timeout = $script:ExecDefaultTimeoutSec }
|
||||
if ($timeout -gt $script:ExecMaxTimeoutSec) { $timeout = $script:ExecMaxTimeoutSec }
|
||||
}
|
||||
$result = Invoke-ExecScript -scriptB64 ([string]$parsed.script) -timeoutSec $timeout
|
||||
if ($result.ContainsKey('error')) {
|
||||
Send-Json $resp 500 @{ error = 'exec_failed'; detail = $result.error }
|
||||
$code = 500
|
||||
if ($result.ContainsKey('hash')) { $extraLog = "hash=$($result.hash)" }
|
||||
} else {
|
||||
Send-Json $resp 200 @{
|
||||
rc = $result.rc
|
||||
stdout = $result.stdout
|
||||
stderr = $result.stderr
|
||||
}
|
||||
$code = 200
|
||||
$extraLog = "hash=$($result.hash) rc=$($result.rc) timeout=$($result.timedOut)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$authTag = 'ok'
|
||||
Send-Json $resp 404 @{ error = 'not_found' }
|
||||
$code = 404
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
Send-Json $resp 500 @{ error = 'internal_error' }
|
||||
$code = 500
|
||||
} catch { }
|
||||
}
|
||||
$sw.Stop()
|
||||
Write-Log $method $path $authTag $code ([int]$sw.ElapsedMilliseconds) $extraLog
|
||||
}
|
||||
} finally {
|
||||
try { $listener.Stop() } catch { }
|
||||
try { $listener.Close() } catch { }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# winpodx bare-metal disguise (#246): prune unused virtio driver service keys.
|
||||
#
|
||||
# The virtio-win bundle installs viostor / vioscsi / BalloonService even when
|
||||
# the matching device is absent (a SATA system disk, no memory balloon). Bare
|
||||
# existence of these HKLM\SYSTEM\...\Services\* keys is what al-khaser's KVM
|
||||
# section flags as a VM tell. Remove ONLY the keys whose device is not present,
|
||||
# so a guest that actually boots from virtio storage (or uses a balloon) is
|
||||
# never left without its driver. Run once, post-install, by config/oem/install.bat.
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
# virtio storage controllers present? blk = DEV_1001/1042, scsi = DEV_1004/1048.
|
||||
$virtioStorage = Get-PnpDevice -PresentOnly | Where-Object {
|
||||
$_.InstanceId -match 'VEN_1AF4&DEV_(1001|1042|1004|1048)'
|
||||
}
|
||||
if (-not $virtioStorage) {
|
||||
Remove-Item 'HKLM:\SYSTEM\CurrentControlSet\Services\viostor' -Recurse -Force
|
||||
Remove-Item 'HKLM:\SYSTEM\CurrentControlSet\Services\vioscsi' -Recurse -Force
|
||||
}
|
||||
|
||||
# virtio balloon present? DEV_1002 (legacy) / DEV_1045 (modern).
|
||||
$virtioBalloon = Get-PnpDevice -PresentOnly | Where-Object {
|
||||
$_.InstanceId -match 'VEN_1AF4&DEV_(1002|1045)'
|
||||
}
|
||||
if (-not $virtioBalloon) {
|
||||
Remove-Item 'HKLM:\SYSTEM\CurrentControlSet\Services\BalloonService' -Recurse -Force
|
||||
}
|
||||
|
||||
# NOTE: an earlier revision also disabled the Hyper-V integration drivers
|
||||
# (vmbus / VMBusHID / vmgid / hyperkbd / HyperVideo / IndirectKmd) to clear
|
||||
# al-khaser's "Hyper-V driver/global objects" checks. That is REMOVED: IndirectKmd
|
||||
# is the Indirect Display Driver the RDP session uses for its framebuffer, so
|
||||
# disabling it left the guest with a black screen and tore the session down.
|
||||
# Those Hyper-V checks are a weak signal (Windows ships these components even on
|
||||
# bare metal) and are not worth breaking the remote display for.
|
||||
@@ -0,0 +1,34 @@
|
||||
' SPDX-License-Identifier: MIT
|
||||
' hidden-launcher.vbs -- universal "run any command, never show a window"
|
||||
'
|
||||
' wscript.exe runs as a GUI-subsystem process (no console of its own), and
|
||||
' WshShell.Run with intWindowStyle=0 (SW_HIDE) propagates STARTF_USESHOWWINDOW
|
||||
' to CreateProcess, so the spawned child also starts with its window hidden
|
||||
' before any flash is possible.
|
||||
'
|
||||
' Used by agent autostart and any other path that would otherwise flash a
|
||||
' PowerShell or cmd console at the user. Args are forwarded verbatim to
|
||||
' WshShell.Run after quoting.
|
||||
'
|
||||
' Usage:
|
||||
' wscript.exe hidden-launcher.vbs <program> [args...]
|
||||
' Example (agent autostart from HKCU\Run):
|
||||
' wscript.exe C:\OEM\hidden-launcher.vbs powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\OEM\agent.ps1
|
||||
|
||||
Option Explicit
|
||||
|
||||
If WScript.Arguments.Count = 0 Then
|
||||
WScript.Quit 1
|
||||
End If
|
||||
|
||||
Dim shell, cmd, i
|
||||
Set shell = CreateObject("WScript.Shell")
|
||||
|
||||
cmd = ""
|
||||
For i = 0 To WScript.Arguments.Count - 1
|
||||
If i > 0 Then cmd = cmd & " "
|
||||
cmd = cmd & """" & WScript.Arguments(i) & """"
|
||||
Next
|
||||
|
||||
' 0 = SW_HIDE; False = don't wait, return immediately so wscript itself exits.
|
||||
shell.Run cmd, 0, False
|
||||
@@ -0,0 +1,762 @@
|
||||
@echo off
|
||||
REM First-boot OEM setup for winpodx Windows guest. Runs once during dockur's unattended install. Every action must stay idempotent - there is no guest-side re-run channel in 0.1.6 (push/exec bridge planned for a later release).
|
||||
|
||||
set WINPODX_OEM_VERSION=28
|
||||
|
||||
echo [WinPodX] Starting post-install configuration (version %WINPODX_OEM_VERSION%)...
|
||||
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM Windows Defender exclusions - ABSOLUTE FIRST STEP.
|
||||
REM
|
||||
REM Background: kernalix7's fresh installs 2026-05-02 through 2026-05-04
|
||||
REM consistently died at line ~248 (PS Expand-Archive of the rdprrap zip
|
||||
REM under C:\OEM\). install.log ended at "--- extract attempt 1" with no
|
||||
REM follow-up; setup.log was never created; agent.ps1 never copied; agent
|
||||
REM never came up. 3 disconnected User sessions accumulated in qwinsta on
|
||||
REM every attempt. v0.3.0 (OEM v12) on the same hardware worked.
|
||||
REM
|
||||
REM Diff against v0.3.0 install.bat: the install.bat code itself is
|
||||
REM essentially unchanged in the pre-extract section. The rdprrap zip
|
||||
REM blob hash is identical. What changed: (1) the OEM bundle grew from 6
|
||||
REM files to 13+ (added rdprrap-activate.ps1, hidden-launcher.vbs,
|
||||
REM launch_uwp.ps1/vbs, agent-respawn.ps1, agent/agent.ps1); (2) the
|
||||
REM dockur image was pinned to a specific Windows 11 digest in PR #83
|
||||
REM (v0.3.1), and that newer Windows build ships a stricter Defender
|
||||
REM real-time policy. The combination - more PS/VBS files staged in
|
||||
REM C:\OEM\ alongside rdprrap-installer.exe (which patches termsrv.dll
|
||||
REM and is naturally classifier-flagged as PUP) - triggers Defender
|
||||
REM real-time scanning of C:\OEM\ during install.bat's first PS call.
|
||||
REM Defender locks one of the files; PS Expand-Archive blocks waiting on
|
||||
REM the lock; install.bat blocks waiting on PS; whole thing deadlocks
|
||||
REM until something kicks the autologon session.
|
||||
REM
|
||||
REM Excluding C:\OEM and C:\winpodx from real-time scanning at the very
|
||||
REM start of install.bat means none of our staged files ever get
|
||||
REM scanned. This is safe because install.bat is the only thing writing
|
||||
REM there, the contents are SHA-pinned to the bundle, and the user
|
||||
REM workload runs from C:\Users\... (still scanned). It also keeps
|
||||
REM rdprrap-installer.exe itself out of Defender's process list.
|
||||
REM
|
||||
REM Add-MpPreference is idempotent - re-running install.bat (e.g., on
|
||||
REM container recreate) just re-asserts the exclusion silently.
|
||||
REM C:\Users\Public\winpodx is where register-apps.ps1 stages the
|
||||
REM reverse-open shim + its per-slug .exe copies (#425). That tiny,
|
||||
REM stripped, unsigned Rust binary trips Defender's ML heuristic
|
||||
REM (Trojan:Win32/Rafvartar!rfn -- a false positive), so it gets
|
||||
REM quarantined and reverse-open silently breaks. Exclude the path (and
|
||||
REM the shim process) here, first-step, so the exclusion is in place
|
||||
REM before per-user logon stages the files. Add-MpPreference accepts a
|
||||
REM not-yet-existent path, so registering it early is fine.
|
||||
echo [WinPodX] Adding Windows Defender exclusions for C:\OEM, C:\winpodx, C:\Users\Public\winpodx, and the winpodx helper processes...
|
||||
powershell -NoProfile -Command "try { Add-MpPreference -ExclusionPath 'C:\OEM','C:\winpodx','C:\Users\Public\winpodx' -ErrorAction Stop; Add-MpPreference -ExclusionProcess 'rdprrap-installer.exe','winpodx-reverse-open-shim.exe' -ErrorAction Stop } catch { Write-Output ('defender-exclusion: ' + $_.Exception.Message) }" >nul 2>&1
|
||||
|
||||
echo [WinPodX] Setting DNS...
|
||||
netsh interface ip set dns "Ethernet" static 1.1.1.1
|
||||
netsh interface ip add dns "Ethernet" 1.0.0.1 index=2
|
||||
|
||||
echo [WinPodX] Enabling Remote Desktop...
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server" /v fDenyTSConnections /t REG_DWORD /d 0 /f
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server" /v fSingleSessionPerUser /t REG_DWORD /d 0 /f
|
||||
|
||||
REM NLA off for automated FreeRDP; SecurityLayer=2 keeps TLS on the RDP channel.
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v UserAuthentication /t REG_DWORD /d 0 /f
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v SecurityLayer /t REG_DWORD /d 2 /f
|
||||
|
||||
echo [WinPodX] Enabling RemoteApp...
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Terminal Server\TSAppAllowList" /v fDisabledAllowList /t REG_DWORD /d 1 /f
|
||||
|
||||
REM Without fInheritInitialProgram, Windows ignores /shell: and /app: from the RDP client.
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" /v fInheritInitialProgram /t REG_DWORD /d 1 /f
|
||||
REM v0.2.1: cap bumped 10 -> 50. cfg.pod.max_sessions still controls the
|
||||
REM actual desired count via _apply_max_sessions; this is just the
|
||||
REM ceiling so the cfg value isn't silently clamped at OEM time.
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v MaxInstanceCount /t REG_DWORD /d 50 /f
|
||||
|
||||
REM Bug B (v0.1.9 / OEM v7): host suspend / long idle commonly leaves Windows
|
||||
REM with TermService stalled or the virtual NIC in power-save, breaking RDP
|
||||
REM while VNC keeps working. Two preventive measures so the host-side
|
||||
REM recover_rdp_if_needed() helper has less to do.
|
||||
echo [WinPodX] Disabling NIC power-save...
|
||||
powershell -NoProfile -Command "Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object {$_.Status -ne 'Disabled'} | Set-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false -ErrorAction SilentlyContinue" >nul 2>&1
|
||||
|
||||
echo [WinPodX] Configuring TermService recovery actions...
|
||||
REM 3 attempts at 5s spacing, 24h reset window - Windows itself recovers
|
||||
REM TermService crashes without needing host intervention.
|
||||
sc.exe failure TermService reset= 86400 actions= restart/5000/restart/5000/restart/5000 >nul 2>&1
|
||||
|
||||
REM v0.1.9.1: RDP session timeout + keep-alive. v0.2.1 adjusts the
|
||||
REM disconnection-time semantics:
|
||||
REM * MaxIdleTime 0 = no idle timeout (active sessions never auto-disconnect)
|
||||
REM * MaxConnectionTime 0 = no max session duration
|
||||
REM * MaxDisconnectionTime 30000 ms = 30 sec - disconnected sessions
|
||||
REM auto-LOGOFF after 30 s. Previously this was 0 ("never logoff"),
|
||||
REM which left zombie disconnected sessions accumulating every time
|
||||
REM the user closed a FreeRDP window. The next launch then triggered
|
||||
REM "Select a session to reconnect to" dialog because Windows saw
|
||||
REM the user had N old disconnected sessions. rdprrap allows
|
||||
REM concurrent sessions but doesn't suppress that prompt - only
|
||||
REM auto-logoff does.
|
||||
REM Both the machine policy and the RDP-Tcp WinStation keys are set;
|
||||
REM whichever Windows consults first finds the saner default.
|
||||
echo [WinPodX] Disabling RDP session timeouts + enabling keep-alive...
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" /v MaxIdleTime /t REG_DWORD /d 0 /f >nul 2>&1
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" /v MaxDisconnectionTime /t REG_DWORD /d 30000 /f >nul 2>&1
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" /v MaxConnectionTime /t REG_DWORD /d 0 /f >nul 2>&1
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" /v KeepAliveEnable /t REG_DWORD /d 1 /f >nul 2>&1
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" /v KeepAliveInterval /t REG_DWORD /d 1 /f >nul 2>&1
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v MaxIdleTime /t REG_DWORD /d 0 /f >nul 2>&1
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v MaxDisconnectionTime /t REG_DWORD /d 30000 /f >nul 2>&1
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v MaxConnectionTime /t REG_DWORD /d 0 /f >nul 2>&1
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" /v KeepAliveTimeout /t REG_DWORD /d 1 /f >nul 2>&1
|
||||
|
||||
echo [WinPodX] Configuring firewall...
|
||||
REM Delete-then-add keeps the rule idempotent; plain add creates duplicates on re-run.
|
||||
netsh advfirewall firewall set rule group="Remote Desktop" new enable=yes 2>nul
|
||||
netsh advfirewall firewall delete rule name="RDP TCP" >nul 2>&1
|
||||
netsh advfirewall firewall delete rule name="RDP UDP" >nul 2>&1
|
||||
netsh advfirewall firewall add rule name="RDP TCP" dir=in action=allow protocol=tcp localport=3389 2>nul
|
||||
netsh advfirewall firewall add rule name="RDP UDP" dir=in action=allow protocol=udp localport=3389 2>nul
|
||||
|
||||
REM Allow inbound on the winpodx agent port. QEMU's user-mode NAT forwards
|
||||
REM container:8765 -> Windows VM 10.0.2.15:8765, which Windows Firewall
|
||||
REM blocks by default - kernalix7 saw curl timeout from the host on
|
||||
REM 2026-04-30 even with agent.ps1 bound to 0.0.0.0:8765 because the SYN
|
||||
REM never made it past the firewall. RDP got auto-allowed via the
|
||||
REM "Remote Desktop" group rule above; 8765 needs an explicit rule.
|
||||
netsh advfirewall firewall delete rule name="winpodx-agent" >nul 2>&1
|
||||
netsh advfirewall firewall add rule name="winpodx-agent" dir=in action=allow protocol=tcp localport=8765 2>nul
|
||||
|
||||
echo [WinPodX] Optimizing for RDP...
|
||||
reg add "HKCU\Control Panel\Desktop" /v DragFullWindows /t REG_SZ /d 0 /f
|
||||
reg add "HKCU\Control Panel\Desktop" /v MenuShowDelay /t REG_SZ /d 0 /f
|
||||
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects" /v VisualFXSetting /t REG_DWORD /d 2 /f
|
||||
|
||||
REM Pin Windows build so termsrv.dll stays stable; feature/build upgrades come via winpodx releases only.
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v NoAutoRebootWithLoggedOnUsers /t REG_DWORD /d 1 /f
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /v TargetReleaseVersion /t REG_DWORD /d 1 /f
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /v ProductVersion /t REG_SZ /d "Windows 11" /f
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /v TargetReleaseVersionInfo /t REG_SZ /d "25H2" /f
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /v DeferFeatureUpdates /t REG_DWORD /d 1 /f
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" /v DeferFeatureUpdatesPeriodInDays /t REG_DWORD /d 365 /f
|
||||
|
||||
REM Timezone is handled upstream now: winpodx sets the dockur TZ env var
|
||||
REM in compose.yaml from cfg.pod.timezone (or host autodetect), and
|
||||
REM dockur writes the <TimeZone> element into the Sysprep unattend.xml
|
||||
REM on first boot. No tzutil call needed here -- Windows reads the
|
||||
REM Sysprep value during OOBE. Leaving this block as a no-op marker so
|
||||
REM the OEM script structure stays predictable across releases.
|
||||
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Search" /v AllowCortana /t REG_DWORD /d 0 /f
|
||||
|
||||
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection" /v AllowTelemetry /t REG_DWORD /d 0 /f
|
||||
|
||||
sc config WSearch start= disabled
|
||||
net stop WSearch 2>nul
|
||||
|
||||
sc config SysMain start= disabled
|
||||
net stop SysMain 2>nul
|
||||
|
||||
powercfg /h off
|
||||
|
||||
REM Print Spooler stays enabled for RDP printer redirection.
|
||||
|
||||
sc config WerSvc start= disabled
|
||||
net stop WerSvc 2>nul
|
||||
|
||||
sc config DiagTrack start= disabled
|
||||
net stop DiagTrack 2>nul
|
||||
|
||||
powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
|
||||
|
||||
REM Force every idle timeout to "never" so the RDP service / virtio NIC
|
||||
REM don't get suspended out from under the host while the user is on a
|
||||
REM coffee break. Without this the host pod_status() probe sees RDP
|
||||
REM unreachable on a healthy container and shows "starting" forever
|
||||
REM (#TBD, observed by kernalix7).
|
||||
powercfg /change standby-timeout-ac 0
|
||||
powercfg /change standby-timeout-dc 0
|
||||
powercfg /change hibernate-timeout-ac 0
|
||||
powercfg /change hibernate-timeout-dc 0
|
||||
powercfg /change monitor-timeout-ac 0
|
||||
powercfg /change monitor-timeout-dc 0
|
||||
powercfg /change disk-timeout-ac 0
|
||||
powercfg /change disk-timeout-dc 0
|
||||
REM Modern Standby (S0 low-power idle) -- even with the timeouts above
|
||||
REM it can drop the NIC. The platform role registry key forces
|
||||
REM Desktop class so Modern Standby logic is bypassed.
|
||||
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Power" /v PlatformAoAcOverride /t REG_DWORD /d 0 /f
|
||||
REM Belt-and-braces: disable hardware-initiated wake / sleep transitions
|
||||
REM via the AC profile too.
|
||||
powercfg /setdcvalueindex SCHEME_CURRENT SUB_SLEEP STANDBYIDLE 0 2>nul
|
||||
powercfg /setacvalueindex SCHEME_CURRENT SUB_SLEEP STANDBYIDLE 0 2>nul
|
||||
powercfg /setactive SCHEME_CURRENT 2>nul
|
||||
|
||||
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Serialize" /v StartupDelayInMSec /t REG_DWORD /d 0 /f
|
||||
|
||||
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\BackgroundAccessApplications" /v GlobalUserDisabled /t REG_DWORD /d 1 /f
|
||||
|
||||
echo [WinPodX] Home folder is available at \\tsclient\home via RDP drive redirection
|
||||
|
||||
REM dockur's base image ships a "Shared" desktop item pointing to \\host.lan\Data (SMB) that we don't use; replace with Home/USB shortcuts to the RDP redirections.
|
||||
echo [WinPodX] Creating desktop shortcuts to tsclient shares...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$d=[Environment]::GetFolderPath('Desktop'); foreach($n in 'Shared','Shared.lnk'){ $p=Join-Path $d $n; if(Test-Path -LiteralPath $p){ Remove-Item -Force -Recurse -LiteralPath $p -ErrorAction SilentlyContinue } }; $s=New-Object -ComObject WScript.Shell; foreach($x in @(@('Home','\\tsclient\home'), @('USB','\\tsclient\media'))){ $l=$s.CreateShortcut((Join-Path $d ($x[0]+'.lnk'))); $l.TargetPath=$x[1]; $l.Save() }"
|
||||
|
||||
REM C:\winpodx is the persistent run dir for runtime-delivered helpers
|
||||
REM (e.g. agent-keepalive.ps1, copied here by the keep-alive task). USB
|
||||
REM media is reachable in every session via the \\tsclient\media RDP
|
||||
REM redirection (the desktop "USB" shortcut above points at it); a real
|
||||
REM block device is available through USB passthrough. There is no
|
||||
REM drive-letter auto-mapper -- it couldn't surface a letter reliably in
|
||||
REM RemoteApp sessions and kept destabilizing first boot (#613/#638).
|
||||
mkdir C:\winpodx 2>nul
|
||||
|
||||
REM Clean up any legacy OEM updater task / file from pre-0.1.6 installs.
|
||||
schtasks /delete /tn "WinpodxOEMUpdate" /f >nul 2>&1
|
||||
if exist C:\winpodx\oem_updater.ps1 del /F /Q C:\winpodx\oem_updater.ps1 >nul 2>&1
|
||||
|
||||
REM Parenthesized echo strips the trailing space that `echo X > file` leaves behind.
|
||||
(echo %WINPODX_OEM_VERSION%)>C:\winpodx\oem_version.txt
|
||||
|
||||
echo [WinPodX] Installing multi-session RDP (rdprrap) - offline bundle...
|
||||
REM Bundle ships under config/oem/ and is staged into C:\OEM\ by dockur's unattended install.
|
||||
REM The pin file (version / filename / sha256) lives next to the zip. No network
|
||||
REM access is required - everything is copied straight from the staged folder.
|
||||
set "RDPRRAP_PIN="
|
||||
if exist "C:\OEM\rdprrap_version.txt" set "RDPRRAP_PIN=C:\OEM\rdprrap_version.txt"
|
||||
|
||||
set "RDPRRAP_VERSION="
|
||||
set "RDPRRAP_FILENAME="
|
||||
set "RDPRRAP_SHA256="
|
||||
if defined RDPRRAP_PIN (
|
||||
for /f "usebackq tokens=1,* delims==" %%A in ("%RDPRRAP_PIN%") do (
|
||||
if /I "%%A"=="version" set "RDPRRAP_VERSION=%%B"
|
||||
if /I "%%A"=="filename" set "RDPRRAP_FILENAME=%%B"
|
||||
if /I "%%A"=="sha256" set "RDPRRAP_SHA256=%%B"
|
||||
)
|
||||
)
|
||||
|
||||
if not defined RDPRRAP_VERSION goto :rdprrap_skip
|
||||
if not defined RDPRRAP_FILENAME goto :rdprrap_skip
|
||||
if not defined RDPRRAP_SHA256 goto :rdprrap_skip
|
||||
|
||||
set "RDPRRAP_DIR=C:\winpodx\rdprrap"
|
||||
set "RDPRRAP_INSTALLED=%RDPRRAP_DIR%\.installed_version"
|
||||
set "RDPRRAP_CUR="
|
||||
if exist "%RDPRRAP_INSTALLED%" (
|
||||
for /f "usebackq delims=" %%V in ("%RDPRRAP_INSTALLED%") do set "RDPRRAP_CUR=%%V"
|
||||
)
|
||||
REM Check sits on its own line so %RDPRRAP_CUR% expands AFTER the for-loop above (no delayed expansion needed).
|
||||
if defined RDPRRAP_CUR if /I "%RDPRRAP_CUR%"=="%RDPRRAP_VERSION%" (
|
||||
echo [WinPodX] rdprrap %RDPRRAP_VERSION% already installed, skipping.
|
||||
goto :rdprrap_done
|
||||
)
|
||||
|
||||
REM Locate the bundled zip inside the OEM staging folder.
|
||||
set "RDPRRAP_ZIP_SRC="
|
||||
if exist "C:\OEM\%RDPRRAP_FILENAME%" set "RDPRRAP_ZIP_SRC=C:\OEM\%RDPRRAP_FILENAME%"
|
||||
if not defined RDPRRAP_ZIP_SRC (
|
||||
echo [WinPodX] WARNING: bundled %RDPRRAP_FILENAME% not found at C:\winpodx or C:\OEM; staying single-session.
|
||||
goto :rdprrap_done
|
||||
)
|
||||
|
||||
REM certutil prints 3 lines; line 2 has the hex digest with spaces between bytes.
|
||||
set "RDPRRAP_GOT="
|
||||
for /f "usebackq skip=1 delims=" %%H in (`certutil -hashfile "%RDPRRAP_ZIP_SRC%" SHA256 ^| findstr /R "^[0-9a-fA-F ]*$"`) do (
|
||||
if not defined RDPRRAP_GOT set "RDPRRAP_GOT=%%H"
|
||||
)
|
||||
set "RDPRRAP_GOT=%RDPRRAP_GOT: =%"
|
||||
if /I not "%RDPRRAP_GOT%"=="%RDPRRAP_SHA256%" (
|
||||
echo [WinPodX] WARNING: rdprrap sha256 mismatch on bundle; staying single-session.
|
||||
echo [WinPodX] expected %RDPRRAP_SHA256%
|
||||
echo [WinPodX] got %RDPRRAP_GOT%
|
||||
goto :rdprrap_done
|
||||
)
|
||||
|
||||
mkdir "%RDPRRAP_DIR%" 2>nul
|
||||
|
||||
REM --- Diagnostic logs ------------------------------------------------------
|
||||
REM Status marker (one-line classification, machine-readable):
|
||||
REM enabled / extract-failed / installer-failed / not-activated / missing-bundle
|
||||
REM Detailed log (full timestamps + retry-by-retry stderr/stdout) so when
|
||||
REM something fails users / `winpodx pod apply-fixes` have something to
|
||||
REM root-cause from. The marker fits a single grep, the log is the deep dive.
|
||||
set "RDPRRAP_STATUS=C:\winpodx\rdprrap\.activation_status"
|
||||
set "RDPRRAP_LOG=C:\winpodx\rdprrap\install.log"
|
||||
(echo === rdprrap install log === %DATE% %TIME%) > "%RDPRRAP_LOG%"
|
||||
(echo version=%RDPRRAP_VERSION%)>>"%RDPRRAP_LOG%"
|
||||
(echo bundle=%RDPRRAP_ZIP_SRC%)>>"%RDPRRAP_LOG%"
|
||||
|
||||
REM --- Extract with retries + per-attempt log ------------------------------
|
||||
REM Expand-Archive occasionally fails on first-boot Sysprep with file-lock /
|
||||
REM antivirus interference. Retry up to 3 times. Capture stderr each
|
||||
REM attempt so a final extract-failed has actionable diagnostics.
|
||||
REM v0.4.0 (post-rc1): tar -xf instead of PS Expand-Archive.
|
||||
REM
|
||||
REM Background: PS Expand-Archive deadlocked on every fresh install
|
||||
REM 2026-05-02 through 2026-05-04. install.log ended at "extract attempt
|
||||
REM 1" with no follow-up - neither "extract OK" nor "extract failed" -
|
||||
REM meaning the PowerShell call never returned. The 3 disconnected
|
||||
REM User sessions in qwinsta on every attempt were the eventual kicks
|
||||
REM (host-side migrate's password probe at 12:45 UTC) that finally
|
||||
REM killed the hanging install.bat. PS Expand-Archive was hanging on
|
||||
REM Defender's real-time scan of C:\OEM\ + the rdprrap zip extraction
|
||||
REM target - one of the staged PS / EXE files (rdprrap-installer.exe
|
||||
REM is naturally PUP-flagged because it patches termsrv.dll) was
|
||||
REM getting locked, and Expand-Archive blocked waiting on the lock.
|
||||
REM
|
||||
REM tar (Windows 10 1803+, ships in System32\tar.exe) bypasses the
|
||||
REM PowerShell engine entirely - no module load, no .NET assembly
|
||||
REM resolution, no AMSI hookpoints. It's a syscall-direct extraction
|
||||
REM that Defender's PS-script analysis layer can't intercept. The
|
||||
REM Defender exclusions added at the top of install.bat (Add-MpPreference
|
||||
REM -ExclusionPath C:\OEM,C:\winpodx) are also in effect by this point,
|
||||
REM so even the EXE path is exempt.
|
||||
REM
|
||||
REM Output flatten: tar's -C strips the archive's leading directory by
|
||||
REM default for our zip layout, so the inner rdprrap-<version>/ folder
|
||||
REM that Expand-Archive used to leave behind isn't present after tar
|
||||
REM extraction. We still defensively check + flatten via cmd's MOVE /Y
|
||||
REM in case the archive layout changes.
|
||||
echo [WinPodX] Extracting rdprrap %RDPRRAP_VERSION%...
|
||||
set "RDPRRAP_EXTRACTED="
|
||||
for %%T in (1 2 3) do (
|
||||
if not defined RDPRRAP_EXTRACTED (
|
||||
(echo --- extract attempt %%T %DATE% %TIME%)>>"%RDPRRAP_LOG%"
|
||||
"%SystemRoot%\System32\tar.exe" -xf "%RDPRRAP_ZIP_SRC%" -C "%RDPRRAP_DIR%" >>"%RDPRRAP_LOG%" 2>&1
|
||||
if not errorlevel 1 set "RDPRRAP_EXTRACTED=1"
|
||||
if not defined RDPRRAP_EXTRACTED (
|
||||
(echo extract attempt %%T failed; sleeping 2s)>>"%RDPRRAP_LOG%"
|
||||
echo [WinPodX] extract attempt %%T failed, retrying after 2s...
|
||||
REM Plain timeout instead of `powershell Start-Sleep` - PS
|
||||
REM call here would re-introduce the very engine load that
|
||||
REM tar is bypassing.
|
||||
timeout /t 2 /nobreak >nul 2>&1
|
||||
)
|
||||
)
|
||||
)
|
||||
if not defined RDPRRAP_EXTRACTED (
|
||||
(echo FINAL: extract-failed)>>"%RDPRRAP_LOG%"
|
||||
echo [WinPodX] WARNING: rdprrap extraction failed after 3 attempts; staying single-session.
|
||||
echo [WinPodX] diagnostic log: %RDPRRAP_LOG%
|
||||
(echo extract-failed)>"%RDPRRAP_STATUS%"
|
||||
goto :rdprrap_done
|
||||
)
|
||||
(echo extract OK via tar)>>"%RDPRRAP_LOG%"
|
||||
|
||||
REM Defensive flatten: if tar left an inner rdprrap-<version>/ folder
|
||||
REM (depends on the zip's layout convention), move its contents up to
|
||||
REM RDPRRAP_DIR. Single shot, no PS - pure cmd. Idempotent: the for
|
||||
REM loop simply finds no match if there's no inner dir.
|
||||
for /d %%D in ("%RDPRRAP_DIR%\rdprrap-*") do (
|
||||
(echo flattening inner dir: %%~nxD)>>"%RDPRRAP_LOG%"
|
||||
xcopy /E /Y /Q "%%D\*" "%RDPRRAP_DIR%\" >>"%RDPRRAP_LOG%" 2>&1
|
||||
rmdir /S /Q "%%D" >>"%RDPRRAP_LOG%" 2>&1
|
||||
)
|
||||
|
||||
set "RDPRRAP_EXE=%RDPRRAP_DIR%\rdprrap-installer.exe"
|
||||
if not exist "%RDPRRAP_EXE%" (
|
||||
(echo FINAL: extract-failed - rdprrap-installer.exe missing after extract)>>"%RDPRRAP_LOG%"
|
||||
echo [WinPodX] WARNING: rdprrap-installer.exe missing after extract; staying single-session.
|
||||
echo [WinPodX] diagnostic log: %RDPRRAP_LOG%
|
||||
(echo extract-failed)>"%RDPRRAP_STATUS%"
|
||||
goto :rdprrap_done
|
||||
)
|
||||
|
||||
REM --- Delegate install / TermService cycle / verify to rdprrap-activate.ps1
|
||||
REM Single source of truth - same script `winpodx pod multi-session on`
|
||||
REM uses for runtime activation. install.bat used to inline ~80 lines of
|
||||
REM installer-retry / TermService-cycle / ServiceDll-verify / marker logic;
|
||||
REM that code now lives in rdprrap-activate.ps1 so a fix to the activation
|
||||
REM flow benefits both OEM-time and runtime paths without drift.
|
||||
REM
|
||||
REM Safety at OEM time: install.bat runs from FirstLogonCommands in the
|
||||
REM local console session - TermService manages RDP sessions only, so the
|
||||
REM cycle inside the script doesn't tear down our cmd.exe parent.
|
||||
REM
|
||||
REM The script writes the same .activation_status / install.log markers
|
||||
REM install.bat used to write directly. Idempotency marker
|
||||
REM (.installed_version) stays install.bat's responsibility - only OEM
|
||||
REM time has the SHA-pinned bundle context to safely stamp it.
|
||||
echo [WinPodX] Activating rdprrap (delegating to rdprrap-activate.ps1)...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0rdprrap-activate.ps1" >>"%RDPRRAP_LOG%" 2>&1
|
||||
set "RDPRRAP_RC=%ERRORLEVEL%"
|
||||
(echo rdprrap-activate.ps1 exit=%RDPRRAP_RC%)>>"%RDPRRAP_LOG%"
|
||||
if not "%RDPRRAP_RC%"=="0" (
|
||||
echo [WinPodX] WARNING: rdprrap-activate.ps1 exited with code %RDPRRAP_RC%; staying single-session.
|
||||
echo [WinPodX] diagnostic log: %RDPRRAP_LOG%
|
||||
goto :rdprrap_done
|
||||
)
|
||||
|
||||
(echo %RDPRRAP_VERSION%)>"%RDPRRAP_INSTALLED%"
|
||||
echo [WinPodX] rdprrap %RDPRRAP_VERSION% installed and activated.
|
||||
goto :rdprrap_done
|
||||
|
||||
:rdprrap_skip
|
||||
echo [WinPodX] rdprrap_version.txt not found or incomplete; staying single-session.
|
||||
:rdprrap_done
|
||||
|
||||
REM ---------------------------------------------------------------------
|
||||
REM v0.2.2-rev1: winpodx guest HTTP agent
|
||||
REM ---------------------------------------------------------------------
|
||||
echo [WinPodX] Installing WinPodX guest agent...
|
||||
copy /Y "%~dp0agent\agent.ps1" "C:\OEM\agent.ps1" 2>nul
|
||||
mkdir C:\OEM\agent-runs 2>nul
|
||||
|
||||
REM Hidden launchers go under C:\Users\Public\winpodx\launchers\ rather than
|
||||
REM C:\OEM\. Public is universally writable for Authenticated Users, so the
|
||||
REM agent (which runs as User, non-admin) can later overwrite these files
|
||||
REM during a `winpodx pod apply-fixes` migration without needing UAC. C:\OEM\
|
||||
REM is SYSTEM-owned and rejects User writes by default.
|
||||
REM
|
||||
REM Diagnostic log (`C:\winpodx\setup.log`) records each copy + verify
|
||||
REM step. Pre-OEM-v20 the copies were silent (`2>nul`), so when a
|
||||
REM Sysprep-time failure (network share blip, AV interference) skipped a
|
||||
REM file, the missing-file symptom only surfaced later as a "Cannot find
|
||||
REM script file" wscript dialog at HKCU\Run firing time -- with no
|
||||
REM breadcrumbs. With this log + per-file existence check below, a
|
||||
REM staging failure now writes a single line we can grep from the host
|
||||
REM via the agent.
|
||||
mkdir C:\winpodx 2>nul
|
||||
set "SETUP_LOG=C:\winpodx\setup.log"
|
||||
(echo === setup log === %DATE% %TIME%) > "%SETUP_LOG%"
|
||||
(echo OEM_VERSION=%WINPODX_OEM_VERSION%)>>"%SETUP_LOG%"
|
||||
(echo install.bat dir=%~dp0)>>"%SETUP_LOG%"
|
||||
|
||||
mkdir "C:\Users\Public\winpodx" 2>nul
|
||||
mkdir "C:\Users\Public\winpodx\launchers" 2>nul
|
||||
|
||||
REM Each launcher: copy, verify the file exists at its destination, log
|
||||
REM the outcome. Continuing on failure (so a single bad copy doesn't
|
||||
REM abort the rest of install.bat) but recording it for diagnostics.
|
||||
REM `LAUNCHERS_OK=1` after the loop means hidden-launcher.vbs (the
|
||||
REM critical wrapper) made it to its destination -- gates the wscript
|
||||
REM reg add below.
|
||||
set "LAUNCHERS_OK="
|
||||
for %%F in (
|
||||
"hidden-launcher.vbs"
|
||||
"launch_uwp.vbs"
|
||||
"launch_uwp.ps1"
|
||||
"agent-respawn.ps1"
|
||||
"agent-keepalive.ps1"
|
||||
"rdprrap-activate.ps1"
|
||||
) do (
|
||||
copy /Y "%~dp0%%~F" "C:\Users\Public\winpodx\launchers\%%~F" >nul 2>>"%SETUP_LOG%"
|
||||
if exist "C:\Users\Public\winpodx\launchers\%%~F" (
|
||||
(echo launcher OK: %%~F)>>"%SETUP_LOG%"
|
||||
) else (
|
||||
(echo launcher FAILED: %%~F src=%~dp0%%~F not staged or copy denied)>>"%SETUP_LOG%"
|
||||
)
|
||||
)
|
||||
if exist "C:\Users\Public\winpodx\launchers\hidden-launcher.vbs" set "LAUNCHERS_OK=1"
|
||||
(echo launchers gate: LAUNCHERS_OK=%LAUNCHERS_OK%)>>"%SETUP_LOG%"
|
||||
|
||||
REM Pre-register the URL ACL for agent.ps1's HttpListener prefix.
|
||||
REM
|
||||
REM agent.ps1 binds ``http://+:8765/`` (all interfaces inside the
|
||||
REM Windows VM) - NOT 127.0.0.1. dockur's user-mode QEMU NAT forwards
|
||||
REM from container:8765 to the VM's slirp interface (10.0.2.15:8765,
|
||||
REM NOT 127.0.0.1:8765); a 127.0.0.1-only listener would mean slirp's
|
||||
REM forwarded packets hit a closed port (kernalix7 saw "Connection
|
||||
REM reset by peer" on 2026-04-30 from exactly this). Binding to + is
|
||||
REM safe because the agent stays externally unreachable: compose's
|
||||
REM 127.0.0.1:8765:8765/tcp mapping is loopback on the host, and the
|
||||
REM QEMU slirp net is private to the container.
|
||||
REM
|
||||
REM HttpListener.Start() needs a urlacl entry to bind ``+``; without
|
||||
REM it the bind fails with "conflicts with an existing registration on
|
||||
REM the machine" because a non-admin process (the autologon User the
|
||||
REM agent runs as) cannot reserve a strong-wildcard prefix without a
|
||||
REM pre-existing reservation it is permitted to use.
|
||||
REM
|
||||
REM #269 (ismikes, Kubuntu 26.04): agent.log showed the bind failing
|
||||
REM with exactly that conflict on every boot even though install.bat
|
||||
REM ran to completion. Root cause: the prior `user=Everyone` form +
|
||||
REM hidden `>nul 2>&1` masked a failed / locale-dependent reservation.
|
||||
REM Hardened below:
|
||||
REM - sddl=D:(A;;GX;;;WD) reserves for the World (Everyone) SID
|
||||
REM S-1-1-0 directly, locale-independent (the literal string
|
||||
REM "Everyone" is localized on non-English Windows and the netsh
|
||||
REM parse silently no-ops there). GX = GENERIC_EXECUTE = the
|
||||
REM register/listen right HttpListener needs.
|
||||
REM - delete every overlapping 8765 reservation (strong +, weak *,
|
||||
REM loopback) first so a stale differently-owned entry can't win.
|
||||
REM - results logged to setup.log (not >nul) so a failure is visible.
|
||||
REM - show urlacl after, so the actual post-state is in the log.
|
||||
echo [agent-install] step=urlacl status=enter>>"%SETUP_LOG%"
|
||||
netsh http delete urlacl url=http://127.0.0.1:8765/ >>"%SETUP_LOG%" 2>&1
|
||||
netsh http delete urlacl url=http://*:8765/ >>"%SETUP_LOG%" 2>&1
|
||||
netsh http delete urlacl url=http://+:8765/ >>"%SETUP_LOG%" 2>&1
|
||||
netsh http add urlacl url=http://+:8765/ sddl="D:(A;;GX;;;WD)" >>"%SETUP_LOG%" 2>&1
|
||||
echo [agent-install] urlacl add rc=%ERRORLEVEL%>>"%SETUP_LOG%"
|
||||
netsh http show urlacl url=http://+:8765/ >>"%SETUP_LOG%" 2>&1
|
||||
echo [agent-install] step=urlacl status=exit>>"%SETUP_LOG%"
|
||||
|
||||
REM Register HKCU\Run via PowerShell instead of `reg add`. Three reasons
|
||||
REM the cmd-quoting path bit users pre-OEM-v20:
|
||||
REM 1. `reg add /d "...\\\"path\\\"..."` survives cmd parsing but
|
||||
REM reg.exe stores literal backslash-quote pairs in the registry,
|
||||
REM and CommandLineToArgvW at logon evaluates them as escaped
|
||||
REM quotes -- not always argv-equivalent to the intended quoted
|
||||
REM argument. PS's Set-ItemProperty takes a real .NET string and
|
||||
REM stores it byte-exact.
|
||||
REM 2. PS can verify hidden-launcher.vbs exists *before* registering
|
||||
REM the wscript-wrapped value -- if the launcher staging copy
|
||||
REM failed (logged via setup.log above, LAUNCHERS_OK unset), we
|
||||
REM fall back to direct `powershell.exe -WindowStyle Hidden` for
|
||||
REM WinpodxAgent. That fallback flashes briefly but at least
|
||||
REM starts the agent; without it, HKCU\Run fired wscript on a
|
||||
REM missing file -> "Cannot find script file" dialog blocking
|
||||
REM the user session indefinitely (kernalix7 saw this on a fresh
|
||||
REM install 2026-05-02 18:48).
|
||||
REM 3. Single PS round-trip writes the WinpodxAgent entry.
|
||||
echo [WinPodX] Registering HKCU\Run entries...
|
||||
echo [agent-install] step=hkcu-run-register status=enter>>"%SETUP_LOG%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$wrap = 'C:\Users\Public\winpodx\launchers\hidden-launcher.vbs';" ^
|
||||
"$haveWrap = Test-Path -LiteralPath $wrap;" ^
|
||||
"Write-Output (\"reg-add: haveWrap=$haveWrap\");" ^
|
||||
"$key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run';" ^
|
||||
"if ($haveWrap) {" ^
|
||||
" $agent = 'wscript.exe \"' + $wrap + '\" \"powershell.exe\" \"-NoProfile\" \"-ExecutionPolicy\" \"Bypass\" \"-File\" \"C:\OEM\agent.ps1\"';" ^
|
||||
"} else {" ^
|
||||
" Write-Output 'reg-add: hidden-launcher.vbs missing -> fallback to direct powershell (brief flash)';" ^
|
||||
" $agent = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\OEM\agent.ps1';" ^
|
||||
"}" ^
|
||||
"try {" ^
|
||||
" Set-ItemProperty -Path $key -Name 'WinpodxAgent' -Value $agent -Force;" ^
|
||||
" Write-Output ('reg-add: WinpodxAgent=' + $agent);" ^
|
||||
"} catch {" ^
|
||||
" Write-Output ('reg-add: ERROR ' + $_.Exception.GetType().FullName + ': ' + $_.Exception.Message);" ^
|
||||
"}" >>"%SETUP_LOG%" 2>&1
|
||||
echo [agent-install] step=hkcu-run-register status=exit rc=%ERRORLEVEL%>>"%SETUP_LOG%"
|
||||
|
||||
REM Start the agent NOW (install.bat-time spawn) -- not just register it
|
||||
REM in HKCU\Run for future sessions. Reasoning: HKCU\Run fires once per
|
||||
REM user logon, and the autologon User session has *already* logged in
|
||||
REM by the time install.bat (FirstLogonCommands) executes. Registering
|
||||
REM HKCU\Run here only takes effect on the NEXT session, so without
|
||||
REM this explicit spawn the agent sits idle until the user (or a host
|
||||
REM RDP probe) opens a new session. install.sh's wait-ready phase 3
|
||||
REM was timing out at /health waiting for an agent that wasn't going
|
||||
REM to start until much later. spawn here -> agent /health up before
|
||||
REM install.bat exits, phase 3 succeeds cleanly, migrate's apply chain
|
||||
REM runs against a healthy agent (no FreeRDP-fallback cascades).
|
||||
REM
|
||||
REM Same wscript+hidden-launcher.vbs wrapper / direct-PS fallback
|
||||
REM split as the HKCU\Run registration above. Spawned via
|
||||
REM Start-Process detached so install.bat doesn't block on the agent's
|
||||
REM event loop.
|
||||
echo [WinPodX] Starting guest agent...
|
||||
echo [agent-install] step=spawn status=enter>>"%SETUP_LOG%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$wrap = 'C:\Users\Public\winpodx\launchers\hidden-launcher.vbs';" ^
|
||||
"$agentScript = 'C:\OEM\agent.ps1';" ^
|
||||
"Write-Output ('agent-spawn: wrap=' + $wrap + ' wrapExists=' + (Test-Path -LiteralPath $wrap));" ^
|
||||
"Write-Output ('agent-spawn: agentScript=' + $agentScript + ' scriptExists=' + (Test-Path -LiteralPath $agentScript));" ^
|
||||
"try {" ^
|
||||
" if (Test-Path -LiteralPath $wrap) {" ^
|
||||
" $startArgs = @($wrap, 'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $agentScript);" ^
|
||||
" $p = Start-Process wscript.exe -ArgumentList $startArgs -WindowStyle Hidden -PassThru;" ^
|
||||
" Write-Output ('agent-spawn: wscript+hidden-launcher.vbs pid=' + $p.Id);" ^
|
||||
" } else {" ^
|
||||
" $p = Start-Process powershell.exe -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $agentScript) -WindowStyle Hidden -PassThru;" ^
|
||||
" Write-Output ('agent-spawn: direct-powershell-fallback pid=' + $p.Id);" ^
|
||||
" }" ^
|
||||
"} catch {" ^
|
||||
" Write-Output ('agent-spawn: ERROR ' + $_.Exception.GetType().FullName + ': ' + $_.Exception.Message);" ^
|
||||
"}" >>"%SETUP_LOG%" 2>&1
|
||||
echo [agent-install] step=spawn status=exit rc=%ERRORLEVEL%>>"%SETUP_LOG%"
|
||||
|
||||
REM Quick post-spawn health probe -- give the agent 5s to bind 8765,
|
||||
REM then log whether the listener is up. If it's not, we know agent.ps1
|
||||
REM either failed to start or crashed before HttpListener.Start(), and
|
||||
REM the user / next debugger has a clear breadcrumb without needing
|
||||
REM to chase Start-Process exit codes.
|
||||
echo [agent-install] step=post-spawn-probe status=enter>>"%SETUP_LOG%"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"Start-Sleep -Seconds 5;" ^
|
||||
"$listener = Get-NetTCPConnection -LocalPort 8765 -State Listen -ErrorAction SilentlyContinue;" ^
|
||||
"if ($listener) {" ^
|
||||
" Write-Output ('post-spawn-probe: 8765 listener up (PID ' + ($listener | Select-Object -First 1 -ExpandProperty OwningProcess) + ')');" ^
|
||||
"} else {" ^
|
||||
" Write-Output 'post-spawn-probe: 8765 NOT listening 5s after spawn';" ^
|
||||
" Get-ChildItem C:\OEM\agent.log -ErrorAction SilentlyContinue | ForEach-Object { Write-Output ('post-spawn-probe: agent.log size=' + $_.Length) };" ^
|
||||
" Get-Content C:\OEM\agent.log -Tail 20 -ErrorAction SilentlyContinue | ForEach-Object { Write-Output ('agent.log: ' + $_) };" ^
|
||||
"}" >>"%SETUP_LOG%" 2>&1
|
||||
echo [agent-install] step=post-spawn-probe status=exit rc=%ERRORLEVEL%>>"%SETUP_LOG%"
|
||||
|
||||
REM ---------------------------------------------------------------------
|
||||
REM Agent keep-alive scheduled task (WinpodxAgentKeepAlive).
|
||||
REM
|
||||
REM HKCU\Run fires the agent exactly once per interactive logon. When the
|
||||
REM autologon session is torn down (RDP single-session enforcement kicks
|
||||
REM it when a FreeRDP connection arrives before rdprrap multi-session is
|
||||
REM active, or a TermService cycle during rdprrap (re)activation), the
|
||||
REM agent process dies with the session and HKCU\Run does NOT re-fire --
|
||||
REM the agent stays dead until the pod reboots. This task is the
|
||||
REM persistent watchdog HKCU\Run never was.
|
||||
REM
|
||||
REM Principal: the INTERACTIVE autologon user, NOT SYSTEM / S4U. The
|
||||
REM agent's /exec runs PowerShell in the user context (Start Menu / per-
|
||||
REM user app discovery, per-user reverse-open HKCU registration); a
|
||||
REM SYSTEM principal would change HKCU + Start Menu out from under those
|
||||
REM callers. A user-context task only runs while a session exists, which
|
||||
REM covers crash-but-alive (1-min repetition) and re-logon (AtLogOn). The
|
||||
REM session-kick-with-no-relogon case is handled by keeping rdprrap
|
||||
REM activation idempotent so the kick does not happen (see
|
||||
REM _apply_multi_session / rdprrap-activate.ps1).
|
||||
REM
|
||||
REM agent-keepalive.ps1 is staged to C:\winpodx (survives the C:\OEM wipe
|
||||
REM on classic VMs, same as power-monitor.ps1) and runs through the
|
||||
REM wscript hidden-launcher wrapper so the 1-min wakeups never flash a
|
||||
REM console. Registered via the ScheduledTasks cmdlets so we can attach
|
||||
REM BOTH an AtLogOn trigger AND a 1-minute repetition (schtasks.exe can
|
||||
REM only set one schedule per task).
|
||||
echo [WinPodX] Registering agent keep-alive scheduled task...
|
||||
echo [agent-install] step=keepalive-task status=enter>>"%SETUP_LOG%"
|
||||
if exist "C:\Users\Public\winpodx\launchers\agent-keepalive.ps1" (
|
||||
if not exist C:\winpodx mkdir C:\winpodx
|
||||
copy /Y "C:\Users\Public\winpodx\launchers\agent-keepalive.ps1" C:\winpodx\agent-keepalive.ps1 >nul 2>&1
|
||||
)
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||
"$wrap = 'C:\Users\Public\winpodx\launchers\hidden-launcher.vbs';" ^
|
||||
"$ka = 'C:\winpodx\agent-keepalive.ps1';" ^
|
||||
"if (-not (Test-Path -LiteralPath $ka)) { Write-Output 'keepalive: agent-keepalive.ps1 not staged; skipping'; exit 0 };" ^
|
||||
"if (Test-Path -LiteralPath $wrap) {" ^
|
||||
" $exe = 'wscript.exe';" ^
|
||||
" $arg = '\"' + $wrap + '\" \"powershell.exe\" \"-NoProfile\" \"-ExecutionPolicy\" \"Bypass\" \"-File\" \"' + $ka + '\"';" ^
|
||||
"} else {" ^
|
||||
" $exe = 'powershell.exe';" ^
|
||||
" $arg = '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"' + $ka + '\"';" ^
|
||||
"}" ^
|
||||
"$act = New-ScheduledTaskAction -Execute $exe -Argument $arg;" ^
|
||||
"$tLogon = New-ScheduledTaskTrigger -AtLogOn;" ^
|
||||
"$tRepeat = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1);" ^
|
||||
"$me = \"$env:USERDOMAIN\$env:USERNAME\";" ^
|
||||
"$prin = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited;" ^
|
||||
"$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 2);" ^
|
||||
"try {" ^
|
||||
" Register-ScheduledTask -TaskName 'WinpodxAgentKeepAlive' -Action $act -Trigger @($tLogon,$tRepeat) -Principal $prin -Settings $set -Force | Out-Null;" ^
|
||||
" Write-Output ('keepalive: registered for ' + $me);" ^
|
||||
"} catch {" ^
|
||||
" Write-Output ('keepalive: ERROR ' + $_.Exception.Message);" ^
|
||||
"}" >>"%SETUP_LOG%" 2>&1
|
||||
echo [agent-install] step=keepalive-task status=exit rc=%ERRORLEVEL%>>"%SETUP_LOG%"
|
||||
|
||||
REM Token is delivered via the OEM bind mount - no \\tsclient\home copy
|
||||
REM needed. Setup stages it to {oem_dir}/agent_token.txt before container
|
||||
REM creation; dockur lays the OEM directory contents into C:\OEM\.
|
||||
echo [WinPodX] Guest agent installed.
|
||||
|
||||
REM Stage power-monitor.ps1 under C:\winpodx (C:\OEM is wiped after
|
||||
REM first boot, so anything we want to keep needs a copy outside it).
|
||||
REM Then register a SYSTEM-level scheduled task that runs the monitor
|
||||
REM at boot; the script subscribes to Win32_PowerManagementEvent and
|
||||
REM cycles TermService when the host suspends/resumes (which the guest
|
||||
REM only sees as a wall-clock jump, leaving the RDP TCP listener
|
||||
REM stale -- kernalix7's recurring "GUI stuck on starting after host
|
||||
REM wake" symptom).
|
||||
if exist C:\OEM\power-monitor.ps1 (
|
||||
if not exist C:\winpodx mkdir C:\winpodx
|
||||
copy /Y C:\OEM\power-monitor.ps1 C:\winpodx\power-monitor.ps1 >nul 2>&1
|
||||
schtasks /create /tn "winpodx-power-monitor" ^
|
||||
/tr "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\winpodx\power-monitor.ps1" ^
|
||||
/sc onstart /ru SYSTEM /rl HIGHEST /f >nul 2>&1
|
||||
REM Kick the task immediately so the WMI subscription is live for
|
||||
REM this session too -- otherwise the very first host suspend
|
||||
REM after install (before the next guest reboot) goes unhandled.
|
||||
schtasks /run /tn "winpodx-power-monitor" >nul 2>&1
|
||||
echo [WinPodX] Guest power monitor scheduled.
|
||||
)
|
||||
|
||||
REM Sentinel lives under C:\winpodx so it survives past the one-shot C:\OEM stage.
|
||||
(echo done)>C:\winpodx\setup_done.txt
|
||||
|
||||
echo [WinPodX] Post-install configuration complete (version %WINPODX_OEM_VERSION%)!
|
||||
echo [WinPodX] RDP is now enabled. You can connect with FreeRDP.
|
||||
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM TermService cycle -- ABSOLUTELY LAST STEP.
|
||||
REM
|
||||
REM This restarts TermService so the running process picks up the
|
||||
REM termwrap.dll patched into HKLM:\...\TermService\Parameters\ServiceDll
|
||||
REM by rdprrap-activate.ps1 (synchronous OEM-mode call earlier in this
|
||||
REM script). rdprrap-activate.ps1 deliberately skips the cycle in OEM
|
||||
REM mode (marker = patched-pending-cycle) BECAUSE this cycle is what
|
||||
REM has been killing install.bat mid-script -- in dockur's setup the
|
||||
REM autologon User session is itself managed through TermService, so
|
||||
REM `net stop TermService /y` takes our cmd.exe down with it.
|
||||
REM
|
||||
REM By doing the cycle as the very last action, we have already
|
||||
REM committed:
|
||||
REM - launcher staging into C:\Users\Public\winpodx\launchers\
|
||||
REM - HKCU\Run\WinpodxAgent (registered via PowerShell)
|
||||
REM - inline agent spawn (so it ran with the now-stale TermService;
|
||||
REM it'll die with this cycle, then HKCU\Run brings it back in
|
||||
REM the autologon-retry session)
|
||||
REM - C:\winpodx\setup_done.txt sentinel
|
||||
REM - rdprrap-installer registry patch (done by rdprrap-activate.ps1)
|
||||
REM
|
||||
REM So even when this cycle kills install.bat's session, the autologon
|
||||
REM retry creates a fresh user session with multi-session active and
|
||||
REM HKCU\Run firing -- that brings the agent up cleanly.
|
||||
REM
|
||||
REM We still try to update the .activation_status marker after the
|
||||
REM cycle so apply-fixes doesn't have to re-trigger activation. If
|
||||
REM cmd.exe dies before reaching that, the marker stays at
|
||||
REM `patched-pending-cycle` and the host's `_apply_multi_session`
|
||||
REM ServiceDll cross-check (PR #85) will reconcile it to `enabled` on
|
||||
REM next apply-fixes.
|
||||
echo [WinPodX] Cycling TermService to load termwrap.dll (final step)...
|
||||
net stop TermService /y >nul 2>&1
|
||||
net start TermService >nul 2>&1
|
||||
|
||||
REM Best-effort marker update. May not run if the cycle killed our
|
||||
REM session; that's fine -- apply-fixes reconciles via ServiceDll check.
|
||||
(echo enabled)>C:\winpodx\rdprrap\.activation_status 2>nul
|
||||
|
||||
REM ---------------------------------------------------------------------------
|
||||
REM Scheduled reboot -- ABSOLUTELY FINAL.
|
||||
REM
|
||||
REM Several registry edits set above (PlatformAoAcOverride for Modern
|
||||
REM Standby, NIC binding, etc.) only take effect on the next Windows
|
||||
REM boot. Without this reboot, a fresh-install guest is technically
|
||||
REM running with the *old* Modern Standby state for the first session;
|
||||
REM the host then sees the long-idle stall the powercfg /change
|
||||
REM timeouts can't prevent on their own. The TermService cycle above
|
||||
REM picks up termwrap.dll; the reboot below picks up everything that
|
||||
REM needs a clean boot.
|
||||
REM
|
||||
REM Sequence:
|
||||
REM 1. install.bat writes ``C:\winpodx\oem_reboot_pending.txt``.
|
||||
REM 2. ``shutdown /r /t 15`` queues the reboot with a 15s grace
|
||||
REM window so cmd.exe can finish + the autologon User session
|
||||
REM doesn't fight the cycle.
|
||||
REM 3. On the next Windows boot, the RunOnce key below fires
|
||||
REM ``del oem_reboot_pending.txt`` so the host can poll the
|
||||
REM marker's absence as the "reboot pass complete" signal.
|
||||
REM 4. ``winpodx pod wait-ready`` adds a [4/4] step that polls
|
||||
REM the marker via the agent transport (or `\\tsclient\home`
|
||||
REM fallback) until it's gone.
|
||||
REM
|
||||
REM If shutdown fails for any reason, the marker is left behind --
|
||||
REM apply-fixes treats this as "still need second-pass reboot" and
|
||||
REM offers to retry. Failure mode is detectable, not silent.
|
||||
|
||||
REM -- winpodx bare-metal disguise: prune unused virtio driver service keys --
|
||||
REM al-khaser flags Services\{viostor,vioscsi,BalloonService} as VM tells. The
|
||||
REM virtio-win bundle installs them even with no matching device. The helper
|
||||
REM removes only the keys whose device is absent (guarded so a virtio-boot or
|
||||
REM ballooned guest keeps its driver). Runs before the reboot so the change is
|
||||
REM applied on the clean boot below. No-op when the helper isn't shipped.
|
||||
if exist "%~dp0disguise-cleanup.ps1" (
|
||||
echo [WinPodX] Pruning unused virtio driver service keys...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0disguise-cleanup.ps1" >nul 2>&1
|
||||
)
|
||||
|
||||
echo [WinPodX] Scheduling reboot to apply registry / power settings...
|
||||
(echo pending)>C:\winpodx\oem_reboot_pending.txt 2>nul
|
||||
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" ^
|
||||
/v WinpodxClearOemRebootMarker /t REG_SZ ^
|
||||
/d "cmd.exe /c del /q C:\winpodx\oem_reboot_pending.txt" /f >nul
|
||||
shutdown /r /t 15 /c "WinPodX: applying registry / power settings"
|
||||
@@ -0,0 +1,94 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# launch_uwp.ps1 -- activate a UWP/MSIX app by AUMID via COM, no explorer.exe
|
||||
#
|
||||
# The default RemoteApp UWP launch (`explorer.exe shell:AppsFolder\<AUMID>`)
|
||||
# briefly shows an explorer.exe RemoteApp window before dispatching to the
|
||||
# UWP frame -- that's the "PowerShell-looking flash" users see when launching
|
||||
# Calculator / Settings / Terminal et al. Calling IApplicationActivationManager
|
||||
# directly skips the explorer transition: the UWP frame appears immediately
|
||||
# without an intermediate window.
|
||||
#
|
||||
# Invoked via launch_uwp.vbs which keeps powershell.exe itself hidden so
|
||||
# this script never flashes either.
|
||||
#
|
||||
# Usage: powershell.exe -NoProfile -ExecutionPolicy Bypass -File launch_uwp.ps1 <AUMID>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$Aumid
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# IApplicationActivationManager -- Windows shell COM interface for launching
|
||||
# packaged apps by AUMID. Documented in MSDN; available since Windows 8.
|
||||
#
|
||||
# Why a C# helper instead of calling the COM object directly from PowerShell:
|
||||
# the interface (IID 2E941141-...) inherits IUnknown only, not IDispatch, so
|
||||
# PS's dynamic-method dispatch can't find ActivateApplication ("does not
|
||||
# contain a method named 'ActivateApplication'"). And the PowerShell-level
|
||||
# cast `[IApplicationActivationManager]$rcw` doesn't carry the QueryInterface
|
||||
# the CLR needs. Doing both inside a compiled C# scope works because the
|
||||
# managed cast there resolves to a real COM QI.
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[ComImport, Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D"),
|
||||
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
public interface IApplicationActivationManager
|
||||
{
|
||||
int ActivateApplication(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string arguments,
|
||||
int options,
|
||||
out uint processId);
|
||||
|
||||
int ActivateForFile(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
|
||||
IntPtr itemArray,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string verb,
|
||||
out uint processId);
|
||||
|
||||
int ActivateForProtocol(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
|
||||
IntPtr itemArray,
|
||||
out uint processId);
|
||||
}
|
||||
|
||||
public static class WinpodxUwpLauncher
|
||||
{
|
||||
public static uint Activate(string aumid)
|
||||
{
|
||||
Type t = Type.GetTypeFromCLSID(new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C"));
|
||||
if (t == null)
|
||||
throw new InvalidOperationException("ApplicationActivationManager CLSID not registered");
|
||||
object instance = Activator.CreateInstance(t);
|
||||
IApplicationActivationManager iam = (IApplicationActivationManager)instance;
|
||||
uint pid;
|
||||
int hr = iam.ActivateApplication(aumid, null, 0, out pid);
|
||||
if (hr < 0)
|
||||
throw new InvalidOperationException("ActivateApplication HRESULT 0x" + hr.ToString("X8"));
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
"@ -ErrorAction Stop
|
||||
|
||||
try {
|
||||
[void][WinpodxUwpLauncher]::Activate($Aumid)
|
||||
exit 0
|
||||
} catch {
|
||||
# Don't surface to the user with a console -- write to a log the agent can
|
||||
# tail. Swallowing keeps RemoteApp's "session ended" return code clean
|
||||
# rather than dumping an uncaught .NET stack on the silent path.
|
||||
try {
|
||||
$logDir = Join-Path $env:LOCALAPPDATA 'winpodx'
|
||||
if (-not (Test-Path $logDir)) {
|
||||
[void](New-Item -ItemType Directory -Path $logDir -Force)
|
||||
}
|
||||
$logPath = Join-Path $logDir 'uwp-launcher.log'
|
||||
$line = "$((Get-Date).ToUniversalTime().ToString('o')) FAIL aumid=$Aumid err=$($_.Exception.Message)"
|
||||
Add-Content -Path $logPath -Value $line -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
' SPDX-License-Identifier: MIT
|
||||
' launch_uwp.vbs -- RemoteApp-friendly UWP launcher
|
||||
'
|
||||
' RemoteApp invokes a single program; this VBS is that program. wscript.exe
|
||||
' has no console of its own, and WshShell.Run with intWindowStyle=0 spawns
|
||||
' powershell.exe with SW_HIDE so launch_uwp.ps1 never flashes a console.
|
||||
' launch_uwp.ps1 then activates the AUMID via IApplicationActivationManager
|
||||
' and exits. The UWP frame becomes the visible RemoteApp window directly --
|
||||
' no explorer.exe transition flash.
|
||||
'
|
||||
' Usage:
|
||||
' wscript.exe launch_uwp.vbs <AUMID>
|
||||
' RemoteApp wiring (xfreerdp):
|
||||
' /app:program:wscript.exe,cmd:C:\OEM\launch_uwp.vbs <AUMID>
|
||||
|
||||
Option Explicit
|
||||
|
||||
If WScript.Arguments.Count = 0 Then
|
||||
WScript.Quit 1
|
||||
End If
|
||||
|
||||
Dim shell, aumid, cmd
|
||||
Set shell = CreateObject("WScript.Shell")
|
||||
|
||||
aumid = WScript.Arguments(0)
|
||||
|
||||
cmd = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ""C:\Users\Public\winpodx\launchers\launch_uwp.ps1"" """ & aumid & """"
|
||||
|
||||
' 0 = SW_HIDE so the powershell.exe child also starts windowless. Don't wait
|
||||
' on the call so wscript itself exits immediately and the UWP frame is the
|
||||
' only window the RemoteApp client sees.
|
||||
shell.Run cmd, 0, False
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# winpodx guest-side power monitor.
|
||||
#
|
||||
# Subscribes to Win32_PowerManagementEvent and restarts TermService when
|
||||
# the system resumes from sleep. Linux host suspend pauses the QEMU
|
||||
# vCPUs but leaves Windows wall-clock running -- the guest sees a
|
||||
# multi-minute time jump on host wake. TCP keepalive on the RDP socket
|
||||
# fails, NAT mappings expire, and TermService stalls in a way that
|
||||
# pre-this-script left the GUI / tray stuck on "starting" forever
|
||||
# (kernalix7's recurring symptom).
|
||||
#
|
||||
# Power event types (Win32_PowerManagementEvent.EventType):
|
||||
# 4 -- Suspend (entry)
|
||||
# 7 -- Resume from Suspend
|
||||
# 18 -- Resume from low-power state (modern standby)
|
||||
#
|
||||
# We restart TermService on 7 + 18 so the listening RDP socket is
|
||||
# fresh on the now-stale TCP/keepalive state. The 5s pre-restart
|
||||
# sleep gives the virtio NIC time to renegotiate its link before
|
||||
# TermService rebinds.
|
||||
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$logPath = 'C:\winpodx\power-monitor.log'
|
||||
|
||||
function Write-PowerLog {
|
||||
param([string]$line)
|
||||
try {
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('o')
|
||||
Add-Content -Path $logPath -Value "$stamp $line"
|
||||
} catch { }
|
||||
}
|
||||
|
||||
Write-PowerLog "power-monitor: starting (pid $PID)"
|
||||
|
||||
# Rate-limit guard. A flapping power source (laptop adapter loose,
|
||||
# UPS click-clack, KVM switch hot-key, etc.) could fire EventType 7
|
||||
# or 18 several times within a few seconds; cycling TermService
|
||||
# every time would DoS the user's RDP session through repeated
|
||||
# disconnect / reconnect storms (each TermService restart drops
|
||||
# every open RemoteApp). $script:LastRestartUtc tracks the last
|
||||
# successful restart -- subsequent resume events within 60 s are
|
||||
# logged but skipped.
|
||||
$script:LastRestartUtc = [DateTime]::MinValue
|
||||
|
||||
Register-WmiEvent `
|
||||
-Query "SELECT * FROM Win32_PowerManagementEvent" `
|
||||
-SourceIdentifier 'WinpodxPowerEvent' `
|
||||
-Action {
|
||||
$evt = $Event.SourceEventArgs.NewEvent.EventType
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('o')
|
||||
$logPath = 'C:\winpodx\power-monitor.log'
|
||||
try { Add-Content -Path $logPath -Value "$stamp event=$evt" } catch { }
|
||||
if ($evt -eq 7 -or $evt -eq 18) {
|
||||
$now = [DateTime]::UtcNow
|
||||
if (($now - $script:LastRestartUtc).TotalSeconds -lt 60) {
|
||||
try {
|
||||
Add-Content -Path $logPath -Value (
|
||||
"$stamp termservice=skipped_rate_limit " +
|
||||
"(last_restart=$($script:LastRestartUtc.ToString('o')))"
|
||||
)
|
||||
} catch { }
|
||||
return
|
||||
}
|
||||
# Resume. Wait for NIC to stabilise then cycle TermService.
|
||||
Start-Sleep -Seconds 5
|
||||
try {
|
||||
Restart-Service -Force TermService -ErrorAction Stop
|
||||
$script:LastRestartUtc = [DateTime]::UtcNow
|
||||
try { Add-Content -Path $logPath -Value "$stamp termservice=restarted" } catch { }
|
||||
} catch {
|
||||
try { Add-Content -Path $logPath -Value "$stamp termservice=restart_failed $($_.Exception.Message)" } catch { }
|
||||
}
|
||||
}
|
||||
} | Out-Null
|
||||
|
||||
# Keep the host PowerShell process alive so the WMI subscription stays
|
||||
# attached. Without this the script returns immediately, the runspace
|
||||
# tears down, and the registered event never fires.
|
||||
while ($true) {
|
||||
Start-Sleep -Seconds 3600
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,191 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# rdprrap-activate.ps1 -- single source of truth for rdprrap activation,
|
||||
# usable both at OEM Sysprep time (synchronous from install.bat) and at
|
||||
# runtime (detached from `winpodx pod multi-session on`).
|
||||
#
|
||||
# Background: rdprrap activates by patching termsrv.dll's ServiceDll
|
||||
# registry entry to point at termwrap.dll. The patch only takes effect
|
||||
# when TermService starts fresh, so activation requires `net stop
|
||||
# TermService /y && net start TermService`. That cycle kills every
|
||||
# active RDP session.
|
||||
#
|
||||
# Two invocation modes:
|
||||
#
|
||||
# 1) OEM time (install.bat, console session):
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File rdprrap-activate.ps1
|
||||
# -> no -Detached: skips the 2s parent-response wait, runs
|
||||
# synchronously, exits with the activation rc so install.bat can
|
||||
# branch on it. TermService cycle is safe -- install.bat runs from
|
||||
# FirstLogonCommands in the local console session and TermService
|
||||
# only manages RDP sessions, so the cycle doesn't tear down our
|
||||
# cmd.exe parent.
|
||||
#
|
||||
# 2) Runtime (cli.pod multi-session on, agent's user session):
|
||||
# wscript.exe hidden-launcher.vbs powershell.exe ... -Detached
|
||||
# -> -Detached: sleeps 2s before any work so the parent /exec
|
||||
# response can land at the host before TermService restart kills
|
||||
# the agent's RDP session. Subsequent run via HKCU\Run reads the
|
||||
# .activation_status marker -- same marker install.bat writes -- so
|
||||
# `winpodx pod multi-session status` and `apply-fixes` report
|
||||
# OEM-time and runtime activations through one surface.
|
||||
#
|
||||
# Requires the calling user to be in BUILTIN\Administrators with High
|
||||
# integrity (true at OEM time, true under dockur's autologon defaults
|
||||
# for the agent at runtime).
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Detached
|
||||
)
|
||||
|
||||
# Default ErrorActionPreference ('Continue') -- errors surface to the
|
||||
# log instead of being silently swallowed. The few calls that genuinely
|
||||
# tolerate failure (registry probes, log writes) opt in to
|
||||
# -ErrorAction SilentlyContinue locally.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$rdprrapDir = 'C:\winpodx\rdprrap'
|
||||
$logPath = "$rdprrapDir\install.log"
|
||||
$statusPath = "$rdprrapDir\.activation_status"
|
||||
$installer = "$rdprrapDir\rdprrap-installer.exe"
|
||||
|
||||
[void](New-Item -ItemType Directory -Path $rdprrapDir -Force -ErrorAction SilentlyContinue)
|
||||
|
||||
function Append-Log([string]$msg) {
|
||||
$line = "$((Get-Date).ToUniversalTime().ToString('o')) $msg"
|
||||
Add-Content -LiteralPath $logPath -Value $line -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Set-Status([string]$value) {
|
||||
Set-Content -LiteralPath $statusPath -Value $value -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Detached mode (runtime via /exec): give the parent call ~2s to land
|
||||
# its response at the host before we trigger TermService cycle that
|
||||
# kills the agent's session. Synchronous mode (OEM install.bat): no
|
||||
# such caller -- skip the wait.
|
||||
if ($Detached) {
|
||||
Start-Sleep -Seconds 2
|
||||
Append-Log '=== runtime rdprrap activation triggered (detached) ==='
|
||||
} else {
|
||||
Append-Log '=== rdprrap activation triggered (synchronous, OEM) ==='
|
||||
}
|
||||
|
||||
# Extract the bundled zip if rdprrap-installer.exe isn't staged. Covers
|
||||
# the case where install.bat's extract step failed at OEM time
|
||||
# (extract-failed marker) -- we don't want activation to be impossible
|
||||
# without container recreate.
|
||||
if (-not (Test-Path -LiteralPath $installer)) {
|
||||
Append-Log 'rdprrap-installer.exe missing; attempting to extract from C:\OEM bundle'
|
||||
$zip = Get-ChildItem -LiteralPath 'C:\OEM' -Filter 'rdprrap-*.zip' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $zip) {
|
||||
Append-Log 'FAIL: no rdprrap-*.zip found under C:\OEM -- cannot proceed'
|
||||
Set-Status 'extract-failed'
|
||||
exit 1
|
||||
}
|
||||
try {
|
||||
Expand-Archive -LiteralPath $zip.FullName -DestinationPath $rdprrapDir -Force -ErrorAction Stop
|
||||
# Flatten an inner rdprrap-<version>/ folder if Expand-Archive
|
||||
# nested one (matches install.bat's flatten step).
|
||||
$inner = Get-ChildItem -LiteralPath $rdprrapDir -Directory -Filter 'rdprrap-*' |
|
||||
Select-Object -First 1
|
||||
if ($inner) {
|
||||
Get-ChildItem -LiteralPath $inner.FullName -Force |
|
||||
Move-Item -Destination $rdprrapDir -Force
|
||||
Remove-Item -LiteralPath $inner.FullName -Recurse -Force
|
||||
}
|
||||
} catch {
|
||||
Append-Log "FAIL: Expand-Archive: $($_.Exception.Message)"
|
||||
Set-Status 'extract-failed'
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $installer)) {
|
||||
Append-Log 'FAIL: rdprrap-installer.exe still missing after extract'
|
||||
Set-Status 'extract-failed'
|
||||
exit 1
|
||||
}
|
||||
Append-Log 'extract OK'
|
||||
}
|
||||
|
||||
# Run the installer up to 3 times. Captures full stdout+stderr so a
|
||||
# final installer-failed surfaces actionable diagnostics.
|
||||
$installOk = $false
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
Append-Log "installer attempt $i"
|
||||
try {
|
||||
$output = & $installer install --skip-restart 2>&1
|
||||
Append-Log ($output | Out-String).TrimEnd()
|
||||
if ($LASTEXITCODE -eq 0) { $installOk = $true; break }
|
||||
Append-Log "installer attempt $i exit=$LASTEXITCODE"
|
||||
} catch {
|
||||
Append-Log "installer attempt $i raised: $($_.Exception.Message)"
|
||||
}
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if (-not $installOk) {
|
||||
Set-Status 'installer-failed'
|
||||
Append-Log 'FINAL: installer-failed'
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OEM-mode (synchronous, no -Detached): SKIP the TermService cycle.
|
||||
#
|
||||
# Background: kernalix7 reported 2026-05-03 that install.bat's session
|
||||
# was being killed mid-script. The forensics: install.log ended at
|
||||
# `FINAL: enabled` (rdprrap-activate.ps1's last line) but the
|
||||
# `(echo rdprrap-activate.ps1 exit=N)>>install.log` line that
|
||||
# install.bat writes IMMEDIATELY after the PS call -- never landed.
|
||||
# install.bat's cmd.exe died between PS exit and that line. Setup.log
|
||||
# was never created, launchers never staged, HKCU\Run never set, agent
|
||||
# never spawned -- the entire post-rdprrap section of install.bat was
|
||||
# silently lost.
|
||||
#
|
||||
# Cause: in dockur's autologon flow, the Sysprep first-logon session
|
||||
# IS managed through TermService. `net stop TermService /y` therefore
|
||||
# kills the autologon User session, and install.bat's cmd.exe (a
|
||||
# child of that session) dies with it. The earlier docstring assumed
|
||||
# the session was a "local console" insulated from TermService -- that
|
||||
# assumption was wrong for dockur's setup.
|
||||
#
|
||||
# Fix: at OEM time we ONLY patch the registry (rdprrap-installer
|
||||
# already did that above with --skip-restart). The actual TermService
|
||||
# cycle is deferred to install.bat's tail, AFTER all other setup
|
||||
# (launcher staging, HKCU\Run, agent spawn, setup_done.txt) has
|
||||
# committed -- so when the cycle eventually does kill install.bat,
|
||||
# everything we needed to write is already on disk.
|
||||
#
|
||||
# Detached mode (-Detached, runtime path from `winpodx pod multi-
|
||||
# session on`): cycle synchronously here. We're already a detached
|
||||
# wscript-spawned process; the host's /exec response landed before
|
||||
# we even started, so killing our session doesn't lose anything.
|
||||
|
||||
if (-not $Detached) {
|
||||
Append-Log 'OEM mode: skipping TermService cycle (install.bat will do it last)'
|
||||
Set-Status 'patched-pending-cycle'
|
||||
Append-Log 'FINAL: patched-pending-cycle (TermService cycle deferred to install.bat tail)'
|
||||
exit 0
|
||||
}
|
||||
|
||||
Append-Log 'restarting TermService to load termwrap.dll'
|
||||
$stopOut = & cmd.exe /c 'net stop TermService /y' 2>&1
|
||||
Append-Log ($stopOut | Out-String).TrimEnd()
|
||||
$startOut = & cmd.exe /c 'net start TermService' 2>&1
|
||||
Append-Log ($startOut | Out-String).TrimEnd()
|
||||
|
||||
# Verify ServiceDll actually flipped.
|
||||
Start-Sleep -Seconds 2
|
||||
$svcDll = (Get-ItemProperty `
|
||||
-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\TermService\Parameters' `
|
||||
-Name ServiceDll -ErrorAction SilentlyContinue).ServiceDll
|
||||
Append-Log "ServiceDll=$svcDll"
|
||||
|
||||
if ($svcDll -match 'termwrap') {
|
||||
Set-Status 'enabled'
|
||||
Append-Log 'FINAL: enabled'
|
||||
exit 0
|
||||
} else {
|
||||
Set-Status 'not-activated'
|
||||
Append-Log 'FINAL: not-activated (ServiceDll did not flip after TermService cycle)'
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version=0.3.0
|
||||
filename=rdprrap-0.3.0-windows-x64.zip
|
||||
sha256=22f2c25230d40bfaa3290bc0ab43d66ae4314eb09c9545ed983f3a7e95b1fbb3
|
||||
@@ -0,0 +1,514 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# =====================================================================
|
||||
# winpodx reverse-open — register the Linux app handlers in Windows.
|
||||
#
|
||||
# Reads `C:\Users\Public\winpodx\reverse-open\apps.json` (synced from
|
||||
# the host) and creates per-app "Open with..." entries that surface each
|
||||
# Linux app in Windows Explorer's right-click menu for the MIME
|
||||
# extensions it advertises.
|
||||
#
|
||||
# Why per-app .exe hard links
|
||||
# ----------------------------
|
||||
# 1. Distinct binary paths per app. Earlier revisions registered each
|
||||
# Linux app as a winpodx-<slug> ProgID whose `shell\open\command`
|
||||
# invoked powershell.exe (and later wscript.exe) with the shim
|
||||
# script. Windows' Open With menu deduplicates by underlying EXE
|
||||
# path, so all N entries collapsed into a single item. A per-slug
|
||||
# binary path is the fix.
|
||||
#
|
||||
# 2. .exe (Rust shim, `windows_subsystem = "windows"`) is what we
|
||||
# register now. Console-less PE subsystem = no flash when the user
|
||||
# clicks "Open with → <app>". Earlier .cmd/.vbs wrappers flashed
|
||||
# cmd.exe or used wscript's generic icon; the Rust .exe has neither
|
||||
# problem AND its embedded icon can be overridden per slug via the
|
||||
# `Applications\<exe>\DefaultIcon` registry value.
|
||||
#
|
||||
# 3. NTFS hard links keep the disk footprint flat. The shim is one
|
||||
# physical binary; per-slug files (`winpodx-<slug>.exe`) are hard
|
||||
# links to the same inode. 95 apps cost ~300 KB on disk total, not
|
||||
# 300 KB × 95 = 28 MB. The shim reads its own filename at runtime
|
||||
# (`std::env::current_exe`) to figure out which slug it represents.
|
||||
#
|
||||
# Icon surface — embedded in the per-slug EXE (rcedit)
|
||||
# -----------------------------------------------------
|
||||
# Earlier revisions tried `Applications\<exe>\DefaultIcon` and a
|
||||
# per-slug ProgID with `\DefaultIcon` — neither is honoured by the
|
||||
# Win10/Win11 "Open with…" chooser when rendering entries registered
|
||||
# under `Applications\<exe>`. Explorer reads the chooser entry icon
|
||||
# from the EXE's embedded PE resource, period.
|
||||
#
|
||||
# The fix: each per-slug `winpodx-<slug>.exe` is now an INDEPENDENT
|
||||
# COPY of the shim (NOT a hard link) whose icon resource has been
|
||||
# overwritten with `<slug>.ico` via vendored `rcedit.exe`. PR #165's
|
||||
# inode-sharing optimisation is sacrificed (~500 KB × N apps on disk)
|
||||
# in exchange for icons that actually show up.
|
||||
#
|
||||
# Registration surfaces stay the same:
|
||||
#
|
||||
# a. `HKCU\Software\Classes\Applications\<exe>\` — FriendlyAppName +
|
||||
# SupportedTypes + shell\open\command. Required for the entry to
|
||||
# appear in the "Open with → Choose another app" long dialog at
|
||||
# all.
|
||||
#
|
||||
# b. `HKCU\Software\Classes\<ext>\OpenWithList\<exe>` — SUB-KEY (NOT
|
||||
# a value). Drives the inline short "Open with" menu visibility
|
||||
# (Win10 + Win11). #166's fix.
|
||||
#
|
||||
# c. `HKCU\Software\Classes\winpodx-<slug>` — per-slug ProgID with
|
||||
# shell\open\command, linked to each extension via
|
||||
# `<ext>\OpenWithProgids\winpodx-<slug>`. No `DefaultIcon` —
|
||||
# Explorer ignores it for the chooser anyway, and the embedded
|
||||
# EXE icon already covers every chooser path.
|
||||
# =====================================================================
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$AppsJson = 'C:\Users\Public\winpodx\reverse-open\apps.json',
|
||||
[string]$IconsDir = 'C:\Users\Public\winpodx\reverse-open\icons',
|
||||
[string]$BinDir = 'C:\Users\Public\winpodx\reverse-open\bin',
|
||||
# Rust-built reverse-open shim (PE subsystem = windows, no console
|
||||
# flash, custom icon overridable per-slug via the Applications
|
||||
# subkey's DefaultIcon value). Each per-slug entry is a hard
|
||||
# link to this single binary.
|
||||
[string]$ShimExe = 'C:\Users\Public\winpodx\reverse-open\bin\winpodx-reverse-open-shim.exe',
|
||||
# rcedit (electron/rcedit, MIT) — patches a per-slug `.exe` copy
|
||||
# to embed the matching `.ico` as its icon resource. Without this
|
||||
# the Open With chooser falls back to the generic .exe glyph.
|
||||
[string]$RcEditExe = 'C:\Users\Public\winpodx\reverse-open\bin\rcedit.exe',
|
||||
[string]$StartMenuDir = $(Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Linux Apps'),
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- helpers --------------------------------------------------------------
|
||||
|
||||
function Write-LogLine([string]$Level, [string]$Msg) {
|
||||
$ts = (Get-Date).ToUniversalTime().ToString('o')
|
||||
Write-Host "$ts [$Level] $Msg"
|
||||
}
|
||||
|
||||
function Test-SlugValid([string]$Slug) {
|
||||
return $Slug -match '^[a-z0-9-]+$'
|
||||
}
|
||||
|
||||
function Set-DefaultValue([string]$Key, [string]$Value) {
|
||||
# PowerShell's New-ItemProperty -Name '(default)' creates a value
|
||||
# named LITERALLY "(default)" -- it does NOT set the real unnamed
|
||||
# default value. The canonical way is Set-Item -Value, which the
|
||||
# registry provider routes to the default.
|
||||
if (-not (Test-Path -LiteralPath $Key)) {
|
||||
New-Item -Path $Key -Force | Out-Null
|
||||
}
|
||||
Set-Item -LiteralPath $Key -Value $Value
|
||||
}
|
||||
|
||||
function Set-NamedValue([string]$Key, [string]$Name, [string]$Value) {
|
||||
if (-not (Test-Path -LiteralPath $Key)) {
|
||||
New-Item -Path $Key -Force | Out-Null
|
||||
}
|
||||
New-ItemProperty -Path $Key -Name $Name -Value $Value -PropertyType String -Force | Out-Null
|
||||
}
|
||||
|
||||
# Curated MIME → extension table covering the most common types.
|
||||
# Long-tail types fall through to a per-type-string fallback below
|
||||
# (`Resolve-MimeExtensions`).
|
||||
$script:DefaultMimeExt = @{
|
||||
'text/plain' = @('.txt')
|
||||
'text/xml' = @('.xml')
|
||||
'text/html' = @('.html', '.htm')
|
||||
'text/css' = @('.css')
|
||||
'text/markdown' = @('.md', '.markdown')
|
||||
'application/json' = @('.json')
|
||||
'application/pdf' = @('.pdf')
|
||||
'application/xml' = @('.xml')
|
||||
'application/zip' = @('.zip')
|
||||
'image/png' = @('.png')
|
||||
'image/jpeg' = @('.jpg', '.jpeg')
|
||||
'image/gif' = @('.gif')
|
||||
'image/svg+xml' = @('.svg')
|
||||
'image/webp' = @('.webp')
|
||||
'image/bmp' = @('.bmp')
|
||||
'image/tiff' = @('.tif', '.tiff')
|
||||
'audio/mpeg' = @('.mp3')
|
||||
'audio/ogg' = @('.ogg')
|
||||
'audio/flac' = @('.flac')
|
||||
'audio/wav' = @('.wav')
|
||||
'video/mp4' = @('.mp4')
|
||||
'video/webm' = @('.webm')
|
||||
'video/x-matroska' = @('.mkv')
|
||||
'video/quicktime' = @('.mov')
|
||||
}
|
||||
|
||||
function Resolve-MimeExtensions([string]$Mime) {
|
||||
if ($script:DefaultMimeExt.ContainsKey($Mime)) {
|
||||
return $script:DefaultMimeExt[$Mime]
|
||||
}
|
||||
if ($Mime -match '^[a-z]+/(.+)$') {
|
||||
return @(".${matches[1]}".ToLowerInvariant())
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
# --- main -----------------------------------------------------------------
|
||||
|
||||
Write-LogLine 'INFO' "reading apps from $AppsJson"
|
||||
if (-not (Test-Path -LiteralPath $AppsJson)) {
|
||||
Write-LogLine 'ERROR' 'apps.json missing — nothing to register'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$manifest = $null
|
||||
try {
|
||||
$manifest = Get-Content -LiteralPath $AppsJson -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
|
||||
} catch {
|
||||
Write-LogLine 'ERROR' "apps.json parse failed: $($_.Exception.Message)"
|
||||
exit 2
|
||||
}
|
||||
|
||||
if ($null -eq $manifest -or -not $manifest.PSObject.Properties['apps']) {
|
||||
Write-LogLine 'ERROR' 'apps.json has no apps array'
|
||||
exit 2
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ShimExe)) {
|
||||
Write-LogLine 'ERROR' "shim binary missing at $ShimExe — refusing to register handlers that point at a nonexistent path"
|
||||
exit 3
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RcEditExe)) {
|
||||
Write-LogLine 'ERROR' "rcedit binary missing at $RcEditExe — required to embed per-slug icons into the per-slug .exe copies"
|
||||
exit 3
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BinDir)) {
|
||||
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Helper: stage a per-slug .exe by copying the source shim and then
|
||||
# embedding the per-slug icon into its PE resource section. Returns
|
||||
# $true on success, $false on failure (logged).
|
||||
#
|
||||
# The copy step replaces the earlier hard-link approach. Hard links
|
||||
# share an inode, which means an icon embedded into one name would
|
||||
# also appear on every other name pointing at the same inode — not
|
||||
# what we want. We need N independent .exe files with N different
|
||||
# embedded icons, so each must be a real copy.
|
||||
function New-PerSlugExe([string]$ExePath, [string]$IconPath) {
|
||||
if (Test-Path -LiteralPath $ExePath) {
|
||||
Remove-Item -LiteralPath $ExePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
try {
|
||||
Copy-Item -LiteralPath $ShimExe -Destination $ExePath -Force -ErrorAction Stop
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not copy shim to ${ExePath}: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
if (-not [string]::IsNullOrEmpty($IconPath) -and (Test-Path -LiteralPath $IconPath)) {
|
||||
try {
|
||||
$rcOutput = & $RcEditExe $ExePath --set-icon $IconPath 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-LogLine 'WARN' "rcedit failed for ${ExePath} (rc=$LASTEXITCODE): $rcOutput"
|
||||
return $false
|
||||
}
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "rcedit invocation failed for ${ExePath}: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
$registered = 0
|
||||
$skipped = 0
|
||||
foreach ($app in $manifest.apps) {
|
||||
$slug = [string]$app.slug
|
||||
if (-not (Test-SlugValid $slug)) {
|
||||
Write-LogLine 'WARN' "skip invalid slug '$slug'"
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
$name = [string]$app.name
|
||||
if ([string]::IsNullOrWhiteSpace($name)) { $name = $slug }
|
||||
|
||||
# Honour the user's Linux-side default-handler choices from
|
||||
# ~/.config/mimeapps.list. Registering an app for every MIME it
|
||||
# *can* handle would flood the Windows "Open with" menu with
|
||||
# entries for every editor / image viewer / etc. -- noisy and
|
||||
# actively unhelpful. Instead, surface ONLY the apps the user has
|
||||
# explicitly set as their default on Linux, and only for the
|
||||
# extensions matching those MIME types.
|
||||
#
|
||||
# An app with empty `is_default_for` (the user hasn't picked it
|
||||
# as default for anything) is skipped entirely. The user can still
|
||||
# widen the registration scope later via the host-side allowlist
|
||||
# surface; the design doc covers that path in Phase 4.
|
||||
$mimes = @()
|
||||
if ($app.PSObject.Properties['is_default_for']) {
|
||||
foreach ($m in $app.is_default_for) { $mimes += [string]$m }
|
||||
}
|
||||
if ($mimes.Count -eq 0) {
|
||||
Write-LogLine 'INFO' "skip $slug — not the Linux default for any MIME type"
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
$icoPath = Join-Path $IconsDir "$slug.ico"
|
||||
$exeName = "winpodx-$slug.exe"
|
||||
$exePath = Join-Path $BinDir $exeName
|
||||
$friendly = "Open with $name (Linux)"
|
||||
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would copy $ShimExe -> $exePath + embed $icoPath + register $friendly for $($mimes -join ',')"
|
||||
$registered++
|
||||
continue
|
||||
}
|
||||
|
||||
# Stage the per-slug .exe as an independent copy with the per-slug
|
||||
# icon embedded into its PE resource section. Without the embed,
|
||||
# the Open With chooser entry renders with the generic .exe icon.
|
||||
[void](New-PerSlugExe -ExePath $exePath -IconPath $icoPath)
|
||||
if (-not (Test-Path -LiteralPath $icoPath)) {
|
||||
Write-LogLine 'WARN' "icon missing for $slug at $icoPath — chooser entry will use the unmodified shim icon"
|
||||
}
|
||||
|
||||
$appRoot = "HKCU:\Software\Classes\Applications\$exeName"
|
||||
Set-NamedValue $appRoot 'FriendlyAppName' $friendly
|
||||
Set-DefaultValue (Join-Path $appRoot 'shell\open\command') "`"$exePath`" `"%1`""
|
||||
|
||||
# --- per-slug ProgID (long-dialog surface) ---------------------------
|
||||
# The ProgID anchors the `<ext>\OpenWithProgids\winpodx-<slug>`
|
||||
# value (written in the per-extension loop below) so the entry
|
||||
# surfaces in both the short Open With menu and the long "Choose
|
||||
# another app" dialog. DefaultIcon intentionally omitted — Explorer
|
||||
# ignores it for chooser entries, and the embedded EXE icon
|
||||
# already covers every chooser path.
|
||||
$progIdRoot = "HKCU:\Software\Classes\winpodx-$slug"
|
||||
Set-DefaultValue $progIdRoot $friendly
|
||||
Set-NamedValue $progIdRoot 'FriendlyTypeName' $friendly
|
||||
Set-DefaultValue (Join-Path $progIdRoot 'shell\open\command') "`"$exePath`" `"%1`""
|
||||
|
||||
# SupportedTypes lists every extension this app handles. The
|
||||
# value name is the extension; the value content is conventionally
|
||||
# empty. Windows uses this to decide whether to display the entry
|
||||
# in "Open with" for a given file type.
|
||||
$stKey = Join-Path $appRoot 'SupportedTypes'
|
||||
if (-not (Test-Path -LiteralPath $stKey)) {
|
||||
New-Item -Path $stKey -Force | Out-Null
|
||||
}
|
||||
|
||||
$exts = New-Object System.Collections.Generic.HashSet[string]
|
||||
foreach ($mime in $mimes) {
|
||||
foreach ($ext in Resolve-MimeExtensions $mime) {
|
||||
if (-not $ext.StartsWith('.')) { continue }
|
||||
$extLower = $ext.ToLowerInvariant()
|
||||
if ($exts.Add($extLower)) {
|
||||
New-ItemProperty -Path $stKey -Name $extLower -Value '' -PropertyType String -Force | Out-Null
|
||||
# OpenWithList — Windows convention is a SUB-KEY named
|
||||
# after the executable, NOT a value under OpenWithList.
|
||||
# The values under \OpenWithList\ are the MRU list (a,
|
||||
# b, c, MRUList), which is for tracking the user's most
|
||||
# recently used picks — Explorer reads the sub-key
|
||||
# names to populate the inline short "Open with" menu.
|
||||
# Writing a value here makes us invisible to the short
|
||||
# menu (it appears only in the long "Choose another
|
||||
# app" dialog).
|
||||
$extKey = "HKCU:\Software\Classes\$extLower\OpenWithList\$exeName"
|
||||
if (-not (Test-Path -LiteralPath $extKey)) {
|
||||
New-Item -Path $extKey -Force | Out-Null
|
||||
}
|
||||
# OpenWithProgids — VALUE (not sub-key). Names the
|
||||
# per-slug ProgID we registered above so Explorer
|
||||
# resolves the entry's icon from the ProgID's
|
||||
# DefaultIcon. Empty REG_NONE value is the documented
|
||||
# convention.
|
||||
$owpKey = "HKCU:\Software\Classes\$extLower\OpenWithProgids"
|
||||
if (-not (Test-Path -LiteralPath $owpKey)) {
|
||||
New-Item -Path $owpKey -Force | Out-Null
|
||||
}
|
||||
New-ItemProperty -Path $owpKey -Name "winpodx-$slug" -Value ([byte[]]@()) -PropertyType None -Force | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-LogLine 'INFO' "registered $slug (exe=$exeName) for $($exts.Count) extension(s)"
|
||||
$registered++
|
||||
}
|
||||
|
||||
# --- Start Menu shortcuts for ALL discovered apps --------------------------
|
||||
#
|
||||
# Per-user Linux Apps menu folder. Carries every discovered app
|
||||
# (regardless of whether the Linux user designated it as the default
|
||||
# for any MIME type) so:
|
||||
# 1. The apps launch directly from Start Menu (no file argument
|
||||
# needed -- the .cmd handles missing %1 gracefully).
|
||||
# 2. The user can pick a non-default Linux app for one-shot file
|
||||
# open by going "Right-click → Open with → Choose another app
|
||||
# → Look for another app on this PC" and browsing to
|
||||
# %APPDATA%\Microsoft\Windows\Start Menu\Programs\Linux Apps
|
||||
# to select a .lnk.
|
||||
#
|
||||
# This is the spiritual answer to "default가 없는 앱들은 어떻게
|
||||
# 할까?" -- the Linux defaults stream into the canonical Windows
|
||||
# "Open with" menu; the rest land in a discoverable Start Menu
|
||||
# folder.
|
||||
|
||||
$startMenuCount = 0
|
||||
if (-not $DryRun) {
|
||||
if (-not (Test-Path -LiteralPath $StartMenuDir)) {
|
||||
New-Item -ItemType Directory -Path $StartMenuDir -Force | Out-Null
|
||||
}
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
|
||||
foreach ($app in $manifest.apps) {
|
||||
$slug = [string]$app.slug
|
||||
if (-not (Test-SlugValid $slug)) { continue }
|
||||
$name = [string]$app.name
|
||||
if ([string]::IsNullOrWhiteSpace($name)) { $name = $slug }
|
||||
$icoPath = Join-Path $IconsDir "$slug.ico"
|
||||
$exePath = Join-Path $BinDir "winpodx-$slug.exe"
|
||||
|
||||
# If the per-app .exe doesn't exist (because the app was
|
||||
# skipped at the registration pass for having no Linux
|
||||
# defaults), stage it now so the shortcut has a target —
|
||||
# icon embedded if available, plain copy otherwise.
|
||||
if (-not (Test-Path -LiteralPath $exePath)) {
|
||||
[void](New-PerSlugExe -ExePath $exePath -IconPath $icoPath)
|
||||
}
|
||||
|
||||
# Sanitise the name for use as a filename — strip illegal
|
||||
# chars and trim. Display label stays in the .lnk's
|
||||
# Description (visible in tooltips).
|
||||
$safeName = ($name -replace '[\\/:*?"<>|]', '_').Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($safeName)) { $safeName = $slug }
|
||||
$lnkPath = Join-Path $StartMenuDir "$safeName.lnk"
|
||||
|
||||
try {
|
||||
$lnk = $shell.CreateShortcut($lnkPath)
|
||||
$lnk.TargetPath = $exePath
|
||||
$lnk.Description = "$name (Linux)"
|
||||
if (Test-Path -LiteralPath $icoPath) {
|
||||
$lnk.IconLocation = "$icoPath,0"
|
||||
}
|
||||
$lnk.Save()
|
||||
$startMenuCount++
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not write shortcut for ${slug}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Desktop folder shortcut + Quick Access pin ----------------------------
|
||||
#
|
||||
# Win11's "Open with → Choose another app → Look for another app on
|
||||
# this PC" surface is a regular file-browse dialog. Without a hint, the
|
||||
# user has to navigate to %APPDATA%\Microsoft\Windows\Start Menu\
|
||||
# Programs\Linux Apps\ — long path, not obvious. We drop two
|
||||
# discoverability aids that point at the existing $StartMenuDir:
|
||||
#
|
||||
# 1. Desktop\Linux Apps.lnk — folder shortcut on the desktop. The
|
||||
# dialog's left sidebar always has Desktop; one click + one
|
||||
# double-click gets the user to the .lnk list.
|
||||
# 2. Quick Access pin — adds the folder to File Explorer's left
|
||||
# sidebar (and every Common Item Dialog inherits that sidebar).
|
||||
# Implemented via the documented "pintohome" shell verb.
|
||||
#
|
||||
# Both are best-effort: failure logs a WARN but doesn't fail the
|
||||
# script, since the per-extension Open With registration above is
|
||||
# what actually delivers the feature.
|
||||
$folderShortcuts = 0
|
||||
if (-not $DryRun -and (Test-Path -LiteralPath $StartMenuDir)) {
|
||||
# Desktop folder shortcut.
|
||||
try {
|
||||
$desktopDir = [Environment]::GetFolderPath('Desktop')
|
||||
if ($desktopDir -and (Test-Path -LiteralPath $desktopDir)) {
|
||||
$desktopLnk = Join-Path $desktopDir 'Linux Apps.lnk'
|
||||
$lnk = $shell.CreateShortcut($desktopLnk)
|
||||
$lnk.TargetPath = $StartMenuDir
|
||||
$lnk.Description = 'Linux apps available via WinPodX'
|
||||
# Use the generic shell32 folder icon (index 4) so the
|
||||
# shortcut renders as a normal folder. Trying to embed our
|
||||
# own .ico here would require shipping a winpodx-folder.ico;
|
||||
# the generic folder glyph is sufficient and unsurprising.
|
||||
$lnk.IconLocation = 'shell32.dll,4'
|
||||
$lnk.Save()
|
||||
$folderShortcuts++
|
||||
Write-LogLine 'INFO' "wrote Desktop folder shortcut: $desktopLnk"
|
||||
}
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not write Desktop folder shortcut: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Quick Access pin via Shell.Application's "pintohome" verb.
|
||||
# This is the documented mechanism for File Explorer's "Pin to
|
||||
# Quick access" right-click action.
|
||||
try {
|
||||
$shellApp = New-Object -ComObject Shell.Application
|
||||
$folder = $shellApp.Namespace($StartMenuDir)
|
||||
if ($folder) {
|
||||
$item = $folder.Self
|
||||
# Look for the localised verb; fall back to the canonical
|
||||
# English name. Localised systems (e.g. ko-KR
|
||||
# "즐겨찾기에 고정") still respond to "pintohome" when
|
||||
# invoked programmatically.
|
||||
$verb = $item.Verbs() | Where-Object { $_.Name -replace '&', '' -match '(pintohome|Pin to Quick access|즐겨찾기에 고정)' } | Select-Object -First 1
|
||||
if ($null -eq $verb) {
|
||||
# Direct invoke by canonical name — works on all locales.
|
||||
$item.InvokeVerb('pintohome')
|
||||
} else {
|
||||
$verb.DoIt()
|
||||
}
|
||||
$folderShortcuts++
|
||||
Write-LogLine 'INFO' "pinned Linux Apps to Quick Access"
|
||||
}
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not pin to Quick Access: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# --- invalidate Explorer's Open With cache --------------------------------
|
||||
#
|
||||
# Win11 caches the per-extension Application list aggressively. After
|
||||
# writing OpenWithList sub-keys + Applications\<exe>\SupportedTypes,
|
||||
# the next right-click → "Open with" can still surface a stale list
|
||||
# until the user logs off / on, OR until `ie4uinit.exe -show` is
|
||||
# invoked. `-show` is documented as "Refresh icon cache + Open With
|
||||
# list" and lands instantly (no UI side-effect for the user).
|
||||
#
|
||||
# Best-effort: missing binary OR non-zero exit is logged but doesn't
|
||||
# fail the script — the registration itself is still durable. We also
|
||||
# poke the Shell.Application "Refresh" to nudge any open Explorer
|
||||
# windows.
|
||||
if (-not $DryRun) {
|
||||
try {
|
||||
$ie4uinit = Join-Path $env:SystemRoot 'System32\ie4uinit.exe'
|
||||
if (Test-Path -LiteralPath $ie4uinit) {
|
||||
& $ie4uinit -show 2>&1 | Out-Null
|
||||
Write-LogLine 'INFO' 'invalidated Open With chooser cache'
|
||||
} else {
|
||||
Write-LogLine 'WARN' "ie4uinit not at $ie4uinit — chooser cache may be stale until next logon"
|
||||
}
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "ie4uinit refresh failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, 0, 0) — the
|
||||
# canonical "file associations changed, refresh per-entry icons"
|
||||
# signal. `ie4uinit -show` covers the OpenWith MRU list but does
|
||||
# NOT invalidate the shell icon cache; without this P/Invoke,
|
||||
# per-slug ProgID DefaultIcons land in the registry but Explorer
|
||||
# keeps painting the previous (often generic) icon until next
|
||||
# logon. Shell32 export, present on every supported Windows.
|
||||
try {
|
||||
Add-Type -Namespace WinPodx -Name Shell32Ext -MemberDefinition @'
|
||||
[System.Runtime.InteropServices.DllImport("shell32.dll")]
|
||||
public static extern void SHChangeNotify(int wEventId, uint uFlags, System.IntPtr dwItem1, System.IntPtr dwItem2);
|
||||
'@ -ErrorAction Stop
|
||||
# SHCNE_ASSOCCHANGED = 0x08000000; SHCNF_IDLIST = 0x0000
|
||||
[WinPodx.Shell32Ext]::SHChangeNotify(0x08000000, 0x0000, [System.IntPtr]::Zero, [System.IntPtr]::Zero)
|
||||
Write-LogLine 'INFO' 'SHChangeNotify(SHCNE_ASSOCCHANGED) — icon cache invalidated'
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "SHChangeNotify failed (icons may need re-logon to refresh): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-LogLine 'INFO' "done. registered=$registered skipped=$skipped start_menu=$startMenuCount folder_shortcuts=$folderShortcuts"
|
||||
exit 0
|
||||
@@ -0,0 +1,5 @@
|
||||
# Cargo build artifacts. The release-mode .exe is checked in under
|
||||
# bin/ (committed binary, shipped with the wheel + distro packages);
|
||||
# everything else under target/ is regenerable.
|
||||
/target/
|
||||
Cargo.lock
|
||||
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "winpodx-reverse-open-shim"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
description = "Stub binary that Windows Explorer invokes from 'Open with' to relay a file-open request back to the Linux host's reverse-open listener."
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
# Crypto-quality randomness for the request UUID. On Windows targets
|
||||
# this routes to BCryptGenRandom; no other deps.
|
||||
getrandom = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
# Embeds a Windows VERSIONINFO resource (CompanyName / ProductName /
|
||||
# FileDescription / version) into the PE at build time (#425). A stripped,
|
||||
# metadata-less binary scores worse on AV heuristics; stamping real
|
||||
# publisher/product info is a cheap signal that this is a legitimate tool.
|
||||
# Only runs for windows targets; uses windres / llvm-rc as the resource
|
||||
# compiler (windres ships with the same mingw toolchain that links the
|
||||
# x86_64-pc-windows-gnu target).
|
||||
winresource = "0.1"
|
||||
|
||||
[profile.release]
|
||||
# Optimise hard for size — the shim ships as N hard links to the same
|
||||
# binary, so one copy of the .exe lives on the guest. Smaller is
|
||||
# better because we ALSO ship the bytes through the agent /exec
|
||||
# bearer-auth pipe (base64-encoded JSON in the snippet), and the
|
||||
# agent body cap is finite.
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,20 @@
|
||||
Copyright (c) 2013 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//! Embed a Windows VERSIONINFO resource into the shim PE at build time.
|
||||
//!
|
||||
//! The reverse-open shim is a tiny, stripped, unsigned binary, which trips
|
||||
//! AV ML heuristics as a false positive (#425). A metadata-less PE scores
|
||||
//! worse than one carrying real publisher/product strings, so we stamp
|
||||
//! CompanyName / ProductName / FileDescription / version into the binary.
|
||||
//! It's not a substitute for code-signing, but it's a cheap legitimacy
|
||||
//! signal and costs nothing at runtime.
|
||||
//!
|
||||
//! Resource embedding only applies to Windows targets; on any other target
|
||||
//! this build script is a no-op. `winresource` shells out to `windres`
|
||||
//! (mingw) or `llvm-rc` to compile the resource.
|
||||
|
||||
fn main() {
|
||||
// CARGO_CFG_TARGET_OS is "windows" for x86_64-pc-windows-{gnu,msvc}.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
// FileVersion / ProductVersion are taken from CARGO_PKG_VERSION by
|
||||
// default; the string fields below are what an AV / the file's
|
||||
// Properties → Details tab will show.
|
||||
res.set("CompanyName", "Kim DaeHyun")
|
||||
.set("ProductName", "WinPodX")
|
||||
.set(
|
||||
"FileDescription",
|
||||
"WinPodX reverse-open shim — relays a Windows file-open back to the Linux host",
|
||||
)
|
||||
.set("InternalName", "winpodx-reverse-open-shim")
|
||||
.set("OriginalFilename", "winpodx-reverse-open-shim.exe")
|
||||
.set(
|
||||
"LegalCopyright",
|
||||
"Copyright (c) 2026 Kim DaeHyun. MIT licensed. https://github.com/kernalix7/winpodx",
|
||||
);
|
||||
|
||||
if let Err(e) = res.compile() {
|
||||
// Don't hard-fail the build if the resource compiler is missing on a
|
||||
// contributor's machine — the binary still works, it just lacks the
|
||||
// metadata. CI / release builds (which have the toolchain) get the
|
||||
// resource; ad-hoc builds degrade gracefully.
|
||||
println!("cargo:warning=winresource: could not embed version metadata: {e}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! winpodx reverse-open shim — Windows-side stub.
|
||||
//!
|
||||
//! Invoked by Windows Explorer when the user picks a Linux app from
|
||||
//! the right-click "Open with…" menu. Single responsibility: parse
|
||||
//! its own filename + argv, write a JSON request to the host
|
||||
//! listener's incoming directory (atomic .tmp + rename), exit.
|
||||
//!
|
||||
//! ## Per-slug identity from filename
|
||||
//!
|
||||
//! The same binary is hard-linked under N filenames
|
||||
//! (`winpodx-<slug>.exe` for each app). The shim discovers its slug
|
||||
//! by inspecting `argv[0]` / `current_exe()` — stripping the
|
||||
//! `winpodx-` prefix and `.exe` suffix gives the slug to embed in
|
||||
//! the request. Sharing one inode keeps the on-disk footprint flat
|
||||
//! regardless of how many Linux apps are registered.
|
||||
//!
|
||||
//! ## No console flash
|
||||
//!
|
||||
//! `#![windows_subsystem = "windows"]` flips the PE subsystem to GUI
|
||||
//! so launching the shim does NOT spawn a console window. The earlier
|
||||
//! `.cmd` / `.vbs` wrappers were workarounds for the same problem;
|
||||
//! Rust + `windows_subsystem` is the canonical fix.
|
||||
//!
|
||||
//! ## Atomic write
|
||||
//!
|
||||
//! Request files land at `\\tsclient\home\.local\share\winpodx\
|
||||
//! reverse-open\incoming\<uuid>.json.tmp` and are renamed to
|
||||
//! `<uuid>.json` only after the bytes are flushed to disk. The host
|
||||
//! listener only matches files named `<uuid>.json` (not `.tmp`), so a
|
||||
//! partial write can't trigger a spawn.
|
||||
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Where the host's reverse-open listener watches for request files.
|
||||
/// Reachable from the guest via the FreeRDP `+home-drive` redirect.
|
||||
const INCOMING_DIR_UNC: &str = r"\\tsclient\home\.local\share\winpodx\reverse-open\incoming";
|
||||
|
||||
/// Filename prefix and suffix the slug-from-filename parser expects.
|
||||
const SLUG_FILENAME_PREFIX: &str = "winpodx-";
|
||||
const SLUG_FILENAME_SUFFIX: &str = ".exe";
|
||||
|
||||
/// Exit codes — kept in sync with the integration tests that check
|
||||
/// the shim's behaviour on bad input.
|
||||
const EXIT_BAD_ARGS: i32 = 2;
|
||||
const EXIT_WRITE_FAIL: i32 = 3;
|
||||
|
||||
fn main() {
|
||||
let slug = match extract_slug() {
|
||||
Some(s) => s,
|
||||
None => process::exit(EXIT_BAD_ARGS),
|
||||
};
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let (path, origin) = match args.get(1) {
|
||||
Some(s) if !s.is_empty() => classify_path(s),
|
||||
// No file argument: the user launched the app directly from its
|
||||
// "Linux Apps" Start-Menu / Desktop shortcut rather than via the
|
||||
// "Open with…" menu on a file. Emit a launch-only request (empty
|
||||
// path, origin "launch") so the host runs the Linux app with no
|
||||
// file argument instead of the shim silently exiting (#616).
|
||||
_ => (String::new(), "launch"),
|
||||
};
|
||||
|
||||
let uid = new_uuid();
|
||||
let ts = format!(
|
||||
"{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
|
||||
let json = build_request_json(&slug, &path, origin, &ts);
|
||||
|
||||
let tmp = format!("{}\\{}.json.tmp", INCOMING_DIR_UNC, uid);
|
||||
let final_path = format!("{}\\{}.json", INCOMING_DIR_UNC, uid);
|
||||
|
||||
if fs::write(&tmp, json.as_bytes()).is_err() {
|
||||
process::exit(EXIT_WRITE_FAIL);
|
||||
}
|
||||
if fs::rename(&tmp, &final_path).is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
process::exit(EXIT_WRITE_FAIL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the slug off the binary's own filename.
|
||||
///
|
||||
/// Returns `None` if the binary wasn't named per the expected
|
||||
/// `winpodx-<slug>.exe` convention. Defensive — without the
|
||||
/// prefix/suffix we'd send an empty `app` field that the host
|
||||
/// listener rejects at schema validation anyway, but we prefer to
|
||||
/// fail loudly on the guest with a non-zero exit.
|
||||
fn extract_slug() -> Option<String> {
|
||||
let exe: PathBuf = env::current_exe().ok()?;
|
||||
let base = exe.file_name()?.to_str()?.to_lowercase();
|
||||
let stem = base
|
||||
.strip_prefix(SLUG_FILENAME_PREFIX)?
|
||||
.strip_suffix(SLUG_FILENAME_SUFFIX)?;
|
||||
if stem.is_empty() || stem == "reverse-open-shim" {
|
||||
// Refuse to fire when the shim is invoked by its bare
|
||||
// filename — that's the source binary copy in
|
||||
// `bin\winpodx-reverse-open-shim.exe`, not a per-app
|
||||
// hard link, so there's no slug to attach.
|
||||
return None;
|
||||
}
|
||||
Some(stem.to_string())
|
||||
}
|
||||
|
||||
/// Classify the file argument and produce the `(path, origin)` pair the
|
||||
/// host listener consumes.
|
||||
///
|
||||
/// - A `\\…` UNC path (in practice `\\tsclient\home\…`) is a **host**
|
||||
/// file reached through FreeRDP's `+home-drive` redirect. Passed
|
||||
/// through verbatim with origin `"host"`; the host resolves it to its
|
||||
/// `$HOME`-relative path.
|
||||
/// - A drive-letter path (`C:\…`, `D:/…`) is a file on the **guest's own
|
||||
/// disk**. The guest disk is exposed to the host as a network mount, so
|
||||
/// the original Windows path is sent verbatim with origin `"guest"` and
|
||||
/// the host maps it onto that mount.
|
||||
/// - Any other shape passes through as `"host"`; the host validator
|
||||
/// rejects it if it isn't reachable under an active share root.
|
||||
///
|
||||
/// Earlier versions rewrote *every* drive letter to `\\tsclient\home\…`,
|
||||
/// which only makes sense if `$HOME` were the root of that drive. It never
|
||||
/// is for `C:`, so a guest-local file (e.g. the Windows Desktop) resolved
|
||||
/// to a non-existent host path and silently failed (#616).
|
||||
fn classify_path(file: &str) -> (String, &'static str) {
|
||||
if file.starts_with(r"\\") {
|
||||
return (file.to_string(), "host");
|
||||
}
|
||||
let bytes = file.as_bytes();
|
||||
if bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/') {
|
||||
// Guest-local drive path — normalise separators, keep the drive.
|
||||
return (file.replace('/', r"\"), "guest");
|
||||
}
|
||||
(file.to_string(), "host")
|
||||
}
|
||||
|
||||
/// Generate a UUIDv4 hex string without dashes.
|
||||
///
|
||||
/// Sources 16 random bytes via the OS RNG (BCryptGenRandom on
|
||||
/// Windows). Stamps the version (0b0100 in byte 6) and variant
|
||||
/// (0b10 in byte 8) nibbles per RFC 4122, then formats as 32 lower-
|
||||
/// case hex chars. The listener's regex
|
||||
/// (`[0-9a-fA-F-]{8,64}.json`) accepts dash-free or dashed forms.
|
||||
fn new_uuid() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
getrandom::getrandom(&mut bytes).expect("getrandom() failed");
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
let mut out = String::with_capacity(32);
|
||||
for b in bytes.iter() {
|
||||
out.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the request JSON payload the host listener expects.
|
||||
///
|
||||
/// Schema (kept in sync with
|
||||
/// `winpodx.reverse_open.listener._validate_schema`):
|
||||
/// version: 2
|
||||
/// app: <slug> (^[a-z0-9-]+$)
|
||||
/// path: <path> (string, ≤ 4 KB, NUL-free; empty for origin "launch")
|
||||
/// origin: "host"|"guest"|"launch" (host = \\tsclient\… redirect;
|
||||
/// guest = guest disk; launch = run the app with no file)
|
||||
/// ts: <ts> (string, non-empty)
|
||||
/// pod_id: null
|
||||
fn build_request_json(slug: &str, path: &str, origin: &str, ts: &str) -> String {
|
||||
format!(
|
||||
r#"{{"version":2,"app":"{}","path":"{}","origin":"{}","ts":"{}","pod_id":null}}"#,
|
||||
json_escape(slug),
|
||||
json_escape(path),
|
||||
json_escape(origin),
|
||||
json_escape(ts),
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal JSON string escaper — enough for the three fields we
|
||||
/// emit (slug regex doesn't include control chars, path may have
|
||||
/// backslashes the JSON consumer needs to see literally).
|
||||
fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
out.push_str(&format!("\\u{:04x}", c as u32));
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify_unc_is_host() {
|
||||
assert_eq!(
|
||||
classify_path(r"\\tsclient\home\Documents\x"),
|
||||
(r"\\tsclient\home\Documents\x".to_string(), "host")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_drive_letter_is_guest_local() {
|
||||
// The Windows path is kept verbatim (NOT rewritten to
|
||||
// \\tsclient\home) and tagged guest so the host maps it onto
|
||||
// the guest-disk mount. Regression guard for #616.
|
||||
assert_eq!(
|
||||
classify_path(r"C:\Users\User\Desktop\notes.txt"),
|
||||
(r"C:\Users\User\Desktop\notes.txt".to_string(), "guest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_normalises_forward_slashes_keeping_drive() {
|
||||
assert_eq!(
|
||||
classify_path("D:/User/x"),
|
||||
(r"D:\User\x".to_string(), "guest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_json_emits_v2_with_origin() {
|
||||
let j = build_request_json("sublime", r"C:\a\b.txt", "guest", "123");
|
||||
assert!(j.contains(r#""version":2"#));
|
||||
assert!(j.contains(r#""origin":"guest""#));
|
||||
assert!(j.contains(r#""app":"sublime""#));
|
||||
// Backslashes are JSON-escaped.
|
||||
assert!(j.contains(r#""path":"C:\\a\\b.txt""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_json_launch_only_empty_path() {
|
||||
// #616: launching an app with no file emits origin "launch" and an
|
||||
// empty path; the host runs the app with no file argument.
|
||||
let j = build_request_json("sublime", "", "launch", "123");
|
||||
assert!(j.contains(r#""origin":"launch""#));
|
||||
assert!(j.contains(r#""app":"sublime""#));
|
||||
assert!(j.contains(r#""path":"""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_escape_handles_metacharacters() {
|
||||
assert_eq!(json_escape(r#"a"b\c"#), "a\\\"b\\\\c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_uuid_is_32_hex_chars() {
|
||||
let u = new_uuid();
|
||||
assert_eq!(u.len(), 32);
|
||||
assert!(u.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_uuid_marks_v4_variant_bits() {
|
||||
let u = new_uuid();
|
||||
// Char index 12 = nibble (byte 6 high nibble) should be '4'.
|
||||
assert_eq!(u.chars().nth(12), Some('4'));
|
||||
// Char index 16 = nibble (byte 8 high nibble) should be 8|9|a|b.
|
||||
let v = u.chars().nth(16).unwrap();
|
||||
assert!("89ab".contains(v), "variant nibble was {}", v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# =====================================================================
|
||||
# winpodx reverse-open — remove the Linux app handlers from Windows.
|
||||
#
|
||||
# Mirrors register-apps.ps1's three registration surfaces:
|
||||
# * Per-slug ProgID at HKCU\Software\Classes\winpodx-<slug>
|
||||
# (canonical icon source — current scheme)
|
||||
# * Applications\winpodx-<slug>.exe key (FriendlyAppName + command)
|
||||
# * Per-extension OpenWithList\winpodx-<slug>.exe sub-keys AND
|
||||
# OpenWithProgids\winpodx-<slug> value entries
|
||||
# * Per-slug .exe hard links under $BinDir + the source shim binary
|
||||
#
|
||||
# Legacy scrub: earlier revisions used .cmd / .vbs wrappers under
|
||||
# Applications\, and an earlier ProgID iteration with the same name —
|
||||
# walked + removed by the same patterns so re-installing over any
|
||||
# prior layout leaves no orphans.
|
||||
#
|
||||
# Idempotent: missing keys / missing values / missing files are
|
||||
# silently OK.
|
||||
# =====================================================================
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$BinDir = 'C:\Users\Public\winpodx\reverse-open\bin',
|
||||
[string]$StartMenuDir = $(Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Linux Apps'),
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-LogLine([string]$Level, [string]$Msg) {
|
||||
$ts = (Get-Date).ToUniversalTime().ToString('o')
|
||||
Write-Host "$ts [$Level] $Msg"
|
||||
}
|
||||
|
||||
$classesRoot = 'HKCU:\Software\Classes'
|
||||
if (-not (Test-Path -LiteralPath $classesRoot)) {
|
||||
Write-LogLine 'INFO' 'no HKCU classes root — nothing to clean'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- per-slug ProgIDs (current scheme + legacy) ---
|
||||
# Current scheme registers winpodx-<slug> as a bare ProgID directly
|
||||
# under HKCU\Software\Classes (canonical icon surface). An earlier
|
||||
# iteration also used the same shape, so this scrub covers both. We
|
||||
# exclude any winpodx-*.exe / .cmd / .vbs children here because those
|
||||
# belong to the Applications\ scrubber (or to the legacy wrapper file
|
||||
# scrub below).
|
||||
$legacyProgIds = @()
|
||||
try {
|
||||
$legacyProgIds = Get-ChildItem -LiteralPath $classesRoot -ErrorAction Stop |
|
||||
Where-Object {
|
||||
$_.PSChildName -like 'winpodx-*' -and
|
||||
$_.PSChildName -notlike '*.exe' -and
|
||||
$_.PSChildName -notlike '*.cmd' -and
|
||||
$_.PSChildName -notlike '*.vbs'
|
||||
} |
|
||||
Select-Object -ExpandProperty PSChildName
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "enumerate legacy ProgIDs failed: $($_.Exception.Message)"
|
||||
}
|
||||
$removedLegacy = 0
|
||||
foreach ($progId in $legacyProgIds) {
|
||||
$progRoot = Join-Path $classesRoot $progId
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would remove legacy ProgID $progRoot"
|
||||
$removedLegacy++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
Remove-Item -LiteralPath $progRoot -Recurse -Force -ErrorAction Stop
|
||||
Write-LogLine 'INFO' "removed legacy ProgID $progRoot"
|
||||
$removedLegacy++
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not remove ${progRoot}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# --- per-app wrappers under Applications\ (.exe / .cmd / .vbs) ---
|
||||
# Current scheme: .exe hard links. Older revisions used .cmd then
|
||||
# .vbs — we scrub all three patterns so re-installing over an old
|
||||
# layout doesn't leave dangling registry entries.
|
||||
$apps = @()
|
||||
$appsRoot = Join-Path $classesRoot 'Applications'
|
||||
if (Test-Path -LiteralPath $appsRoot) {
|
||||
try {
|
||||
$apps = Get-ChildItem -LiteralPath $appsRoot -ErrorAction Stop |
|
||||
Where-Object {
|
||||
$_.PSChildName -like 'winpodx-*.exe' -or
|
||||
$_.PSChildName -like 'winpodx-*.cmd' -or
|
||||
$_.PSChildName -like 'winpodx-*.vbs'
|
||||
} |
|
||||
Select-Object -ExpandProperty PSChildName
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "enumerate Applications failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
$removedApps = 0
|
||||
foreach ($appName in $apps) {
|
||||
$appKey = Join-Path $appsRoot $appName
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would remove $appKey"
|
||||
$removedApps++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
Remove-Item -LiteralPath $appKey -Recurse -Force -ErrorAction Stop
|
||||
Write-LogLine 'INFO' "removed $appKey"
|
||||
$removedApps++
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not remove ${appKey}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# --- per-ext OpenWithList sub-keys + legacy values + OpenWithProgids ---
|
||||
# Current scheme: OpenWithList\winpodx-<slug>.exe SUB-KEYS (this is the
|
||||
# Windows convention). Pre-fix builds wrote VALUES under OpenWithList
|
||||
# with the same name — we scrub both so an upgrade from the broken
|
||||
# revision leaves no orphans.
|
||||
$removedExtRefs = 0
|
||||
try {
|
||||
$extKeys = Get-ChildItem -LiteralPath $classesRoot -ErrorAction Stop |
|
||||
Where-Object { $_.PSChildName -like '.*' }
|
||||
} catch {
|
||||
$extKeys = @()
|
||||
}
|
||||
foreach ($ext in $extKeys) {
|
||||
# Strip winpodx-*.exe / .cmd / .vbs sub-keys under OpenWithList.
|
||||
$owlKey = Join-Path $ext.PSPath 'OpenWithList'
|
||||
if (Test-Path -LiteralPath $owlKey) {
|
||||
try {
|
||||
$children = Get-ChildItem -LiteralPath $owlKey -ErrorAction Stop |
|
||||
Where-Object { $_.PSChildName -like 'winpodx-*' }
|
||||
} catch {
|
||||
$children = @()
|
||||
}
|
||||
foreach ($child in $children) {
|
||||
$childPath = $child.PSPath
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would remove sub-key $childPath"
|
||||
$removedExtRefs++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
Remove-Item -LiteralPath $childPath -Recurse -Force -ErrorAction Stop
|
||||
$removedExtRefs++
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not remove ${childPath}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
# Strip winpodx-* legacy VALUES under OpenWithList + any
|
||||
# winpodx-* OpenWithProgids attachments.
|
||||
foreach ($subName in @('OpenWithList', 'OpenWithProgids')) {
|
||||
$subKey = Join-Path $ext.PSPath $subName
|
||||
if (-not (Test-Path -LiteralPath $subKey)) { continue }
|
||||
$props = $null
|
||||
try {
|
||||
$props = Get-ItemProperty -LiteralPath $subKey -ErrorAction Stop
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
foreach ($prop in $props.PSObject.Properties) {
|
||||
if ($prop.Name -like 'winpodx-*') {
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would strip value $($prop.Name) from $subKey"
|
||||
$removedExtRefs++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
Remove-ItemProperty -LiteralPath $subKey -Name $prop.Name -Force -ErrorAction Stop
|
||||
$removedExtRefs++
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not strip $($prop.Name) from ${subKey}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- delete the per-slug wrapper files (.exe / .cmd / .vbs) and shim ---
|
||||
# Hard links share an inode with the source shim, so removing each
|
||||
# .exe just drops a name; the inode is freed when the last name goes
|
||||
# (and we delete the source shim explicitly at the end). Legacy .cmd
|
||||
# / .vbs files are removed for upgrade hygiene.
|
||||
$removedFiles = 0
|
||||
if (Test-Path -LiteralPath $BinDir) {
|
||||
foreach ($pat in @('winpodx-*.exe', 'winpodx-*.cmd', 'winpodx-*.vbs')) {
|
||||
try {
|
||||
$files = Get-ChildItem -LiteralPath $BinDir -Filter $pat -ErrorAction Stop
|
||||
} catch {
|
||||
$files = @()
|
||||
}
|
||||
foreach ($f in $files) {
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would delete $($f.FullName)"
|
||||
$removedFiles++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
Remove-Item -LiteralPath $f.FullName -Force -ErrorAction Stop
|
||||
$removedFiles++
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not delete $($f.FullName): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
# Also remove the master shim binary and the vendored rcedit.
|
||||
# The shim used to be the hard-link source; it's now just the
|
||||
# copy source. rcedit was added with the icon-embed flow.
|
||||
foreach ($extra in @('winpodx-reverse-open-shim.exe', 'rcedit.exe')) {
|
||||
$p = Join-Path $BinDir $extra
|
||||
if (-not (Test-Path -LiteralPath $p)) { continue }
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would delete $p"
|
||||
$removedFiles++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
Remove-Item -LiteralPath $p -Force -ErrorAction Stop
|
||||
$removedFiles++
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not delete ${p}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Start Menu shortcuts directory ---
|
||||
$removedShortcuts = 0
|
||||
if (Test-Path -LiteralPath $StartMenuDir) {
|
||||
if ($DryRun) {
|
||||
Write-LogLine 'INFO' "[dry-run] would delete shortcut dir $StartMenuDir"
|
||||
$removedShortcuts = 1
|
||||
} else {
|
||||
try {
|
||||
Remove-Item -LiteralPath $StartMenuDir -Recurse -Force -ErrorAction Stop
|
||||
$removedShortcuts = 1
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not remove ${StartMenuDir}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Desktop folder shortcut + Quick Access pin ---------------------------
|
||||
# Mirror cleanup for the discoverability aids register-apps.ps1 lands
|
||||
# alongside the per-slug registrations.
|
||||
$removedFolderShortcuts = 0
|
||||
if (-not $DryRun) {
|
||||
# Desktop\Linux Apps.lnk
|
||||
try {
|
||||
$desktopDir = [Environment]::GetFolderPath('Desktop')
|
||||
if ($desktopDir) {
|
||||
$desktopLnk = Join-Path $desktopDir 'Linux Apps.lnk'
|
||||
if (Test-Path -LiteralPath $desktopLnk) {
|
||||
Remove-Item -LiteralPath $desktopLnk -Force -ErrorAction Stop
|
||||
$removedFolderShortcuts++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not remove Desktop folder shortcut: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Quick Access unpin. The Shell.Application "unpinfromhome" verb
|
||||
# only works if the pinned item still exists on disk, so we run
|
||||
# this BEFORE the Start Menu folder deletion above would have
|
||||
# taken effect — except that section already ran. Fall back to
|
||||
# walking the AutomaticDestinations file the pin lives in.
|
||||
try {
|
||||
$shellApp = New-Object -ComObject Shell.Application
|
||||
# The Quick Access folder is exposed as "shell:::{679f85cb-0220-4080-b29b-5540cc05aab6}"
|
||||
$qa = $shellApp.Namespace('shell:::{679f85cb-0220-4080-b29b-5540cc05aab6}')
|
||||
if ($qa) {
|
||||
foreach ($it in $qa.Items()) {
|
||||
if ($it.Name -eq 'Linux Apps' -or $it.Path -eq $StartMenuDir) {
|
||||
$it.InvokeVerb('unpinfromhome')
|
||||
$removedFolderShortcuts++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-LogLine 'WARN' "could not unpin from Quick Access: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Invalidate Open With chooser cache so the removed handlers stop
|
||||
# appearing immediately. See register-apps.ps1 for rationale.
|
||||
if (-not $DryRun) {
|
||||
try {
|
||||
$ie4uinit = Join-Path $env:SystemRoot 'System32\ie4uinit.exe'
|
||||
if (Test-Path -LiteralPath $ie4uinit) {
|
||||
& $ie4uinit -show 2>&1 | Out-Null
|
||||
}
|
||||
} catch {
|
||||
# best-effort — the registry scrub already succeeded above
|
||||
}
|
||||
|
||||
# SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, 0, 0) — mirrors
|
||||
# the same call from register-apps.ps1. Required to flush the shell
|
||||
# icon cache; without it Explorer can keep painting the removed
|
||||
# ProgID icons until next logon.
|
||||
try {
|
||||
Add-Type -Namespace WinPodx -Name Shell32Ext -MemberDefinition @'
|
||||
[System.Runtime.InteropServices.DllImport("shell32.dll")]
|
||||
public static extern void SHChangeNotify(int wEventId, uint uFlags, System.IntPtr dwItem1, System.IntPtr dwItem2);
|
||||
'@ -ErrorAction Stop
|
||||
[WinPodx.Shell32Ext]::SHChangeNotify(0x08000000, 0x0000, [System.IntPtr]::Zero, [System.IntPtr]::Zero)
|
||||
} catch {
|
||||
# best-effort — registry/file cleanup already succeeded above
|
||||
}
|
||||
}
|
||||
|
||||
Write-LogLine 'INFO' "done. legacy_progids=$removedLegacy apps=$removedApps ext_refs=$removedExtRefs files=$removedFiles start_menu=$removedShortcuts folder_shortcuts=$removedFolderShortcuts"
|
||||
exit 0
|
||||
@@ -0,0 +1,104 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# winpodx - Windows Update toggle
|
||||
# Usage: toggle_updates.ps1 -Action enable|disable|status
|
||||
# Runs inside the Windows container only.
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[ValidateSet("enable", "disable", "status")]
|
||||
[string]$Action
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
$Services = @("wuauserv", "UsoSvc", "WaaSMedicSvc")
|
||||
|
||||
function Get-UpdateStatus {
|
||||
$wuauserv = Get-Service wuauserv -ErrorAction SilentlyContinue
|
||||
if ($wuauserv -and $wuauserv.StartType -ne "Disabled") {
|
||||
Write-Host "enabled"
|
||||
} else {
|
||||
Write-Host "disabled"
|
||||
}
|
||||
}
|
||||
|
||||
function Disable-Updates {
|
||||
Write-Host "[WinPodX] Disabling Windows Update..."
|
||||
|
||||
foreach ($svc in $Services) {
|
||||
Stop-Service $svc -Force -ErrorAction SilentlyContinue
|
||||
Set-Service $svc -StartupType Disabled -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Block update-related scheduled tasks
|
||||
$tasks = @(
|
||||
"\Microsoft\Windows\WindowsUpdate\Scheduled Start"
|
||||
"\Microsoft\Windows\UpdateOrchestrator\Schedule Scan"
|
||||
"\Microsoft\Windows\UpdateOrchestrator\USO_UxBroker"
|
||||
)
|
||||
foreach ($task in $tasks) {
|
||||
schtasks /Change /TN $task /Disable 2>$null
|
||||
}
|
||||
|
||||
# Block Windows Update domains via hosts file (belt and suspenders)
|
||||
$hostsFile = "C:\Windows\System32\drivers\etc\hosts"
|
||||
$marker = "# winpodx-update-block"
|
||||
$blockEntries = @(
|
||||
"$marker"
|
||||
"0.0.0.0 update.microsoft.com"
|
||||
"0.0.0.0 windowsupdate.microsoft.com"
|
||||
"0.0.0.0 download.windowsupdate.com"
|
||||
"0.0.0.0 dl.delivery.mp.microsoft.com"
|
||||
"0.0.0.0 ctldl.windowsupdate.com"
|
||||
"$marker-end"
|
||||
)
|
||||
|
||||
# Remove existing block if present, then add
|
||||
$content = Get-Content $hostsFile -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_ -notmatch "winpodx-update-block" -and
|
||||
$_ -notmatch "^0\.0\.0\.0.*(update\.microsoft|windowsupdate|dl\.delivery)"
|
||||
}
|
||||
$content += $blockEntries
|
||||
# -Encoding ASCII: PS 5.1 defaults to locale-ANSI (non-portable) and PS 7
|
||||
# writes UTF-8 with BOM, which the Windows DNS client refuses to parse.
|
||||
# ASCII is the only encoding the hosts resolver has accepted since XP.
|
||||
Set-Content -Path $hostsFile -Value $content -Encoding ASCII -Force
|
||||
|
||||
Write-Host "[WinPodX] Windows Update disabled"
|
||||
}
|
||||
|
||||
function Enable-Updates {
|
||||
Write-Host "[WinPodX] Enabling Windows Update..."
|
||||
|
||||
foreach ($svc in $Services) {
|
||||
Set-Service $svc -StartupType Manual -ErrorAction SilentlyContinue
|
||||
Start-Service $svc -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Re-enable scheduled tasks
|
||||
$tasks = @(
|
||||
"\Microsoft\Windows\WindowsUpdate\Scheduled Start"
|
||||
"\Microsoft\Windows\UpdateOrchestrator\Schedule Scan"
|
||||
"\Microsoft\Windows\UpdateOrchestrator\USO_UxBroker"
|
||||
)
|
||||
foreach ($task in $tasks) {
|
||||
schtasks /Change /TN $task /Enable 2>$null
|
||||
}
|
||||
|
||||
# Remove hosts file block
|
||||
$hostsFile = "C:\Windows\System32\drivers\etc\hosts"
|
||||
$content = Get-Content $hostsFile -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_ -notmatch "winpodx-update-block" -and
|
||||
$_ -notmatch "^0\.0\.0\.0.*(update\.microsoft|windowsupdate|dl\.delivery)"
|
||||
}
|
||||
# -Encoding ASCII: hosts file must stay ASCII so the DNS client parses it on both PS 5.1 and PS 7.
|
||||
Set-Content -Path $hostsFile -Value $content -Encoding ASCII -Force
|
||||
|
||||
Write-Host "[WinPodX] Windows Update enabled"
|
||||
}
|
||||
|
||||
switch ($Action) {
|
||||
"enable" { Enable-Updates }
|
||||
"disable" { Disable-Updates }
|
||||
"status" { Get-UpdateStatus }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# Catalog of debloat items consumed by ``winpodx.core.debloat`` and
|
||||
# exposed via ``winpodx debloat --list`` (and a future GUI picker).
|
||||
#
|
||||
# Each item is a TOML table:
|
||||
#
|
||||
# * ``label`` -- short user-facing name (shown in CLI / GUI lists).
|
||||
# * ``description`` -- one-line explanation of what it disables / why.
|
||||
# * ``script`` -- path under ``scripts/windows/debloat/`` to the
|
||||
# PowerShell snippet that performs the action.
|
||||
# Resolved against ``bundle_dir()`` at run time.
|
||||
# * ``risk`` -- one of ``low`` / ``medium`` / ``high``.
|
||||
# ``low`` = pure noise removal, no UX regression.
|
||||
# ``medium`` = removes a feature some users want
|
||||
# (OneDrive, common startup apps).
|
||||
# ``high`` = trades a real capability for VM
|
||||
# perf (Windows Search indexing).
|
||||
# * ``undo_script`` -- (optional) path under ``scripts/windows/debloat/undo/``
|
||||
# for the reverse-action ``.ps1``. Items without an
|
||||
# ``undo_script`` field are flagged as one-way: ``winpodx
|
||||
# debloat --undo --items <name>`` rejects them with a
|
||||
# clear error. Used by #247 P2 to drive per-item undo
|
||||
# + state tracking via ``%ProgramData%\winpodx\debloat-
|
||||
# applied.json``.
|
||||
#
|
||||
# Presets in ``items_presets`` reference these item names. Adding a new
|
||||
# item requires:
|
||||
#
|
||||
# 1. Append a ``[<name>]`` table here.
|
||||
# 2. Drop the matching ``.ps1`` under ``scripts/windows/debloat/``.
|
||||
# 3. (Optional) Drop an undo sibling under ``scripts/windows/debloat/undo/``
|
||||
# and add the ``undo_script`` field to the table.
|
||||
# 4. Add the name to whichever preset(s) it belongs to.
|
||||
|
||||
[items.telemetry]
|
||||
label = "Telemetry & diagnostics"
|
||||
description = "Disable DiagTrack / dmwappushservice services + AllowTelemetry policy."
|
||||
script = "telemetry.ps1"
|
||||
undo_script = "undo/telemetry.ps1"
|
||||
risk = "low"
|
||||
|
||||
[items.ads]
|
||||
label = "Ads & suggestions"
|
||||
description = "Disable Start menu / lock-screen / settings ads (ContentDeliveryManager keys)."
|
||||
script = "ads.ps1"
|
||||
undo_script = "undo/ads.ps1"
|
||||
risk = "low"
|
||||
|
||||
[items.onedrive]
|
||||
label = "OneDrive"
|
||||
description = "Remove OneDrive integration (uninstall + remove from File Explorer pane). One-way: undo requires reinstalling OneDrive from Microsoft."
|
||||
script = "onedrive.ps1"
|
||||
risk = "medium"
|
||||
|
||||
[items.sysmain]
|
||||
label = "SuperFetch (SysMain)"
|
||||
description = "Disable SysMain -- VM has no spinning disk; SuperFetch wastes RAM."
|
||||
script = "sysmain.ps1"
|
||||
undo_script = "undo/sysmain.ps1"
|
||||
risk = "low"
|
||||
|
||||
[items.web_search]
|
||||
label = "Cortana + Windows web search"
|
||||
description = "Disable Cortana + Start menu web result lookups."
|
||||
script = "web_search.ps1"
|
||||
undo_script = "undo/web_search.ps1"
|
||||
risk = "low"
|
||||
|
||||
[items.widgets]
|
||||
label = "Widgets / news panel"
|
||||
description = "Disable taskbar widgets + sliding news panel."
|
||||
script = "widgets.ps1"
|
||||
undo_script = "undo/widgets.ps1"
|
||||
risk = "low"
|
||||
|
||||
[items.scheduled_tasks]
|
||||
label = "Unnecessary scheduled tasks"
|
||||
description = "Disable telemetry tasks: App Experience / CEIP / Feedback / WindowsAI + Office telemetry / Disk Diagnostic / Maps / RetailDemo / WER (no Defender, licensing, or update tasks)."
|
||||
script = "scheduled_tasks.ps1"
|
||||
undo_script = "undo/scheduled_tasks.ps1"
|
||||
risk = "low"
|
||||
|
||||
[items.startup_programs]
|
||||
label = "Common startup bloat"
|
||||
description = "Disable common autostart entries (OneDrive, Teams, Skype, Spotify, Discord, OEM updaters). One-way: undo can't restore original autostart commands."
|
||||
script = "startup_programs.ps1"
|
||||
risk = "medium"
|
||||
|
||||
[items.visual_effects]
|
||||
label = "Visual effects -> best performance"
|
||||
description = "Disable animations; keep thumbnails, font smoothing, drag-window contents (sysguides recipe)."
|
||||
script = "visual_effects.ps1"
|
||||
undo_script = "undo/visual_effects.ps1"
|
||||
risk = "low"
|
||||
|
||||
[items.search_indexing]
|
||||
label = "Search indexing service"
|
||||
description = "Disable the WSearch service. Tradeoff: Start menu search becomes file-system-walk slow."
|
||||
script = "search_indexing.ps1"
|
||||
undo_script = "undo/search_indexing.ps1"
|
||||
risk = "high"
|
||||
|
||||
[items.transparency]
|
||||
label = "Window transparency"
|
||||
description = "Disable Windows acrylic / transparency effects (saves GPU + scanout bandwidth)."
|
||||
script = "transparency.ps1"
|
||||
undo_script = "undo/transparency.ps1"
|
||||
risk = "low"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preset definitions. Each preset is an ordered list of item names; presets
|
||||
# *seed* the picker's checkbox state, the actual run applies whatever the
|
||||
# user has selected (which may diverge from any preset).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[presets]
|
||||
# Current default. Conservative -- only zero-regression items.
|
||||
normal = ["telemetry", "ads"]
|
||||
# Adds OS-noise removal that some users notice (OneDrive, web search,
|
||||
# widgets, scheduled tasks).
|
||||
full = ["telemetry", "ads", "onedrive", "web_search", "widgets", "scheduled_tasks"]
|
||||
# Adds VM-perf wins (SuperFetch, startup bloat, visual effects).
|
||||
performance = [
|
||||
"telemetry",
|
||||
"ads",
|
||||
"onedrive",
|
||||
"web_search",
|
||||
"widgets",
|
||||
"scheduled_tasks",
|
||||
"sysmain",
|
||||
"startup_programs",
|
||||
"visual_effects",
|
||||
]
|
||||
# Everything. Search indexing + transparency cost real UX in trade.
|
||||
speed = [
|
||||
"telemetry",
|
||||
"ads",
|
||||
"onedrive",
|
||||
"web_search",
|
||||
"widgets",
|
||||
"scheduled_tasks",
|
||||
"sysmain",
|
||||
"startup_programs",
|
||||
"visual_effects",
|
||||
"search_indexing",
|
||||
"transparency",
|
||||
]
|
||||
@@ -0,0 +1,201 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# IANA timezone -> Windows TimeZone ID mapping, used by
|
||||
# winpodx.utils.locale.iana_to_windows() to translate the host's
|
||||
# detected IANA zone (e.g. "Asia/Seoul") into the value tzutil.exe
|
||||
# expects on the Windows guest (e.g. "Korea Standard Time").
|
||||
#
|
||||
# Source: Unicode CLDR `common/supplemental/windowsZones.xml` (BCP 47
|
||||
# subtag mapping). This file ships the "001" (territory wildcard)
|
||||
# entries from that table -- the canonical IANA <-> Windows mapping
|
||||
# without per-country variants. The CLDR data is itself MIT-licensed.
|
||||
#
|
||||
# Refresh: `scripts/ci/refresh_windows_zones.py` (planned) pulls the
|
||||
# latest CLDR XML and regenerates this file. Until that lands, hand-
|
||||
# bump entries against
|
||||
# https://github.com/unicode-org/cldr/blob/main/common/supplemental/windowsZones.xml
|
||||
#
|
||||
# Only IANA names are keys; values are the literal strings tzutil
|
||||
# /s "<id>" expects. Quoted on the right because some Windows IDs
|
||||
# contain spaces ("Korea Standard Time") and TOML bare keys can't.
|
||||
|
||||
[mapping]
|
||||
|
||||
# --- UTC / GMT family ---
|
||||
"Etc/UTC" = "UTC"
|
||||
"Etc/GMT" = "UTC"
|
||||
"UTC" = "UTC"
|
||||
|
||||
# --- Africa ---
|
||||
"Africa/Abidjan" = "Greenwich Standard Time"
|
||||
"Africa/Accra" = "Greenwich Standard Time"
|
||||
"Africa/Algiers" = "W. Central Africa Standard Time"
|
||||
"Africa/Cairo" = "Egypt Standard Time"
|
||||
"Africa/Casablanca" = "Morocco Standard Time"
|
||||
"Africa/Johannesburg" = "South Africa Standard Time"
|
||||
"Africa/Khartoum" = "Sudan Standard Time"
|
||||
"Africa/Lagos" = "W. Central Africa Standard Time"
|
||||
"Africa/Nairobi" = "E. Africa Standard Time"
|
||||
"Africa/Tripoli" = "Libya Standard Time"
|
||||
"Africa/Tunis" = "W. Central Africa Standard Time"
|
||||
"Africa/Windhoek" = "Namibia Standard Time"
|
||||
|
||||
# --- America ---
|
||||
"America/Anchorage" = "Alaskan Standard Time"
|
||||
"America/Argentina/Buenos_Aires" = "Argentina Standard Time"
|
||||
"America/Asuncion" = "Paraguay Standard Time"
|
||||
"America/Bogota" = "SA Pacific Standard Time"
|
||||
"America/Caracas" = "Venezuela Standard Time"
|
||||
"America/Chicago" = "Central Standard Time"
|
||||
"America/Chihuahua" = "Central Standard Time (Mexico)"
|
||||
"America/Costa_Rica" = "Central America Standard Time"
|
||||
"America/Denver" = "Mountain Standard Time"
|
||||
"America/Detroit" = "Eastern Standard Time"
|
||||
"America/Edmonton" = "Mountain Standard Time"
|
||||
"America/Fortaleza" = "SA Eastern Standard Time"
|
||||
"America/Godthab" = "Greenland Standard Time"
|
||||
"America/Halifax" = "Atlantic Standard Time"
|
||||
"America/Havana" = "Cuba Standard Time"
|
||||
"America/Indiana/Indianapolis" = "US Eastern Standard Time"
|
||||
"America/Indianapolis" = "US Eastern Standard Time"
|
||||
"America/La_Paz" = "SA Western Standard Time"
|
||||
"America/Lima" = "SA Pacific Standard Time"
|
||||
"America/Los_Angeles" = "Pacific Standard Time"
|
||||
"America/Mazatlan" = "Mountain Standard Time (Mexico)"
|
||||
"America/Mexico_City" = "Central Standard Time (Mexico)"
|
||||
"America/Montevideo" = "Montevideo Standard Time"
|
||||
"America/Montreal" = "Eastern Standard Time"
|
||||
"America/New_York" = "Eastern Standard Time"
|
||||
"America/Noronha" = "UTC-02"
|
||||
"America/Panama" = "SA Pacific Standard Time"
|
||||
"America/Phoenix" = "US Mountain Standard Time"
|
||||
"America/Port-au-Prince" = "Haiti Standard Time"
|
||||
"America/Puerto_Rico" = "SA Western Standard Time"
|
||||
"America/Regina" = "Canada Central Standard Time"
|
||||
"America/Santiago" = "Pacific SA Standard Time"
|
||||
"America/Santo_Domingo" = "SA Western Standard Time"
|
||||
"America/Sao_Paulo" = "E. South America Standard Time"
|
||||
"America/St_Johns" = "Newfoundland Standard Time"
|
||||
"America/Tegucigalpa" = "Central America Standard Time"
|
||||
"America/Tijuana" = "Pacific Standard Time (Mexico)"
|
||||
"America/Toronto" = "Eastern Standard Time"
|
||||
"America/Vancouver" = "Pacific Standard Time"
|
||||
"America/Winnipeg" = "Central Standard Time"
|
||||
|
||||
# --- Antarctica ---
|
||||
"Antarctica/Casey" = "Central Pacific Standard Time"
|
||||
"Antarctica/McMurdo" = "New Zealand Standard Time"
|
||||
"Antarctica/South_Pole" = "New Zealand Standard Time"
|
||||
|
||||
# --- Asia ---
|
||||
"Asia/Almaty" = "Central Asia Standard Time"
|
||||
"Asia/Amman" = "Jordan Standard Time"
|
||||
"Asia/Baghdad" = "Arabic Standard Time"
|
||||
"Asia/Bahrain" = "Arab Standard Time"
|
||||
"Asia/Baku" = "Azerbaijan Standard Time"
|
||||
"Asia/Bangkok" = "SE Asia Standard Time"
|
||||
"Asia/Beirut" = "Middle East Standard Time"
|
||||
"Asia/Calcutta" = "India Standard Time"
|
||||
"Asia/Chongqing" = "China Standard Time"
|
||||
"Asia/Colombo" = "Sri Lanka Standard Time"
|
||||
"Asia/Damascus" = "Syria Standard Time"
|
||||
"Asia/Dhaka" = "Bangladesh Standard Time"
|
||||
"Asia/Dubai" = "Arabian Standard Time"
|
||||
"Asia/Hong_Kong" = "China Standard Time"
|
||||
"Asia/Irkutsk" = "North Asia East Standard Time"
|
||||
"Asia/Jakarta" = "SE Asia Standard Time"
|
||||
"Asia/Jayapura" = "Tokyo Standard Time"
|
||||
"Asia/Jerusalem" = "Israel Standard Time"
|
||||
"Asia/Kabul" = "Afghanistan Standard Time"
|
||||
"Asia/Karachi" = "Pakistan Standard Time"
|
||||
"Asia/Kathmandu" = "Nepal Standard Time"
|
||||
"Asia/Kolkata" = "India Standard Time"
|
||||
"Asia/Krasnoyarsk" = "North Asia Standard Time"
|
||||
"Asia/Kuala_Lumpur" = "Singapore Standard Time"
|
||||
"Asia/Kuwait" = "Arab Standard Time"
|
||||
"Asia/Magadan" = "Magadan Standard Time"
|
||||
"Asia/Manila" = "Singapore Standard Time"
|
||||
"Asia/Muscat" = "Arabian Standard Time"
|
||||
"Asia/Nicosia" = "E. Europe Standard Time"
|
||||
"Asia/Novosibirsk" = "N. Central Asia Standard Time"
|
||||
"Asia/Pyongyang" = "North Korea Standard Time"
|
||||
"Asia/Qatar" = "Arab Standard Time"
|
||||
"Asia/Riyadh" = "Arab Standard Time"
|
||||
"Asia/Saigon" = "SE Asia Standard Time"
|
||||
"Asia/Seoul" = "Korea Standard Time"
|
||||
"Asia/Shanghai" = "China Standard Time"
|
||||
"Asia/Singapore" = "Singapore Standard Time"
|
||||
"Asia/Taipei" = "Taipei Standard Time"
|
||||
"Asia/Tashkent" = "West Asia Standard Time"
|
||||
"Asia/Tbilisi" = "Georgian Standard Time"
|
||||
"Asia/Tehran" = "Iran Standard Time"
|
||||
"Asia/Tokyo" = "Tokyo Standard Time"
|
||||
"Asia/Ulaanbaatar" = "Ulaanbaatar Standard Time"
|
||||
"Asia/Urumqi" = "Central Asia Standard Time"
|
||||
"Asia/Vladivostok" = "Vladivostok Standard Time"
|
||||
"Asia/Yakutsk" = "Yakutsk Standard Time"
|
||||
"Asia/Yekaterinburg" = "Ekaterinburg Standard Time"
|
||||
"Asia/Yerevan" = "Caucasus Standard Time"
|
||||
|
||||
# --- Atlantic ---
|
||||
"Atlantic/Azores" = "Azores Standard Time"
|
||||
"Atlantic/Bermuda" = "Atlantic Standard Time"
|
||||
"Atlantic/Cape_Verde" = "Cape Verde Standard Time"
|
||||
"Atlantic/Reykjavik" = "Greenwich Standard Time"
|
||||
|
||||
# --- Australia ---
|
||||
"Australia/Adelaide" = "Cen. Australia Standard Time"
|
||||
"Australia/Brisbane" = "E. Australia Standard Time"
|
||||
"Australia/Darwin" = "AUS Central Standard Time"
|
||||
"Australia/Hobart" = "Tasmania Standard Time"
|
||||
"Australia/Melbourne" = "AUS Eastern Standard Time"
|
||||
"Australia/Perth" = "W. Australia Standard Time"
|
||||
"Australia/Sydney" = "AUS Eastern Standard Time"
|
||||
|
||||
# --- Europe ---
|
||||
"Europe/Amsterdam" = "W. Europe Standard Time"
|
||||
"Europe/Athens" = "GTB Standard Time"
|
||||
"Europe/Belgrade" = "Central European Standard Time"
|
||||
"Europe/Berlin" = "W. Europe Standard Time"
|
||||
"Europe/Brussels" = "Romance Standard Time"
|
||||
"Europe/Bucharest" = "GTB Standard Time"
|
||||
"Europe/Budapest" = "Central European Standard Time"
|
||||
"Europe/Chisinau" = "E. Europe Standard Time"
|
||||
"Europe/Copenhagen" = "Romance Standard Time"
|
||||
"Europe/Dublin" = "GMT Standard Time"
|
||||
"Europe/Helsinki" = "FLE Standard Time"
|
||||
"Europe/Istanbul" = "Turkey Standard Time"
|
||||
"Europe/Kaliningrad" = "Kaliningrad Standard Time"
|
||||
"Europe/Kiev" = "FLE Standard Time"
|
||||
"Europe/Lisbon" = "GMT Standard Time"
|
||||
"Europe/London" = "GMT Standard Time"
|
||||
"Europe/Madrid" = "Romance Standard Time"
|
||||
"Europe/Minsk" = "Belarus Standard Time"
|
||||
"Europe/Moscow" = "Russian Standard Time"
|
||||
"Europe/Oslo" = "W. Europe Standard Time"
|
||||
"Europe/Paris" = "Romance Standard Time"
|
||||
"Europe/Prague" = "Central Europe Standard Time"
|
||||
"Europe/Riga" = "FLE Standard Time"
|
||||
"Europe/Rome" = "W. Europe Standard Time"
|
||||
"Europe/Samara" = "Russia Time Zone 3"
|
||||
"Europe/Sofia" = "FLE Standard Time"
|
||||
"Europe/Stockholm" = "W. Europe Standard Time"
|
||||
"Europe/Tallinn" = "FLE Standard Time"
|
||||
"Europe/Vienna" = "W. Europe Standard Time"
|
||||
"Europe/Vilnius" = "FLE Standard Time"
|
||||
"Europe/Warsaw" = "Central European Standard Time"
|
||||
"Europe/Zagreb" = "Central European Standard Time"
|
||||
"Europe/Zurich" = "W. Europe Standard Time"
|
||||
|
||||
# --- Indian Ocean ---
|
||||
"Indian/Mauritius" = "Mauritius Standard Time"
|
||||
|
||||
# --- Pacific ---
|
||||
"Pacific/Apia" = "Samoa Standard Time"
|
||||
"Pacific/Auckland" = "New Zealand Standard Time"
|
||||
"Pacific/Fiji" = "Fiji Standard Time"
|
||||
"Pacific/Guam" = "West Pacific Standard Time"
|
||||
"Pacific/Honolulu" = "Hawaiian Standard Time"
|
||||
"Pacific/Pago_Pago" = "UTC-11"
|
||||
"Pacific/Port_Moresby" = "West Pacific Standard Time"
|
||||
"Pacific/Tongatapu" = "Tonga Standard Time"
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
viewBox="-10.198 -24.139 190.368 190.368"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1" /><g
|
||||
id="layer1"
|
||||
transform="translate(-22.613732,-66.893883)"><g
|
||||
id="g3"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,-65.529516,34.333496)"><g
|
||||
id="g1"
|
||||
style="fill:#2596be;fill-opacity:1"
|
||||
transform="translate(-718.32936,-313.76438)"><path
|
||||
style="fill:#2596be;fill-opacity:1"
|
||||
d="m 1326.735,973.6718 c -8.7902,-0.6541 -33.5095,-4.3462 -42.8878,-6.4058 -23.9215,-5.2534 -49.3875,-15.0786 -76.0377,-29.3365 l -14.4254,-7.7176 -12.5746,-8.6049 c -16.2761,-11.1378 -19.9778,-14.0745 -35.4417,-28.1166 l -12.8672,-11.6842 -7.7269,-9 c -30.911,-36.0037 -52.1953,-75.06431 -65.9334,-121 l -3.8879,-13 -1.7416,-12.98373 -1.7416,-12.98373 0.5791,-13.01627 0.579,-13.01627 2.0653,-9.5 c 2.7898,-12.83301 5.428,-20.62887 10.9982,-32.5 l 4.6923,-10 6.426,-8.98263 c 21.9823,-30.72841 54.4706,-52.46565 95.7084,-64.03639 l 6.272,-1.75982 0.7227,0.72207 0.7228,0.72208 v 25.65897 25.65898 l -10,3.5657 c -12.6962,4.52708 -16.2862,6.42189 -26.0603,13.75472 -9.7818,7.33858 -18.8002,16.58354 -24.7147,25.33545 l -4.4866,6.63913 -4.0152,10.36087 c -4.5564,11.7576 -5.8848,17.32261 -6.8063,28.51288 l -0.6463,7.84799 1.1911,8 c 6.4383,43.2416 33.2205,66.27231 90.3605,77.70338 l 11.1778,2.23617 19.5,-0.024 19.5,-0.024 9,-1.69196 c 26.9808,-5.07224 52.5422,-14.82578 76.7316,-29.27873 6.1976,-3.70299 20.5453,-12.91324 31.8838,-20.46723 57.3171,-38.18611 79.1683,-47.86613 146.8846,-65.06956 44.9665,-11.42381 68.8656,-20.48231 92,-34.87076 8.5238,-5.30142 13.0454,-8.98563 21.2429,-17.30878 l 5.7428,-5.83094 4.001,-6.73468 4.001,-6.73469 1.6323,-5.45216 c 0.8978,-2.99869 1.966,-8.15216 2.3737,-11.45216 l 0.7413,-6 -0.8719,-6.5 -0.8718,-6.5 -3.5397,-7.22854 -3.5396,-7.22853 -4.7282,-4.85426 -4.7282,-4.85426 -7.4778,-3.65217 c -8.9762,-4.38402 -16.8945,-6.53879 -26.9778,-7.34138 l -7.5,-0.59697 -9,1.1747 c -4.95,0.64609 -12.7799,2.06129 -17.3998,3.14489 -4.6199,1.0836 -8.6037,1.76632 -8.8529,1.51715 -0.5865,-0.58647 -0.4467,-51.71456 0.143,-52.30424 0.2458,-0.24579 5.771,-1.3532 12.2783,-2.46091 l 11.8314,-2.01403 17,0.52308 17,0.52308 8.2461,2.06014 c 4.5354,1.13308 12.3565,3.64756 17.3802,5.58773 l 9.1341,3.52759 8.1198,4.94453 8.1198,4.94454 7.6639,7.0463 7.6639,7.0463 4.4513,6.24763 c 5.5868,7.84139 10.006,16.56739 13.1443,25.9543 l 2.4385,7.29333 1.6416,11 1.6416,11 -0.6325,6.5 c -0.3478,3.575 -1.2967,10.45098 -2.1085,15.27996 l -1.476,8.77997 -3.3793,9.72003 c -1.8585,5.34602 -7.0658,19.62004 -11.5716,31.72004 -4.5058,12.1 -11.2577,30.55 -15.0042,41 -6.1488,17.15055 -10.1126,27.79579 -25.5062,68.5 -10.4093,27.52435 -19.8166,47.2065 -31.3066,65.5 -26.2499,41.7933 -50.5189,66.9429 -89.2174,92.4545 l -14.5572,9.5967 -14.0787,7.221 -14.0787,7.221 -15.3641,6.1165 c -24.2126,9.639 -39.6944,13.7699 -67.3641,17.9746 l -13,1.9754 -21.5,0.3576 c -11.825,0.1966 -23.975,0.1734 -27,-0.052 z m 11.8845,-65.916 c 20.7134,-2.8693 34.2071,-6.7917 54.1155,-15.7305 28.9991,-13.0206 51.1713,-28.7481 74.507,-52.8505 l 11.007,-11.3686 12.3655,-16.5 c 6.8011,-9.075 12.8179,-16.95 13.3708,-17.5 l 1.0051,-1 -0.6177,5.59012 -0.6176,5.59008 -3.4854,11.4972 c -4.3981,14.5081 -8.3402,23.801 -15.1962,35.8226 -9.8512,17.2735 -23.9149,34.619 -38.3011,47.239 -3.5544,3.1181 -6.2124,5.9193 -5.9067,6.225 1.4515,1.4515 36.0286,-17.361 46.8693,-25.5004 1.925,-1.4453 6.9467,-5.2095 11.1593,-8.3648 23.4889,-17.5939 48.0348,-46.2367 65.214,-76.0988 7.7672,-13.50163 10.646,-19.55839 17.8235,-37.5 4.4267,-11.06533 18.0752,-47.63925 19.4941,-52.23836 l 0.5363,-1.73836 -8.3636,3.90566 c -4.6,2.14811 -15.8258,6.39132 -24.9463,9.42936 l -16.5827,5.52371 -20.9173,5.12554 c -25.9959,6.36999 -34.9917,8.89808 -53.7531,15.10617 l -14.8358,4.90912 -13.0905,5.98489 -13.0905,5.98488 -13.5737,8.15187 c -7.4655,4.48352 -18.9737,11.84143 -25.5737,16.35089 -35.6667,24.36943 -48.871,32.22863 -71.5896,42.61033 l -14.7207,6.7269 -12.3449,3.9951 c -13.114,4.244 -25.457,7.1374 -41.4959,9.7274 l -9.8489,1.5905 -18,-0.031 -18,-0.031 -11.5069,-1.5246 c -6.3288,-0.8384 -16.4538,-2.6168 -22.5,-3.9519 -6.0462,-1.335 -11.0901,-2.3334 -11.2087,-2.2186 -0.1186,0.1148 3.1813,3.9004 7.3332,8.4126 14.48,15.7363 36.7785,32.5077 55.8824,42.0309 l 10,4.985 12,4.0267 12,4.0267 10.5,2.1068 c 5.775,1.1587 15,2.5033 20.5,2.9878 l 10,0.881 12,-0.4793 c 6.6,-0.2636 16.673,-1.1267 22.3845,-1.9178 z"
|
||||
id="path1" /><path
|
||||
style="fill:#58a6ff;fill-opacity:1"
|
||||
d="m 1294.6201,750.97754 c -4.789,-1.77888 -8.6454,-4.60281 -13.5454,-9.91869 -5.3953,-5.8532 -8.6212,-11.6313 -11.8652,-21.25265 -3.3064,-9.80633 -7.1785,-16.7826 -12.121,-21.83781 l -3.7522,-3.83782 -6.5506,-3.29293 c -3.6029,-1.81111 -8.7016,-4.0208 -11.3305,-4.91043 -2.6289,-0.88963 -7.0912,-2.78881 -9.9164,-4.22041 l -5.1365,-2.6029 -5.1451,-5.14885 -5.1451,-5.14885 -2.5994,-5.5 -2.5994,-5.5 -1.1817,-6.5 -1.1817,-6.5 0.6179,-5 c 0.3397,-2.75 1.6189,-7.90586 2.8425,-11.45747 l 2.2247,-6.45747 5.3553,-5.78997 5.3554,-5.78996 7.1446,-3.48304 7.1447,-3.48303 6.5,-0.91653 6.5,-0.91653 44.5,0.147 44.5,0.147 4.7267,2.32791 4.7266,2.32791 2.678,3.17209 c 1.4729,1.74465 3.4985,4.97209 4.5014,7.17209 l 1.8234,4 -0.04,43 -0.04,43 -0.741,7.40368 -0.741,7.40368 -3.3724,2.81541 c -3.9135,3.26718 -22.8537,15.45109 -34.6829,22.31103 l -8.1609,4.73262 -3.8391,0.42385 -3.8391,0.42386 z m 80.7222,-46.92134 c 0.7878,-4.59384 6.0463,-15.13949 15.1004,-30.28309 4.2858,-7.1682 13.6423,-23.07716 20.7923,-35.35324 l 13,-22.32014 4.2721,-4.81824 4.272,-4.81825 4.2937,-2.07852 4.2936,-2.07852 h 5.005 5.005 l 5.429,2.75 5.429,2.75 3.5003,4.68952 c 3.4376,4.60547 28.0003,45.05695 28.0003,46.11264 0,0.29641 -1.4625,0.8537 -3.25,1.23843 -19.8175,4.26532 -57.8565,17.51233 -75.25,26.20564 -7.8497,3.9233 -32.9332,17.794 -35.9725,19.8921 -1.0849,0.74892 -2.5361,1.36167 -3.225,1.36167 h -1.2525 z M 1220.4941,580.8997 c -5.6241,-2.03197 -8.4763,-4.20899 -11.4996,-8.77759 l -2.7368,-4.13551 -0.3087,-7.0902 -0.3086,-7.0902 3.9054,-13.5 c 2.148,-7.425 7.3746,-25.2 11.6147,-39.5 l 7.7093,-26 2.9632,-4.63518 2.9631,-4.63518 3.8879,-2.6943 3.8879,-2.69429 6.0624,-1.88684 c 3.3344,-1.03777 11.8086,-3.21445 18.8316,-4.83706 7.023,-1.62261 23.3101,-5.91277 36.1935,-9.53368 l 23.4244,-6.58347 h 4.3902 4.3902 l 4.8632,2.09209 4.8632,2.0921 2.5259,3.6579 c 1.3893,2.01185 3.2218,5.33096 4.0722,7.37579 l 1.5463,3.71788 v 53.46702 53.46703 l -1.0559,2.8151 c -0.5807,1.54831 -2.2366,4.53105 -3.6798,6.62833 l -2.6239,3.81322 -4.5702,2.68678 -4.5702,2.68677 -56.5,0.22254 -56.5,0.22254 z m 212.2409,-0.0209 c -17.0084,-3.78034 -28.3363,-10.49377 -40.969,-24.2801 l -6.2066,-6.77341 -3.8464,-8.00956 c -2.1155,-4.40525 -4.6469,-10.93455 -5.6253,-14.50955 l -1.7789,-6.5 0.031,-11.5 0.031,-11.5 1.7708,-6.5 c 2.4195,-8.88159 7.4585,-19.40008 12.4555,-26 l 4.1642,-5.5 6.7366,-5.73907 6.7366,-5.73907 6.5,-3.41907 c 3.575,-1.88048 9.875,-4.46816 14,-5.75039 l 7.5,-2.33132 13,0.03 13,0.03 8,2.76808 8,2.76808 6.9782,4.19135 6.9782,4.19135 6.2728,5.8656 6.2728,5.86561 4.6375,7.44048 4.6374,7.44048 2.9493,8.55655 2.9494,8.55654 0.5293,11.13737 0.5293,11.13737 -2.1572,8.99762 c -5.6367,23.51114 -18.8283,39.45683 -41.3859,50.02635 l -7.1911,3.36943 -7.7078,1.5533 -7.7079,1.5533 -7.2921,-0.10247 -7.2922,-0.10247 z"
|
||||
id="path1-3" /></g><g
|
||||
id="g2"
|
||||
style="fill:#2596be;fill-opacity:1"
|
||||
transform="translate(-111.66527,32.56245)" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=WinPodX
|
||||
Comment=Windows App Integration for Linux
|
||||
Exec=winpodx gui
|
||||
Icon=winpodx
|
||||
Categories=System;Utility;
|
||||
Keywords=windows;winpodx;rdp;virtualization;
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,66 @@
|
||||
# Example winpodx.toml — the on-disk config (usually at
|
||||
# ~/.config/winpodx/winpodx.toml). winpodx writes + manages this file itself;
|
||||
# this is a reference for hand-editing. Unknown keys are ignored, and any key
|
||||
# you omit falls back to the built-in default shown here.
|
||||
|
||||
schema_version = 2
|
||||
|
||||
[rdp]
|
||||
user = "" # Windows account user name ("" = the dockur default)
|
||||
password = "" # auto-generated; leave empty for a random password
|
||||
password_updated = "" # ISO timestamp of the last rotation (managed)
|
||||
password_max_age = 7 # days; 0 = disable auto-rotation
|
||||
askpass = "" # command to retrieve the password instead of storing it
|
||||
domain = ""
|
||||
ip = "127.0.0.1" # RDP host (the manual backend points this at the VM)
|
||||
port = 3390
|
||||
scale = 100 # 100 / 140 / 180 — RemoteApp scale (auto-detected from the DE)
|
||||
dpi = 0 # 0 = let Windows decide; 100–300 = explicit Windows DPI %
|
||||
extra_flags = "" # extra xfreerdp flags (validated against an allowlist)
|
||||
freerdp_source = "auto" # auto | native | flatpak
|
||||
multimon = "span" # span | off | multimon — multi-monitor RAIL strategy
|
||||
|
||||
[pod]
|
||||
backend = "podman" # podman | docker | manual (libvirt was dropped in 0.6.0)
|
||||
vm_name = "RDPWindows" # dockur VM name
|
||||
container_name = "winpodx-windows"
|
||||
win_version = "11" # 11 | 10 | ltsc11 | ltsc10 | iot11 | tiny11 | tiny10 | 2025 | 2022 | 2019 | 2016
|
||||
cpu_cores = 4
|
||||
ram_gb = 6 # GiB given to the Windows VM
|
||||
vnc_port = 8007
|
||||
auto_start = false # opt-in: auto-start the pod when launching an app
|
||||
idle_timeout = 0 # seconds before auto-suspend (0 = disabled)
|
||||
boot_timeout = 300 # seconds to wait for first boot before giving up
|
||||
disk_size = "64G" # Windows virtual disk ceiling
|
||||
disk_autogrow = true # grow the disk automatically when it fills up
|
||||
disk_autogrow_threshold_pct = 80
|
||||
disk_autogrow_target_free_pct = 30
|
||||
disk_autogrow_increment = "32G"
|
||||
disk_max_size = "" # cap for auto-grow ("" = host free space minus a reserve)
|
||||
guest_autosync = true # push refreshed guest scripts on pod start after a host upgrade
|
||||
max_sessions = 25 # concurrent RemoteApp sessions the guest is tuned for
|
||||
storage_path = "" # host dir backing the VM disk ("" = the podman/docker volume)
|
||||
language = "English" # Windows display language (e.g. "Spanish" / "French" / "German")
|
||||
region = "en-001" # regional format (date / currency); e.g. "es-ES", "fr-FR"
|
||||
keyboard = "en-US" # keyboard layout; e.g. "es-ES", "fr-FR", "de-DE"
|
||||
timezone = "" # "" = follow the host timezone
|
||||
tuning_profile = "auto" # auto | low | mid | high — host-adaptive Windows-on-KVM tuning
|
||||
usb_live = true # bind the host USB bus so `device attach` hot-plugs live
|
||||
# devices = [] # passthrough device ids (managed by `winpodx device`)
|
||||
# disguise_level = "balanced" # bare-metal mode: off | balanced (default, free hiding) | max (Hyper-V off, slower). Nvidia code-43 / VM-hostile apps; not an anti-cheat bypass (#246)
|
||||
|
||||
[reverse_open]
|
||||
enabled = true # surface Linux apps in the Windows "Open with…" menu
|
||||
allowlist = [] # [] = offer every discovered host app
|
||||
denylist = ["foot", "kitty", "konsole", "gnome-terminal", "alacritty"]
|
||||
deny_dangerous = true # never reverse-open shells / risky launchers
|
||||
|
||||
[install]
|
||||
auto_resume = true # resume a deferred install step on the next launch
|
||||
# (the remaining [install] keys are agent / watchdog tuning, managed automatically)
|
||||
|
||||
[logging]
|
||||
level = "INFO" # DEBUG | INFO | WARNING | ERROR | RAW
|
||||
|
||||
[ui]
|
||||
language = "auto" # winpodx UI language: auto | en | ko | zh | ja | de | fr | it
|
||||
Vendored
+1036
File diff suppressed because it is too large
Load Diff
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
Source: winpodx
|
||||
Section: x11
|
||||
Priority: optional
|
||||
Maintainer: Kim DaeHyun <kernalix7@kodenet.io>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
dh-python,
|
||||
python3-all (>= 3.9),
|
||||
python3-setuptools,
|
||||
python3-wheel,
|
||||
python3-hatchling,
|
||||
python3-pip,
|
||||
pybuild-plugin-pyproject
|
||||
Standards-Version: 4.6.2
|
||||
Homepage: https://github.com/kernalix7/winpodx
|
||||
Vcs-Git: https://github.com/kernalix7/winpodx.git
|
||||
Vcs-Browser: https://github.com/kernalix7/winpodx
|
||||
Rules-Requires-Root: no
|
||||
|
||||
Package: winpodx
|
||||
Architecture: all
|
||||
Depends:
|
||||
${python3:Depends},
|
||||
${misc:Depends},
|
||||
python3 (>= 3.9),
|
||||
python3-tomli,
|
||||
freerdp-x11 | freerdp3-x11 | freerdp2-x11
|
||||
Recommends:
|
||||
podman,
|
||||
podman-compose,
|
||||
python3-pyside6.qtcore,
|
||||
python3-pyside6.qtgui,
|
||||
python3-pyside6.qtwidgets,
|
||||
python3-pyside6.qtsvg
|
||||
Description: Windows app integration for Linux desktop
|
||||
WinPodX runs Windows applications from a Podman or Docker
|
||||
backend and exposes them on the Linux desktop with desktop entries,
|
||||
MIME handlers, icons, and a Qt system tray.
|
||||
Vendored
+76
@@ -0,0 +1,76 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: winpodx
|
||||
Upstream-Contact: Kim DaeHyun <kernalix7@kodenet.io>
|
||||
Source: https://github.com/kernalix7/winpodx
|
||||
|
||||
Files: *
|
||||
Copyright: 2026 Kim DaeHyun <kernalix7@kodenet.io>
|
||||
License: MIT
|
||||
|
||||
Files: config/oem/rdprrap-*-windows-x64.zip
|
||||
Copyright: 2026 Kim DaeHyun <kernalix7@kodenet.io>
|
||||
License: MIT and Apache-2.0
|
||||
Comment: Pre-built binary bundle of rdprrap (https://github.com/kernalix7/rdprrap),
|
||||
redistributed under the same MIT license as WinPodX. Bundled at a
|
||||
SHA256-pinned version (0.1.3 onward) and extracted into the Windows
|
||||
guest during the first-boot OEM setup to enable multi-session RDP.
|
||||
.
|
||||
rdprrap's source tree ports code from three upstream projects whose
|
||||
licenses require source-level attribution:
|
||||
- stascorp/rdpwrap (Apache-2.0)
|
||||
- llccd/TermWrap (MIT)
|
||||
- llccd/RDPWrapOffsetFinder (MIT)
|
||||
.
|
||||
The bundled ZIP therefore ships LICENSE (rdprrap MIT), NOTICE (names
|
||||
each upstream and the derived rdprrap files), vendor/licenses/
|
||||
(verbatim upstream license texts), and THIRD_PARTY_LICENSES.txt (Rust
|
||||
crate dependencies). All four files are extracted into the guest at
|
||||
C:\Program Files\RDP Wrapper\ and C:\winpodx\rdprrap\ and are not
|
||||
re-extracted on the Debian host.
|
||||
.
|
||||
WinPodX 0.1.6 and earlier bundled rdprrap 0.1.0, which upstream later
|
||||
withdrew for missing these attribution files. Upgrade to WinPodX
|
||||
0.1.7 or later.
|
||||
|
||||
Files: config/oem/reverse-open/shim/bin/rcedit.exe
|
||||
Copyright: 2013-2024 GitHub Inc.
|
||||
License: MIT
|
||||
Comment: Pre-built binary of electron/rcedit
|
||||
(https://github.com/electron/rcedit) bundled to patch Windows PE
|
||||
metadata on the per-app reverse-open shim during OEM install.
|
||||
LICENSE-rcedit.txt sits beside the binary in the same directory and
|
||||
is also surfaced in the GUI License tab + THIRD_PARTY_LICENSES.md.
|
||||
|
||||
Files: config/oem/reverse-open/shim/bin/winpodx-reverse-open-shim.exe
|
||||
Copyright: 2026 Kim DaeHyun <kernalix7@kodenet.io>
|
||||
License: MIT
|
||||
Comment: Pre-built WinPodX-owned Rust stub (source at
|
||||
config/oem/reverse-open/shim/) that Windows Explorer invokes from the
|
||||
"Open with" menu to relay a file-open request back to the host's
|
||||
reverse-open listener. Same MIT terms as WinPodX.
|
||||
|
||||
License: MIT
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
License: Apache-2.0
|
||||
The rdprrap bundle ports code from stascorp/rdpwrap, licensed under the
|
||||
Apache License, Version 2.0. On Debian systems the complete license text
|
||||
is at `/usr/share/common-licenses/Apache-2.0`; the bundled ZIP also ships
|
||||
the verbatim upstream text under `vendor/licenses/`.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# Post-install banner for the winpodx debian package (#255 PR 4).
|
||||
#
|
||||
# Prints a hint that the user needs to run `winpodx setup` to finish
|
||||
# provisioning -- the package install only drops the binary + desktop
|
||||
# entry. We don't auto-trigger setup because dpkg postinst runs as
|
||||
# root with no TTY, and setup expects user-namespace context + a
|
||||
# ~10 min Windows ISO download nobody would expect from a
|
||||
# 'sudo apt install' line.
|
||||
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
cat <<'EOF'
|
||||
|
||||
[WinPodX] Package installed. Next step:
|
||||
- Open WinPodX from your application menu (GUI), OR
|
||||
- Run 'winpodx gui' in a terminal
|
||||
The GUI's first-run modal offers auto setup / customize wizard / skip.
|
||||
|
||||
For the curl-like all-in-one experience, run:
|
||||
winpodx setup
|
||||
|
||||
Full docs: https://github.com/kernalix7/winpodx/blob/main/docs/INSTALL.md
|
||||
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# Post-remove hook for the winpodx debian package (#255 PR 4).
|
||||
#
|
||||
# $1 is the action: remove, purge, upgrade, failed-upgrade, abort-
|
||||
# install, abort-upgrade, disappear. We hand the value off to the
|
||||
# shared packaging/scripts/postrm-common.sh which normalises across
|
||||
# debian / rpm / aur and runs the two-stage cleanup (process kill on
|
||||
# remove, full user-cleanup on purge).
|
||||
#
|
||||
# The script lives at /usr/share/winpodx/packaging/postrm-common.sh
|
||||
# after install (see debian/winpodx.docs + dh_install rule).
|
||||
|
||||
set -e
|
||||
|
||||
ACTION="$1"
|
||||
|
||||
# Bail on failed-upgrade / abort-* paths -- the package isn't actually
|
||||
# going away, leave user state alone.
|
||||
case "$ACTION" in
|
||||
remove|purge)
|
||||
# Run the common hook. Fall back to in-line minimal cleanup
|
||||
# if the helper isn't installed (paranoid: should always be
|
||||
# present alongside this script).
|
||||
if [ -x /usr/share/winpodx/packaging/postrm-common.sh ]; then
|
||||
/usr/share/winpodx/packaging/postrm-common.sh "$ACTION" || true
|
||||
else
|
||||
# Inline fallback: kill processes for each user. Skip the
|
||||
# full cleanup -- we don't want to do that without the
|
||||
# canonical 'winpodx uninstall --purge' path.
|
||||
for home in /home/*; do
|
||||
[ -d "$home" ] || continue
|
||||
user=$(basename "$home")
|
||||
runuser -u "$user" -- pkill -f 'python.*winpodx' >/dev/null 2>&1 || true
|
||||
runuser -u "$user" -- pkill -f 'winpodx-app' >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
export PYBUILD_NAME=winpodx
|
||||
export PYBUILD_SYSTEM=pyproject
|
||||
|
||||
%:
|
||||
dh $@ --with python3 --buildsystem=pybuild
|
||||
|
||||
# Tests run upstream in GitHub Actions CI across Python 3.9-3.13; skip them
|
||||
# here so .deb builds don't need pytest in Build-Depends on every target.
|
||||
override_dh_auto_test:
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
3.0 (native)
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
THIRD_PARTY_LICENSES.md
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
packaging/scripts/postrm-common.sh usr/share/winpodx/packaging/
|
||||
uninstall.sh usr/share/winpodx/
|
||||
@@ -0,0 +1,228 @@
|
||||
# 아키텍처
|
||||
|
||||
[English](ARCHITECTURE.md) | **한국어**
|
||||
|
||||
WinPodX 가 어떻게 조립되어 있는지: 앱 실행 시 데이터 흐름, 기술 스택, 소스 트리 레이아웃.
|
||||
|
||||
## 동작 방식
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
앱 메뉴에서 │ Linux Desktop (KDE, │
|
||||
"Word" 클릭 ───> │ GNOME, Sway, ...) │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ WinPodX │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 자동 프로비저닝: │ │
|
||||
│ │ config → password │ │
|
||||
│ │ → container → RDP │ │
|
||||
│ │ → desktop entries │ │
|
||||
│ └─────────────────────┘ │
|
||||
└──────────────┬──────────────┘
|
||||
│ FreeRDP RemoteApp
|
||||
┌──────────────▼──────────────┐
|
||||
│ Windows Container (Podman)│
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Word Excel PPT ...│ │
|
||||
│ │ multi-session/rdprrap│ │
|
||||
│ └──────────────────────┘ │
|
||||
│ 127.0.0.1:3390 (TLS) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
Pod 의 명령 채널은 게스트 안에서 `127.0.0.1:8765` 에 listen 하는 bearer-auth HTTP agent (loopback 전용). RDP 자체는 `127.0.0.1:3390` 에서 TLS 암호화로 동작. Reverse-open (Linux 앱이 Windows "Open with..." 메뉴에 노출되는 기능) 은 별도의 호스트 측 listener daemon 이 `\\tsclient\home` 공유를 통해 요청을 받음.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
| 레이어 | 기술 |
|
||||
|-------|------|
|
||||
| 언어 | Python 3.9+ (3.11+ 는 stdlib 만; 3.9/3.10 은 `tomli` 폴백) |
|
||||
| CLI | argparse (stdlib) |
|
||||
| GUI (선택) | PySide6 (Qt6) |
|
||||
| 설정 | TOML (3.11+ 는 stdlib `tomllib` / 3.9/3.10 은 `tomli`; 자체 writer) |
|
||||
| RDP | FreeRDP 3+ (xfreerdp, RemoteApp/RAIL) |
|
||||
| Guest agent | PowerShell `HttpListener` on `127.0.0.1:8765` (bearer auth, base64 인코딩 `/exec` payload) |
|
||||
| 컨테이너 | Podman / Docker ([dockur/windows](https://github.com/dockur/windows)) |
|
||||
| 하이퍼바이저 | QEMU / KVM (dockur 컨테이너 내부; 호스트 USB / PCI 장치 패스스루가 이 레이어에 연결됨) |
|
||||
| Reverse-open shim | Rust (`windows_subsystem = "windows"`, vendored rcedit 로 슬러그별 아이콘 embed) |
|
||||
| i18n | `winpodx.core.i18n` (영어 원문을 key 로, 언어별 flat JSON 카탈로그) |
|
||||
| CI | GitHub Actions (lint + test on 3.9-3.13 + pip-audit) |
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
```
|
||||
winpodx/
|
||||
├── install.sh # 원라인 인스톨러 (pip 없음)
|
||||
├── uninstall.sh # 깔끔한 언인스톨러
|
||||
├── src/winpodx/
|
||||
│ ├── cli/ # argparse 명령 (app, pod, config, setup, host-open, ...)
|
||||
│ ├── core/ # Config, RDP, pod lifecycle, provisioner, daemon
|
||||
│ ├── backend/ # Podman, Docker, manual
|
||||
│ ├── desktop/ # .desktop 엔트리, 아이콘, MIME, tray, 알림
|
||||
│ ├── display/ # X11/Wayland 감지, DPI 스케일링
|
||||
│ ├── gui/ # Qt6 메인 윈도, 앱 다이얼로그, 테마, reverse-open Settings 카드
|
||||
│ ├── reverse_open/ # Discovery, ICO 변환, listener daemon, sync transport
|
||||
│ └── utils/ # XDG 경로, 의존성, TOML writer, winapps 호환
|
||||
├── data/ # winpodx GUI desktop 엔트리 + 아이콘 + 설정 예시
|
||||
├── config/oem/
|
||||
│ ├── install.bat # Windows OEM 첫 부팅 오케스트레이션
|
||||
│ └── reverse-open/ # register-apps.ps1, unregister-apps.ps1, Rust shim, rcedit
|
||||
├── scripts/windows/ # PowerShell 스크립트 (debloat, time sync, USB mapping, 앱 discovery)
|
||||
├── packaging/ # OBS / AUR / RHEL spec + 메인테이너 문서
|
||||
├── debian/ # Debian 소스 패키지 레이아웃
|
||||
├── docs/ # 사용자 문서 (영어 + 한국어 mirror)
|
||||
├── .github/workflows/ # CI: lint + test + publish (OBS / RHEL / deb / AUR)
|
||||
└── tests/ # pytest 테스트 스위트
|
||||
```
|
||||
|
||||
## 주요 데이터 흐름
|
||||
|
||||
- **앱 실행.** CLI → `provisioner.ensure_ready()` (config + 비밀번호 회전 + compose + resume + pod + bundled apps + desktop 엔트리) → FreeRDP 세션 → `.cproc` 추적 + reaper 스레드 + desktop 알림.
|
||||
- **앱 설치 (Linux 측).** AppInfo (TOML) → `.desktop` 파일 생성 → 아이콘 설치 → MIME 등록 → 아이콘 캐시 refresh.
|
||||
- **파일 열기 (host → guest).** Linux 경로 → UNC 경로 변환 (`\\tsclient\home\...`) → RDP `/app-cmd`.
|
||||
- **자동 suspend.** `daemon.run_idle_monitor()` → N 초 동안 세션 없으면 `podman pause` → lock 파일 정리.
|
||||
- **자동 resume.** `provisioner` → `daemon.ensure_pod_awake()` → `podman unpause` → RDP 대기.
|
||||
- **비밀번호 회전.** `ensure_ready()` → `password_max_age` 확인 → 새 비밀번호 생성 → config + compose 저장 → 컨테이너 재생성 → 실패 시 rollback.
|
||||
- **Reverse-open (guest → host).** Windows Explorer "Open with..." → 슬러그별 `winpodx-<slug>.exe` shim → `\\tsclient\home\.local\share\winpodx\reverse-open\incoming\<uuid>.json` 에 atomic JSON 쓰기 → host listener 가 픽업 → `safe_open_unc` TOCTOU-safe 경로 해소 → 호스트에서 `xdg-open` 호출.
|
||||
- **장치 패스스루 (host → guest).** `winpodx device list / attach <id> / detach <id>` (GUI "Devices" 페이지 + 트레이 USB 스위처도 제공) → QEMU (dockur) 레이어에서 장치를 게스트로 연결. USB 는 live hot-plug (`cfg.pod.usb_live`, 기본 on); PCI 는 boot-add 방식이라 게스트 재시작과 안전 확인 (`--force` / 다이얼로그) 필요.
|
||||
|
||||
## Guest sync 서브시스템
|
||||
|
||||
**코드.** `src/winpodx/core/guest_sync.py`. 설계 노트: [docs/design/GUEST_SYNC_DESIGN.md](design/GUEST_SYNC_DESIGN.md).
|
||||
|
||||
호스트의 WinPodX 를 업그레이드하면 호스트 바이너리는 갱신되지만, 첫 설치
|
||||
때 staging 된 게스트 측 아티팩트 (`C:\OEM\agent.ps1`, urlacl 예약,
|
||||
rdprrap / `shim.exe` / `rcedit.exe`, 헬퍼 스크립트) 는 사용자가 Windows 를
|
||||
밀고 재설치하기 전까지 stale 상태로 남습니다. Guest sync 가 재설치 없이 이
|
||||
간극을 메웁니다.
|
||||
|
||||
**핵심 enabler.** `/oem` 은 호스트 `config/oem` 의 **live bind mount**
|
||||
(`compose.py` 의 `{oem_dir}:/oem:Z`) — 즉 호스트 업그레이드 후 동작 중인
|
||||
컨테이너의 `/oem` 에 *이미* 새 파일이 들어 있음 (이미지 재빌드 없음).
|
||||
게스트 전달은 `winpodx guest recover-oem` 과 동일 채널 재사용: 컨테이너에서
|
||||
`/oem` tar → `127.0.0.1:8766` 일회성 HTTP 서버로 serve → 게스트가 QEMU NAT
|
||||
게이트웨이 `10.0.2.2` 로 pull. sync 중에는 agent 가 살아 있으므로 pull 과
|
||||
후속 fix 가 noVNC paste 대신 bearer-auth `/exec` 엔드포인트로 동작.
|
||||
|
||||
`sync_guest` 는 부분 실패 후 재실행이 안전하도록 순서가 잡혀 있음:
|
||||
|
||||
1. **`/oem` 전달** — 게스트 `Invoke-WebRequest` + `tar -xzf` → `C:\OEM`.
|
||||
`install.bat` 은 **재실행 안 함** (autologon / 계정 설정 같은 one-shot
|
||||
첫 부팅 로직을 담고 있어 live install 에서 재발사하면 안 됨).
|
||||
2. **urlacl 예약** — install.bat 의 netsh 블록을 `/exec` 로 재적용
|
||||
(겹치는 `:8765` 예약 삭제 후 `WD` SID SDDL 로 `http://+:8765/` 재등록).
|
||||
3. **Idempotent 레지스트리 / runtime fix** — `apply_windows_runtime_fixes(cfg)`
|
||||
호출 (apply-fixes 와 동일 체인). 갱신된 바이너리에 대해 rdprrap 도 재활성화.
|
||||
4. **agent 재시작** — agent 는 자신이 실행되는 `/exec` 를 제공하므로 동기적으로
|
||||
스스로를 `Stop-Process` 할 수 없음. **one-shot scheduled task** 가 ~5초 뒤
|
||||
발사해 현재 agent 를 멈추고 `C:\OEM\agent.ps1` 을 재기동; `/exec` 호출이 먼저
|
||||
반환된 뒤 새 agent 가 교정된 urlacl 로 `:8765` 재바인드.
|
||||
5. **버전 stamp** — 1–3 단계가 성공한 경우에만
|
||||
`C:\winpodx\install-state\guest_version.json` (`{winpodx, oem_bundle}`) 작성.
|
||||
|
||||
**Staleness 판정.** 호스트 current = `winpodx.__version__` +
|
||||
`core.info._bundled_oem_version()`. `guest_sync_needed(cfg)` 가 `/exec` 로 stamp
|
||||
읽음; stamp 가 존재 **하고** 오래됐으면 sync 발사, stamp 부재면 기록만 (진행 중인
|
||||
첫 부팅 install 을 방해 안 함). pod readiness 후 `cfg.pod.guest_autosync`
|
||||
(default `True`) 면 자동 실행, podman/docker 로 gate. 수동:
|
||||
`winpodx guest sync [--force]` 와 GUI Tools → Sync Guest 액션. `sync_guest`
|
||||
는 CLI/GUI 가 행을 렌더링할 수 있도록 단계별 결과 맵 반환.
|
||||
|
||||
## Disk auto-grow 서브시스템
|
||||
|
||||
**코드.** `src/winpodx/core/disk.py` (sizing + 게스트 extend),
|
||||
`src/winpodx/core/daemon.py` (idle 경로) 에서 트리거.
|
||||
|
||||
dockur 는 `cfg.pod.disk_size` 가 증가하고 컨테이너가 재생성될 때만 가상 디스크
|
||||
*이미지*를 키울 뿐, 게스트의 C: 파티션은 절대 확장 안 하고 **online resize 도
|
||||
없음**. WinPodX 가 양쪽을 처리하는 idle-time auto-grow 를 추가.
|
||||
|
||||
**트리거.** pod start / idle 시, C: used% 가
|
||||
`cfg.pod.disk_autogrow_threshold_pct` (default 80) 를 넘고 **동시에** pod 이 idle 일 때.
|
||||
|
||||
**Sizing.** `cfg.pod.disk_autogrow_target_free_pct` 여유 (default 30%) 를 복원할
|
||||
만큼만 키우되 `cfg.pod.disk_autogrow_increment` 단위 (default `32G`) 로 올림.
|
||||
ceiling 은 선택적 `cfg.pod.disk_max_size` 와 *호스트가 실제로 back 가능한 양* —
|
||||
`current + (host_free − reserve)` (reserve 가 auto-grow 의 호스트 디스크 고갈 방지)
|
||||
중 작은 쪽. 어느 headroom 도 없으면 로그 한 줄과 함께 grow 스킵.
|
||||
|
||||
**왜 idle 전용.** dockur 에 online resize 가 없으므로 매 grow 가 **컨테이너를
|
||||
재생성** (빠른 게스트 reboot). idle 전용 스케줄링이 live RemoteApp 세션을 절대
|
||||
중단 안 함을 보장.
|
||||
|
||||
**게스트 extend.** 이미지가 커지면 새 공간은 디스크 끝에 붙지만 C: 는 원래
|
||||
위치에서 끝남. extend 는 `/exec` 로 동작: `Resize-Partition -DriveLetter C`.
|
||||
dockur 의 Windows 레이아웃은 C: **바로 뒤**에 작은 WinRE Recovery 파티션을 두어
|
||||
extend 를 막으므로 — 단계가 WinRE 를 detach (`reagentc /disable`), 막는 recovery
|
||||
파티션 삭제, C: extend, 그 다음 WinRE 재활성화 (`reagentc /enable`, 전용 파티션이
|
||||
없으면 `C:\Windows` 로 폴백).
|
||||
|
||||
## UI 국제화 (i18n)
|
||||
|
||||
**코드.** `src/winpodx/core/i18n.py`; 카탈로그 `src/winpodx/locale/<lang>.json`.
|
||||
|
||||
Linux 측 UI 텍스트 (tray, GUI, CLI) 는 `winpodx.core.i18n.tr(text)` 로 감쌈.
|
||||
**영어 원문이 카탈로그 key** — `tr()` 이 활성 언어 카탈로그에서 원문을 찾고,
|
||||
miss 시 동일 영어 원문으로 문자열별 폴백 → 카탈로그가 불완전해도 UI 가 비지 않음.
|
||||
카탈로그는 flat `{ "<english>": "<translation>" }` JSON. 활성 언어는
|
||||
`[ui] language` (default `auto` — 호스트 로케일을 `$LC_ALL` / `$LC_MESSAGES` /
|
||||
`$LANG` 에서 매핑, unknown → 영어) 에서 해소. 7개 언어 제공: en, ko, zh, ja, de,
|
||||
fr, it. (`pod.language` 와는 별개 — 그건 *Windows 게스트* 설치 언어.)
|
||||
|
||||
## 고급: 커스텀 Windows ISO
|
||||
|
||||
WinPodX 는 dockur 가 큐레이트한 Windows 에디션 (Win10 / 11, LTSC, IoT
|
||||
LTSC, Tiny, Server 2016+) 을 기본 지원합니다. 목록은
|
||||
`src/winpodx/core/config.py` 의 `_KNOWN_WIN_VERSIONS` 에 있고 GUI
|
||||
설정 → Container/VM 카드 드롭다운으로 노출됩니다.
|
||||
|
||||
dockur 가 큐레이트하지 **않는** Windows ISO (직접 만든 프로그램 미리
|
||||
깐 이미지, 특정 debloat 프리셋이 들어간 Enterprise 에디션, dockur 가
|
||||
태그하지 않은 로컬라이즈 빌드) 를 부팅하고 싶다면 수동으로 통과시킬
|
||||
수 있습니다. **이 경로는 비공식 / 비지원** — WinPodX 의 OEM 스크립트
|
||||
(`install.bat`, `agent.ps1`, `rdprrap`) 는 dockur 큐레이트 Win10+
|
||||
패밀리에 맞춰 작성되어 있습니다. 커스텀 ISO 는 부팅은 될 수 있어도
|
||||
에이전트 / 멀티세션 활성화 / RemoteApp 디스커버리가 깨질 수 있습니다.
|
||||
커스텀 ISO 관련 버그 리포트는 사용자가 직접 디버깅해야 합니다.
|
||||
|
||||
위 면책 조항에 동의한다면:
|
||||
|
||||
1. ISO 파일을 읽기 가능한 위치에 둡니다 (예: `~/winpodx-custom.iso`).
|
||||
2. `winpodx.toml` 에 `win_version = "custom"` 설정:
|
||||
|
||||
```toml
|
||||
[pod]
|
||||
win_version = "custom"
|
||||
```
|
||||
|
||||
WinPodX 는 "known list 에 없다" WARNING 한 줄을 로그에 남기고
|
||||
값을 dockur 에 그대로 통과시킵니다.
|
||||
|
||||
3. 생성된 `~/.config/winpodx/compose.yaml` 을 편집해서 dockur 가
|
||||
기대하는 경로에 ISO 마운트:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
windows:
|
||||
volumes:
|
||||
- ~/winpodx-custom.iso:/storage/custom.iso
|
||||
# ...기존 volumes 는 그대로
|
||||
```
|
||||
|
||||
4. 컨테이너 재생성:
|
||||
|
||||
```bash
|
||||
winpodx pod stop
|
||||
podman compose -f ~/.config/winpodx/compose.yaml up -d
|
||||
```
|
||||
|
||||
compose 템플릿은 일부 코드 경로에서 (`winpodx setup`, `winpodx pod
|
||||
start`, GUI 의 Save 버튼이 cpu / ram / port / user 변경을 감지했을 때
|
||||
등) `winpodx setup` 이 재생성합니다 — 그 시점에 수동 편집이 덮어
|
||||
써집니다. 재생성 후 다시 적용해야 합니다.
|
||||
|
||||
이 패턴을 자주 사용하게 되고 upstream dockur 가 해당 에디션을 추가하지
|
||||
않는다면 feature request 를 올려주세요: `cfg.pod.custom_iso_path`
|
||||
필드 추가는 검토 대상이지만 아직 정식 출시되지 않았습니다.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Architecture
|
||||
|
||||
**English** | [한국어](ARCHITECTURE.ko.md)
|
||||
|
||||
How WinPodX is put together: the data flow on app launch, the technology stack, and the source tree layout.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
Click "Word" │ Linux Desktop (KDE, │
|
||||
in app menu ───> │ GNOME, Sway, ...) │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────┐
|
||||
│ WinPodX │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ auto-provision: │ │
|
||||
│ │ config → password │ │
|
||||
│ │ → container → RDP │ │
|
||||
│ │ → desktop entries │ │
|
||||
│ └─────────────────────┘ │
|
||||
└──────────────┬──────────────┘
|
||||
│ FreeRDP RemoteApp
|
||||
┌──────────────▼──────────────┐
|
||||
│ Windows Container (Podman)│
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ Word Excel PPT ...│ │
|
||||
│ │ multi-session/rdprrap│ │
|
||||
│ └──────────────────────┘ │
|
||||
│ 127.0.0.1:3390 (TLS) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
The pod's command channel is a bearer-authed HTTP agent listening on `127.0.0.1:8765` inside the guest (loopback only). RDP itself runs on `127.0.0.1:3390` with TLS encryption. Reverse-open (Linux apps appearing in the Windows "Open with..." menu) runs through a separate host-side listener daemon that receives requests pushed via the `\\tsclient\home` share.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Language | Python 3.9+ (stdlib only on 3.11+; `tomli` fallback on 3.9/3.10) |
|
||||
| CLI | argparse (stdlib) |
|
||||
| GUI (optional) | PySide6 (Qt6) |
|
||||
| Config | TOML (stdlib `tomllib` on 3.11+ / `tomli` on 3.9/3.10; built-in writer) |
|
||||
| RDP | FreeRDP 3+ (xfreerdp, RemoteApp/RAIL) |
|
||||
| Guest agent | PowerShell `HttpListener` on `127.0.0.1:8765` (bearer auth, base64-encoded `/exec` payloads) |
|
||||
| Container | Podman / Docker ([dockur/windows](https://github.com/dockur/windows)) |
|
||||
| Hypervisor | QEMU / KVM (inside the dockur container; host USB / PCI device passthrough is wired at this layer) |
|
||||
| Reverse-open shim | Rust (`windows_subsystem = "windows"`, embedded per-slug icon via vendored rcedit) |
|
||||
| i18n | `winpodx.core.i18n` (English-source-as-key, flat JSON catalogs per language) |
|
||||
| CI | GitHub Actions (lint + test on 3.9-3.13 + pip-audit) |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
winpodx/
|
||||
├── install.sh # One-line installer (no pip)
|
||||
├── uninstall.sh # Clean uninstaller
|
||||
├── src/winpodx/
|
||||
│ ├── cli/ # argparse commands (app, pod, config, setup, host-open, ...)
|
||||
│ ├── core/ # Config, RDP, pod lifecycle, provisioner, daemon
|
||||
│ ├── backend/ # Podman, Docker, manual
|
||||
│ ├── desktop/ # .desktop entries, icons, MIME, tray, notifications
|
||||
│ ├── display/ # X11/Wayland detection, DPI scaling
|
||||
│ ├── gui/ # Qt6 main window, app dialog, theme, reverse-open Settings card
|
||||
│ ├── reverse_open/ # Discovery, ICO conversion, listener daemon, sync transport
|
||||
│ └── utils/ # XDG paths, deps, TOML writer, winapps compat
|
||||
├── data/ # winpodx GUI desktop entry + icon + config example
|
||||
├── config/oem/
|
||||
│ ├── install.bat # Windows OEM first-boot orchestration
|
||||
│ └── reverse-open/ # register-apps.ps1, unregister-apps.ps1, Rust shim, rcedit
|
||||
├── scripts/windows/ # PowerShell scripts (debloat, time sync, USB mapping, app discovery)
|
||||
├── packaging/ # OBS / AUR / RHEL spec + maintainer docs
|
||||
├── debian/ # Debian source package layout
|
||||
├── docs/ # User docs (English + Korean mirrors)
|
||||
├── .github/workflows/ # CI: lint + test + publish (OBS / RHEL / deb / AUR)
|
||||
└── tests/ # pytest test suite
|
||||
```
|
||||
|
||||
## Key Data Flows
|
||||
|
||||
- **App launch.** CLI → `provisioner.ensure_ready()` (config + password rotation + compose + resume + pod + bundled apps + desktop entries) → FreeRDP session → `.cproc` tracking + reaper thread + desktop notification.
|
||||
- **App install (Linux side).** AppInfo (TOML) → `.desktop` file generation → icon install → MIME registration → icon cache refresh.
|
||||
- **File open (host → guest).** Linux path → UNC path conversion (`\\tsclient\home\...`) → RDP `/app-cmd`.
|
||||
- **Auto suspend.** `daemon.run_idle_monitor()` → no sessions for N seconds → `podman pause` → lock file cleanup.
|
||||
- **Auto resume.** `provisioner` → `daemon.ensure_pod_awake()` → `podman unpause` → wait for RDP.
|
||||
- **Password rotation.** `ensure_ready()` → check `password_max_age` → generate new password → save config + compose → recreate container → rollback on failure.
|
||||
- **Reverse-open (guest → host).** Windows Explorer "Open with..." → per-slug `winpodx-<slug>.exe` shim → atomic JSON write to `\\tsclient\home\.local\share\winpodx\reverse-open\incoming\<uuid>.json` → host listener picks it up → `safe_open_unc` TOCTOU-safe path resolution → `xdg-open` invocation on the host.
|
||||
- **Device passthrough (host → guest).** `winpodx device list / attach <id> / detach <id>` (also a GUI "Devices" page and a tray USB switcher) → device wired through to the guest at the QEMU (dockur) layer. USB hot-plugs live (`cfg.pod.usb_live`, default on); PCI is boot-added and needs a guest restart plus a safety confirmation (`--force` / dialog).
|
||||
|
||||
## Guest sync subsystem
|
||||
|
||||
**Code.** `src/winpodx/core/guest_sync.py`. Design notes: [docs/design/GUEST_SYNC_DESIGN.md](design/GUEST_SYNC_DESIGN.md).
|
||||
|
||||
Upgrading WinPodX on the host updates the host binary, but the guest-side
|
||||
artifacts staged at first install (`C:\OEM\agent.ps1`, the urlacl reservation,
|
||||
rdprrap / `shim.exe` / `rcedit.exe`, helper scripts) would otherwise go stale
|
||||
until the user wipes and reinstalls Windows. Guest sync closes that gap
|
||||
without a reinstall.
|
||||
|
||||
**Key enabler.** `/oem` is a **live bind mount** of the host's `config/oem`
|
||||
(`{oem_dir}:/oem:Z` in `compose.py`), so after a host upgrade the running
|
||||
container's `/oem` *already* holds the new files — no image rebuild. Delivery
|
||||
into the guest reuses the same channel as `winpodx guest recover-oem`: tar `/oem`
|
||||
in the container → serve it over a one-shot HTTP server on `127.0.0.1:8766`
|
||||
→ guest pulls via the QEMU NAT gateway `10.0.2.2`. Because the agent is alive
|
||||
during sync, the pull and follow-up fixes run over the bearer-authed `/exec`
|
||||
endpoint rather than the noVNC paste path.
|
||||
|
||||
`sync_guest` is ordered so a partial failure is safe to re-run:
|
||||
|
||||
1. **Deliver `/oem`** — guest `Invoke-WebRequest` + `tar -xzf` into `C:\OEM`.
|
||||
`install.bat` is **not** re-run (it carries one-shot first-boot logic —
|
||||
autologon, account setup — that must not fire on a live install).
|
||||
2. **urlacl reservation** — re-applies install.bat's netsh block over `/exec`
|
||||
(delete overlapping `:8765` reservations, re-add `http://+:8765/` with the
|
||||
`WD` SID SDDL).
|
||||
3. **Idempotent registry / runtime fixes** — calls
|
||||
`apply_windows_runtime_fixes(cfg)` (same chain as apply-fixes), which also
|
||||
re-activates rdprrap against the refreshed binaries.
|
||||
4. **Restart the agent** — the agent serves the `/exec` it runs through, so it
|
||||
can't `Stop-Process` itself synchronously. A **one-shot scheduled task**
|
||||
fires ~5 s later to stop and relaunch `C:\OEM\agent.ps1`; the `/exec` call
|
||||
returns first, then the new agent rebinds `:8765` under the corrected urlacl.
|
||||
5. **Stamp version** — writes `C:\winpodx\install-state\guest_version.json`
|
||||
(`{winpodx, oem_bundle}`) only after steps 1–3 succeed.
|
||||
|
||||
**Staleness check.** Host current = `winpodx.__version__` +
|
||||
`core.info._bundled_oem_version()`. `guest_sync_needed(cfg)` reads the stamp via
|
||||
`/exec`; a stamp that is present **and** older triggers a sync, a missing stamp
|
||||
is recorded only (no disruption during a first-boot install still in progress).
|
||||
Auto-runs after pod readiness when `cfg.pod.guest_autosync` (default `True`) is
|
||||
set, gated to podman/docker. Manual: `winpodx guest sync [--force]` and a
|
||||
GUI Tools → Sync Guest action. `sync_guest` returns a per-step result map so
|
||||
the CLI/GUI can render rows.
|
||||
|
||||
## Disk auto-grow subsystem
|
||||
|
||||
**Code.** `src/winpodx/core/disk.py` (sizing + guest extend), triggered from
|
||||
`src/winpodx/core/daemon.py` (idle path).
|
||||
|
||||
dockur only grows the virtual disk *image* when `cfg.pod.disk_size` increases
|
||||
and the container is recreated — it never extends the guest's C: partition, and
|
||||
it has **no online resize**. WinPodX adds an idle-time auto-grow that handles
|
||||
both ends.
|
||||
|
||||
**Trigger.** On pod start / idle, if C: used% exceeds
|
||||
`cfg.pod.disk_autogrow_threshold_pct` (default 80) **and** the pod is idle.
|
||||
|
||||
**Sizing.** Grows the image just enough to restore
|
||||
`cfg.pod.disk_autogrow_target_free_pct` free (default 30%), rounded up to whole
|
||||
`cfg.pod.disk_autogrow_increment` steps (default `32G`). The ceiling is the
|
||||
smaller of the optional `cfg.pod.disk_max_size` and *what the host can actually
|
||||
back* — `current + (host_free − reserve)`, where the reserve keeps auto-grow
|
||||
from consuming the last of the host disk. If neither headroom is available the
|
||||
grow is skipped with a log line.
|
||||
|
||||
**Why idle-only.** Since dockur has no online resize, every grow **recreates
|
||||
the container** (a quick guest reboot). Scheduling it idle-only guarantees it
|
||||
never interrupts a live RemoteApp session.
|
||||
|
||||
**Guest extend.** After the image grows, the new space lands at the end of the
|
||||
disk but C: still ends where it did. The extend runs over `/exec`:
|
||||
`Resize-Partition -DriveLetter C`. dockur's Windows layout puts a small WinRE
|
||||
Recovery partition **right after** C:, blocking the extend — so the step
|
||||
detaches WinRE (`reagentc /disable`), deletes the blocking recovery partition,
|
||||
extends C:, then re-enables WinRE (`reagentc /enable`, which falls back to
|
||||
`C:\Windows` when no dedicated partition is present).
|
||||
|
||||
## UI internationalization (i18n)
|
||||
|
||||
**Code.** `src/winpodx/core/i18n.py`; catalogs in `src/winpodx/locale/<lang>.json`.
|
||||
|
||||
The Linux-side UI text (tray, GUI, CLI) is wrapped in
|
||||
`winpodx.core.i18n.tr(text)`. The **English string is the catalog key** —
|
||||
`tr()` looks the source string up in the active-language catalog and falls back
|
||||
to that same English source per-string on a miss, so an incomplete catalog
|
||||
never blanks the UI. Catalogs are flat `{ "<english>": "<translation>" }` JSON.
|
||||
The active language is resolved from `[ui] language` (default `auto`, which maps
|
||||
the host locale from `$LC_ALL` / `$LC_MESSAGES` / `$LANG`, unknown → English).
|
||||
Seven languages ship: en, ko, zh, ja, de, fr, it. (Distinct from
|
||||
`pod.language`, which is the *Windows guest* install language.)
|
||||
|
||||
## Advanced: Custom Windows ISO
|
||||
|
||||
WinPodX ships first-class support for the dockur-curated Windows
|
||||
editions (Win10 / 11, LTSC, IoT LTSC, Tiny, Server 2016+). The list
|
||||
lives in `_KNOWN_WIN_VERSIONS` in `src/winpodx/core/config.py` and
|
||||
the GUI Settings → Container/VM card exposes it as a dropdown.
|
||||
|
||||
If you need to boot a Windows ISO that dockur does **not** curate
|
||||
(your own pre-loaded installer image, an Enterprise edition with
|
||||
specific debloat preset, a localised build dockur hasn't tagged),
|
||||
you can pass it through manually. **This path is unsupported** —
|
||||
WinPodX's OEM scripts (`install.bat`, `agent.ps1`, `rdprrap`) are
|
||||
written against the dockur-curated Win10+ family. A custom ISO may
|
||||
boot but fail to surface the agent, the multi-session enabler, or
|
||||
RemoteApp discovery. Bug reports specific to custom-ISO installs
|
||||
fall on you to debug.
|
||||
|
||||
With that disclaimer:
|
||||
|
||||
1. Place your `.iso` somewhere readable (e.g. `~/winpodx-custom.iso`).
|
||||
2. Edit your `winpodx.toml` to set `win_version = "custom"`:
|
||||
|
||||
```toml
|
||||
[pod]
|
||||
win_version = "custom"
|
||||
```
|
||||
|
||||
WinPodX will log a one-line WARNING that the value isn't on its
|
||||
known list, then pass it through to dockur as-is.
|
||||
|
||||
3. Edit the generated `~/.config/winpodx/compose.yaml` to mount the
|
||||
ISO at the path dockur looks for:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
windows:
|
||||
volumes:
|
||||
- ~/winpodx-custom.iso:/storage/custom.iso
|
||||
# ...existing volumes stay
|
||||
```
|
||||
|
||||
4. Recreate the container:
|
||||
|
||||
```bash
|
||||
winpodx pod stop
|
||||
podman compose -f ~/.config/winpodx/compose.yaml up -d
|
||||
```
|
||||
|
||||
The compose template is regenerated by `winpodx setup` and
|
||||
`winpodx pod start` on certain code paths (cpu / ram / port / user
|
||||
changes via the GUI Save button, for example) — your manual edit
|
||||
will be overwritten there. Re-apply after any such regeneration.
|
||||
|
||||
If you find yourself doing this routinely and the upstream dockur
|
||||
project doesn't carry your edition, file a feature request: a
|
||||
narrow `cfg.pod.custom_iso_path` field is on the table but is not
|
||||
shipped today.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
# Contributor Covenant 행동 강령
|
||||
|
||||
[English](../CODE_OF_CONDUCT.md) | **한국어**
|
||||
|
||||
## 우리의 약속
|
||||
|
||||
우리 구성원, 기여자, 메인테이너는 나이, 체형, 눈에 보이는/보이지 않는 장애,
|
||||
민족, 성적 특성, 성 정체성과 표현, 경험 수준, 교육, 사회경제적 지위,
|
||||
국적, 외모, 인종, 종교, 성적 지향과 무관하게 모두에게 괴롭힘 없는
|
||||
커뮤니티 참여 경험을 제공할 것을 약속합니다.
|
||||
|
||||
우리는 개방적이고, 환영하며, 다양하고, 포용적이며, 건강한 커뮤니티를
|
||||
만드는 방향으로 행동하고 상호작용할 것을 약속합니다.
|
||||
|
||||
## 우리의 기준
|
||||
|
||||
긍정적 환경에 기여하는 행동 예시:
|
||||
- 공감과 친절을 보이는 행동
|
||||
- 서로 다른 의견과 경험에 대한 존중
|
||||
- 건설적 피드백을 주고받는 태도
|
||||
- 실수에 대한 책임과 개선 노력
|
||||
- 커뮤니티 전체에 가장 이로운 방향의 선택
|
||||
|
||||
허용되지 않는 행동 예시:
|
||||
- 성적 언어, 이미지, 원치 않는 관심
|
||||
- 트롤링, 모욕적/비하적 발언
|
||||
- 공개적 또는 사적인 괴롭힘
|
||||
- 동의 없는 개인정보 공개
|
||||
- 전문적 환경에 부적절한 기타 행위
|
||||
|
||||
## 집행 책임
|
||||
|
||||
프로젝트 메인테이너는 허용 가능한 행동 기준을 명확히 하고 집행할 책임이 있으며,
|
||||
필요 시 공정하고 적절한 시정 조치를 취합니다.
|
||||
|
||||
## 적용 범위
|
||||
|
||||
이 행동 강령은 프로젝트의 모든 공간에서 적용되며,
|
||||
공식적으로 프로젝트를 대표하는 공개 활동에서도 적용됩니다.
|
||||
|
||||
## 신고 및 집행
|
||||
|
||||
부적절한 행동은 메인테이너에게 신고해 주세요.
|
||||
비민감 사안은 저장소 Discussions/Issues를 사용할 수 있으며,
|
||||
민감 사안은 저장소 프로필에 기재된 비공개 연락 채널을 이용해 주세요.
|
||||
|
||||
모든 신고는 신속하고 공정하게 검토 및 조사됩니다.
|
||||
|
||||
## 집행 가이드라인
|
||||
|
||||
커뮤니티 영향도 기준(Contributor Covenant 기반):
|
||||
1. 시정 요청
|
||||
2. 경고
|
||||
3. 일시적 활동 제한
|
||||
4. 영구적 활동 제한
|
||||
|
||||
## 출처
|
||||
|
||||
본 문서는 Contributor Covenant v2.1을 기반으로 작성되었습니다:
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
@@ -0,0 +1,55 @@
|
||||
# 비교
|
||||
|
||||
[English](COMPARISON.md) | **한국어**
|
||||
|
||||
Linux 에서 Windows 앱을 실행하기 위한 다른 도구들과 WinPodX 의 비교.
|
||||
|
||||
## 왜 WinPodX인가?
|
||||
|
||||
Linux 에서 Windows 앱을 실행하는 기존 도구들은 각각 한계가 있습니다:
|
||||
|
||||
| | winapps | LinOffice | winboat | WinPodX |
|
||||
|---|---|---|---|---|
|
||||
| 핵심 기술 | RDP 가능한 Windows 호스트 (클라우드 / 물리 / 컨테이너) + FreeRDP | dockur + FreeRDP | dockur + FreeRDP | dockur (Podman) + FreeRDP + HTTP guest agent |
|
||||
| 설정 | 수동 (셸 + 설정 파일 + RDP 테스트) | 원라인 스크립트 | 원클릭 GUI 설치 | **제로 설정** (첫 실행 시 자동) |
|
||||
| 인터페이스 | CLI 만 | CLI 만 | Electron GUI | **Qt6 GUI + CLI + 트레이** |
|
||||
| 앱 범위 | 모든 Windows 앱 | Office 전용 | 모든 Windows 앱 | 모든 Windows 앱 |
|
||||
| 언어 | Shell (86%) | Shell + Python | TypeScript / Vue / Go | **Python (100%)** |
|
||||
| 런타임 의존성 | curl, dialog, git, netcat | Podman, FreeRDP | Electron, Docker/Podman, FreeRDP | **Python 3.9+, FreeRDP, Podman** |
|
||||
| 자동 suspend / resume | 없음 | 없음 | 문서화 안 됨 | **있음 (idle timeout)** |
|
||||
| 비밀번호 회전 | 없음 | 없음 | 문서화 안 됨 | **있음 (7일, atomic)** |
|
||||
| HiDPI 자동 감지 | 없음 | 없음 | 문서화 안 됨 | **GNOME, KDE, Sway, Hyprland, Cinnamon, xrdb** |
|
||||
| 사운드 기본 | 없음 | 없음 | 있음 (FreeRDP) | 있음 (FreeRDP) |
|
||||
| 프린터 리디렉션 기본 | 없음 | 없음 | 문서화 안 됨 | 있음 (FreeRDP) |
|
||||
| USB 드라이브 자동 매핑 | 없음 | 없음 | 스마트카드 패스스루 | **드라이브 서브폴더 → 드라이브 레터, FileSystemWatcher** |
|
||||
| 호스트 USB / PCI 장치 패스스루 | 없음 | 없음 | 스마트카드만 | **있음 (`device list / attach / detach`, GUI Devices 페이지, 트레이 USB 스위처; USB live hot-plug, PCI boot-add)** |
|
||||
| Discovery (설치된 앱 자동 스캔) | 없음 | 없음 | 있음 | **있음 (Registry + Start Menu + UWP + choco/scoop)** |
|
||||
| 멀티세션 RDP | 없음 | 없음 | 문서화 안 됨 | **있음 (bundled rdprrap, 최대 10)** |
|
||||
| Reverse 파일 열기 (guest → host xdg-open) | 없음 | 없음 | 없음 | **있음 (Linux 앱이 Windows "Open with…" 메뉴에 노출)** |
|
||||
| Windows 디스크 자동 확장 | 없음 | 없음 | 없음 | **있음 (idle, 호스트 여유 공간으로 bounded)** |
|
||||
| 게스트 동기화 (재설치 없는 in-place 업데이트) | 없음 | 없음 | 없음 | **있음 (pod 시작 시 자동 + `guest sync`)** |
|
||||
| 다국어 UI | 영어 전용 | 영어 전용 | 영어 전용 | **있음 (7개 언어, 로케일 자동 감지)** |
|
||||
| 오프라인 / 에어갭 설치 | 없음 | 없음 | 없음 | **있음 (`--source` + `--image-tar`)** |
|
||||
| 라이선스 | MIT | AGPL-3.0 | MIT | MIT |
|
||||
|
||||
> winboat 가 스코프상 가장 가까운 peer 이고 영감을 준 프로젝트. 우리는 다른 조합에 집중 — Electron 대신 stdlib 지향 Python + Qt6, 더 깊은 자동 설정 (auto suspend, 7일 비밀번호 회전, 다중 DE HiDPI), reverse-open (Linux 앱이 Windows "Open with…" 메뉴에 기본 노출되는 유일한 프로젝트), 다국어 UI (7개 언어, 로케일 자동 감지), 채워질수록 스스로 커지는 자가 관리 Windows 디스크, 재설치 없이 실행 중인 게스트로 호스트 업데이트를 push 하는 in-place 게스트 동기화, 명시적 에어갭 설치 경로. 두 프로젝트 모두 dockur/windows 위에 빌드 — 그 생태계는 한 앱보다 크다.
|
||||
|
||||
## WinPodX vs Wine
|
||||
|
||||
**WinPodX 는 Wine 의 대체재가 아닙니다.** Wine 은 Windows API 호출을 변환; WinPodX 는 실제 Windows OS 를 컨테이너에서 실행. 둘은 다른 문제를 해결하고, 많은 사용자가 둘 다 설치합니다.
|
||||
|
||||
| 필요한 것... | 사용 |
|
||||
|---|---|
|
||||
| 구형 Win32 앱, 인디 게임, 가벼운 유틸리티 | **Wine / Bottles / Lutris** |
|
||||
| GPU 가속 게임 / 3D 앱 (DirectX 9 – 12) | **Wine** — DXVK / VKD3D 가 거의 네이티브 프레임레이트 제공. WinPodX 는 기본적으로 GPU 패스스루 없음; QEMU CPU 렌더링은 훨씬 느림. (VFIO 통한 GPU 패스스루는 수동 BYO 설정 — 패키징 안 됨.) |
|
||||
| Microsoft 365 with 완전한 Outlook + Teams + OneDrive 통합 | **WinPodX** |
|
||||
| Adobe Creative Suite (Photoshop, Illustrator, Premiere, Lightroom) | WinPodX — 단 무거운 GPU 이펙트는 CPU 바운드 (위 GPU 행 참조) |
|
||||
| 안티치트 게임 (Valorant, EAC, BattlEye) | **TBD** — 안티치트마다 VM 감지 정책 다름 (Vanguard 는 TPM 2.0 + hypervisor 없음 필요, EAC 는 대부분 VM 차단, VAC 는 관대). 시도 전 테스트 필수. |
|
||||
| DRM 무거운 소프트웨어 / 하드웨어 동글 앱 | **WinPodX** |
|
||||
| 커널 모드 드라이버 출시 앱 (일부 VPN, 보안 스위트) | **WinPodX** |
|
||||
| 지역 인증서 사용 은행 / 세무 / 행정 도구 | **WinPodX** |
|
||||
| Visual Studio, WinUI 3 / WinRT, Wine 이 따라잡지 못한 .NET 기능 | **WinPodX** |
|
||||
| IE 전용 레거시 엔터프라이즈 웹 앱 | **WinPodX** |
|
||||
| "대부분 동작" 이 허용 안 되는 모든 것 | **WinPodX** |
|
||||
|
||||
Wine 은 속도와 GPU 에서 (DXVK/VKD3D 변환이 깔끔하게 될 때) 이김. WinPodX 는 그 외 모든 곳에서 **100% Windows 기능 동등성** 으로 이김 — 모든 앱이 실제 Windows 커널 위에서 실행되고, FreeRDP RemoteApp 통해 Linux 데스크톱에 네이티브 윈도로 렌더링됨.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Comparison
|
||||
|
||||
**English** | [한국어](COMPARISON.ko.md)
|
||||
|
||||
How WinPodX compares to other tools for running Windows applications on Linux.
|
||||
|
||||
## Why WinPodX?
|
||||
|
||||
Existing tools for running Windows apps on Linux all have trade-offs:
|
||||
|
||||
| | winapps | LinOffice | winboat | WinPodX |
|
||||
|---|---|---|---|---|
|
||||
| Core tech | Any RDP-capable Windows host (cloud / physical / container) + FreeRDP | dockur + FreeRDP | dockur + FreeRDP | dockur (Podman) + FreeRDP + HTTP guest agent |
|
||||
| Setup | Manual (shell + config + RDP testing) | One-liner script | One-click GUI installer | **Zero-config** (auto on first launch) |
|
||||
| Interface | CLI only | CLI only | Electron GUI | **Qt6 GUI + CLI + tray** |
|
||||
| App scope | Any Windows app | Office only | Any Windows app | Any Windows app |
|
||||
| Language | Shell (86%) | Shell + Python | TypeScript / Vue / Go | **Python (100%)** |
|
||||
| Runtime deps | curl, dialog, git, netcat | Podman, FreeRDP | Electron, Docker/Podman, FreeRDP | **Python 3.9+, FreeRDP, Podman** |
|
||||
| Auto suspend / resume | No | No | Not documented | **Yes (idle timeout)** |
|
||||
| Password rotation | No | No | Not documented | **Yes (7-day, atomic)** |
|
||||
| HiDPI auto-detect | No | No | Not documented | **GNOME, KDE, Sway, Hyprland, Cinnamon, xrdb** |
|
||||
| Sound default | No | No | Yes (FreeRDP) | Yes (FreeRDP) |
|
||||
| Printer redirection default | No | No | Not documented | Yes (FreeRDP) |
|
||||
| USB drive auto-mapping | No | No | Smartcard passthrough | **Drive subfolders → drive letters via FileSystemWatcher** |
|
||||
| Host USB / PCI device passthrough | No | No | Smartcard only | **Yes (`device list / attach / detach`, GUI Devices page, tray USB switcher; USB live hot-plug, PCI boot-added)** |
|
||||
| Discovery (auto-scan installed apps) | No | No | Yes | **Yes (Registry + Start Menu + UWP + choco/scoop)** |
|
||||
| Multi-session RDP | No | No | Not documented | **Yes (bundled rdprrap, up to 10)** |
|
||||
| Reverse file open (guest → host xdg-open) | No | No | No | **Yes (Linux apps in Windows "Open with…" menu)** |
|
||||
| Windows disk auto-grow | No | No | No | **Yes (idle, bounded by host free space)** |
|
||||
| Guest sync (in-place update, no reinstall) | No | No | No | **Yes (auto on pod start + `guest sync`)** |
|
||||
| Multilingual UI | English only | English only | English only | **Yes (7 languages, locale auto-detect)** |
|
||||
| Offline / air-gapped install | No | No | No | **Yes (`--source` + `--image-tar`)** |
|
||||
| License | MIT | AGPL-3.0 | MIT | MIT |
|
||||
|
||||
> winboat is the closest peer in scope and was an inspiration. We focus on a different mix — stdlib-leaning Python + Qt6 instead of Electron, deeper auto-config (auto suspend, 7-day password rotation, multi-DE HiDPI), reverse-open (the only project where Linux apps appear in the Windows "Open with…" menu by default), a multilingual UI (7 languages, auto-detected from the locale), self-managing Windows disk that auto-grows as it fills, in-place guest sync that pushes host updates into a running guest without reinstalling, and an explicit air-gapped install path. Both projects build on dockur/windows; that ecosystem is bigger than any one app.
|
||||
|
||||
## WinPodX vs Wine
|
||||
|
||||
**WinPodX is not a Wine replacement.** Wine translates Windows API calls; WinPodX runs the actual Windows OS in a container. The two solve different problems and many users have both installed.
|
||||
|
||||
| When you need... | Use |
|
||||
|---|---|
|
||||
| Older Win32 apps, indie games, lightweight utilities | **Wine / Bottles / Lutris** |
|
||||
| GPU-accelerated games / 3D apps (DirectX 9 – 12) | **Wine** — DXVK / VKD3D give near-native frame rates. WinPodX has no GPU passthrough by default; QEMU CPU rendering is much slower. (GPU passthrough via VFIO is a manual bring-your-own setup — not yet packaged.) |
|
||||
| Microsoft 365 with full Outlook + Teams + OneDrive integration | **WinPodX** |
|
||||
| Adobe Creative Suite (Photoshop, Illustrator, Premiere, Lightroom) | WinPodX — but heavy GPU effects will be CPU-bound (see GPU row above) |
|
||||
| Anti-cheat games (Valorant, EAC, BattlEye) | **TBD** — anti-cheats vary by VM-detection policy (Vanguard needs TPM 2.0 + no hypervisor, EAC mostly blocks VMs, VAC is lenient). Test before committing. |
|
||||
| DRM-heavy software / hardware dongle apps | **WinPodX** |
|
||||
| Apps that ship kernel-mode drivers (some VPNs, security suites) | **WinPodX** |
|
||||
| Banking / tax / government tools with regional certificates | **WinPodX** |
|
||||
| Visual Studio, WinUI 3 / WinRT, .NET features Wine hasn't caught up to | **WinPodX** |
|
||||
| IE-only legacy enterprise web apps | **WinPodX** |
|
||||
| Anything where "mostly works" isn't acceptable | **WinPodX** |
|
||||
|
||||
Wine wins on speed and on GPU when DXVK/VKD3D translate cleanly. WinPodX wins on **100% Windows feature parity** for everything else — every app runs on a real Windows kernel, rendered into your Linux desktop as a native window via FreeRDP RemoteApp.
|
||||
@@ -0,0 +1,168 @@
|
||||
# WinPodX 기여 가이드
|
||||
|
||||
[English](../CONTRIBUTING.md) | **한국어**
|
||||
|
||||
WinPodX에 관심을 가져 주셔서 감사합니다! 이 가이드는 기여를 시작하는 데 도움을 드립니다.
|
||||
|
||||
## 사전 요구 사항
|
||||
|
||||
- Python 3.9+ (3.13 에서 개발; CI 는 3.9 / 3.10 / 3.11 / 3.12 / 3.13 매트릭스)
|
||||
- FreeRDP 3+
|
||||
|
||||
## 빌드
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kernalix7/winpodx.git
|
||||
cd winpodx
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 테스트 실행
|
||||
pytest tests/ -v
|
||||
|
||||
# 린트
|
||||
ruff check src/ tests/
|
||||
|
||||
# 포맷 검사
|
||||
ruff format --check src/ tests/
|
||||
```
|
||||
|
||||
`pytest`는 Linux CI 러너에서 돌며 Windows 게스트를 실행할 수 없습니다. 게스트를
|
||||
건드리는 변경(`config/oem/`, `scripts/windows/`, reverse-open shim, `compose`
|
||||
포트/QEMU args, 에이전트, 설치 흐름, RAIL 실행)은 머지 전 **실제 Windows 게스트
|
||||
스모크**도 필요 — 게스트측 스모크 + 릴리즈별 체크리스트는
|
||||
[docs/RELEASE_TESTING.ko.md](RELEASE_TESTING.ko.md) 참고.
|
||||
|
||||
## 워크플로우
|
||||
|
||||
1. 저장소를 **포크**합니다
|
||||
2. **기능 브랜치**를 생성합니다 (`git checkout -b feat/my-feature`)
|
||||
3. **Conventional Commits** 규칙에 따라 변경 사항을 작성합니다
|
||||
4. **Pull Request**를 제출합니다
|
||||
|
||||
## PR 체크리스트
|
||||
|
||||
PR을 제출하기 전에 다음을 확인하세요:
|
||||
|
||||
- [ ] `pytest tests/ -v` 통과
|
||||
- [ ] `ruff check src/ tests/` 오류 없음
|
||||
- [ ] `ruff format --check src/ tests/` 통과
|
||||
- [ ] 문서 업데이트 완료 (해당하는 경우)
|
||||
- [ ] 하드코딩된 자격 증명 또는 비밀 정보 없음
|
||||
|
||||
## 커밋 규칙
|
||||
|
||||
이 프로젝트는 [Conventional Commits](https://www.conventionalcommits.org/)를 따릅니다:
|
||||
|
||||
| 접두사 | 용도 |
|
||||
|--------|------|
|
||||
| `feat` | 새로운 기능 |
|
||||
| `fix` | 버그 수정 |
|
||||
| `docs` | 문서 변경 |
|
||||
| `refactor` | 코드 리팩토링 (기능 변경 없음) |
|
||||
| `test` | 테스트 추가 또는 업데이트 |
|
||||
| `chore` | 유지보수 작업 (CI, 의존성 등) |
|
||||
|
||||
### 예시
|
||||
|
||||
```
|
||||
feat: add Wayland display detection
|
||||
fix: resolve DPI scaling on multi-monitor setups
|
||||
docs: update installation instructions
|
||||
refactor: simplify backend abstraction layer
|
||||
test: add unit tests for UNC path conversion
|
||||
chore: update ruff to 0.8.x
|
||||
```
|
||||
|
||||
### AI 툴 co-author 트레일러 금지
|
||||
|
||||
`Co-authored-by:` 트레일러에 AI 툴 / 코딩 에이전트 이름을 넣지 **마세요**. 다음 모두 해당:
|
||||
|
||||
- `Co-authored-by: Cursor <cursoragent@cursor.com>`
|
||||
- `Co-authored-by: Claude <noreply@anthropic.com>` (및 다른 Anthropic 이메일)
|
||||
- `Co-authored-by: Copilot <...>` (GitHub Copilot 모든 변종)
|
||||
- `Co-authored-by: <기타 AI 툴 / 에이전트 정체성>`
|
||||
|
||||
패치를 작성한 건 당신입니다 — 정식 사람 author 는 당신. AI 툴이 얼마나 기여했든 이 repo 에서 co-author credit 받지 않습니다. 깜빡하고 트레일러가 들어갔다면 amend 요청하거나 (이미 머지된 PR 의 경우) 후속 PR 로 조정된 history-rewrite 를 제안합니다.
|
||||
|
||||
사람 co-author (예: 변경 사항을 함께 페어 프로그래밍한 동료) 는 환영 — 실제 사람 정체성 + 이메일 사용.
|
||||
|
||||
## 릴리스 노트 작성
|
||||
|
||||
`CHANGELOG.md` (그리고 `docs/CHANGELOG.ko.md`) 의 각 버전 섹션은 `### Highlights` 로 시작합니다 — 한 줄 헤드라인 + 3–6 개 스캔 가능한 bullet. 이것이 GitHub 릴리스 페이지 맨 위에 보이는 내용입니다. `release.yml` 이 해당 버전 섹션을 verbatim 으로 추출해서 릴리스 body 에 넣기 때문에, 섹션 맨 앞에 있는 게 릴리스 본문 맨 앞에 옴.
|
||||
|
||||
자세한 `### Added` / `### Changed` / `### Fixed` bullet 은 그 아래에. archeology 와 exhaustive tracking 용이지 first-read 용이 아님.
|
||||
|
||||
스켈레톤:
|
||||
|
||||
```markdown
|
||||
## [X.Y.Z] - YYYY-MM-DD
|
||||
|
||||
### Highlights
|
||||
|
||||
**한 줄 헤드라인.** 필요시 1-2 문장 추가 컨텍스트.
|
||||
|
||||
- 가장 중요한 사용자 가시적 변경 (한 줄, 스캔 가능)
|
||||
- 두 번째 중요한 변경
|
||||
- (최대 3-6 bullets; prose 블록 금지)
|
||||
|
||||
### Added
|
||||
- (자세한 bullet)
|
||||
|
||||
### Changed
|
||||
- (자세한 bullet)
|
||||
|
||||
### Fixed
|
||||
- (자세한 bullet)
|
||||
```
|
||||
|
||||
릴리스 컷할 때 `REL-vX.Y.Z` marker tag 도 같이 푸시 — 이것이 `release.yml` 을 fire 시킴 (`wheel` + `sdist` 빌드, CHANGELOG 섹션 추출, GitHub 릴리스 body 갱신). REL- marker 없으면 버전 태그 (`vX.Y.Z`) 가 4개 패키징 워크플로우 (`obs-publish.yml`, `rhel-publish.yml`, `debs-publish.yml`, `aur-publish.yml`) 만 trigger 하고 `wheel` / `sdist` 없음 + 자동 추출 릴리스 body 없음.
|
||||
|
||||
```bash
|
||||
git tag vX.Y.Z <commit>
|
||||
git tag REL-vX.Y.Z vX.Y.Z^{} # nested-tag 경고 피하려고 commit 으로 dereference
|
||||
git push origin vX.Y.Z REL-vX.Y.Z
|
||||
```
|
||||
|
||||
### Highlights 에서 기여자 표기
|
||||
|
||||
Highlights bullet 이 메인테이너 외부에서 온 작업 (외부 PR / 외부 버그
|
||||
리포트 / feature request) 을 다룰 때, 인라인으로 기여자 크레딧을 표기.
|
||||
컨벤션:
|
||||
|
||||
| 출처 | 접미사 |
|
||||
|---|---|
|
||||
| 외부 PR (다른 사람의 커밋) | `(by @username, #PR)` |
|
||||
| 외부 이슈 / feature request (코드는 메인테이너 작성) | `(reported by @username, #issue)` |
|
||||
| 둘 다 — 같은 사람의 외부 리포트 **+** 외부 PR | `(by @username, #PR / #issue)` |
|
||||
|
||||
GitHub 가 두 형식 모두 릴리스 페이지에 user 아바타 + 핸들로 자동
|
||||
렌더링 — 추가 작업 없이 크레딧이 노출됨.
|
||||
|
||||
예시:
|
||||
|
||||
```markdown
|
||||
### Highlights
|
||||
|
||||
- Atomic Fedora flavours (Silverblue / Kinoite / Bazzite) now ship via the
|
||||
OBS repo with `rpm-ostree install --apply-live`. (by @Zeik0s, #163)
|
||||
- LTSC IoT and Win10 LTSC pickable from Settings or `--win-version`.
|
||||
(reported by @gabe39, #178)
|
||||
- Dynamic Desktop Window Resolution — Full Desktop sessions now resize
|
||||
the FreeRDP client window automatically. (by @Zeik0s, #202)
|
||||
- Ubuntu 26.04 build target + Wayland-friendly Recommends split.
|
||||
(by @juampe, #206)
|
||||
```
|
||||
|
||||
위의 "AI tool co-author trailers 금지" 룰과는 무관: 그것은
|
||||
기계 생성 attribution 금지. 사람 기여자는 자유롭고 명시적으로
|
||||
크레딧.
|
||||
|
||||
## 보안
|
||||
|
||||
보안 취약점을 발견한 경우, [SECURITY.ko.md](SECURITY.ko.md)에 설명된 절차를 따라 주세요. **공개 이슈를 열지 마세요.**
|
||||
@@ -0,0 +1,244 @@
|
||||
# 기능
|
||||
|
||||
[English](FEATURES.md) | **한국어**
|
||||
|
||||
전체 기능 셋: 주변기기 & 공유, 멀티세션 RDP, 앱 프로필, reverse-open (Linux 앱이 Windows "Open with…" 메뉴에 등장).
|
||||
|
||||
## Reverse-open (Linux 앱이 Windows "Open with…" 에)
|
||||
|
||||
v0.5.0 에 ship, 이후 기본 활성. Windows 게스트 안에서 아무 파일이나 우클릭하면 Linux 측 핸들러가 "Open with…" 메뉴에 등장 — `.txt` 는 Kate, `.png` 는 gwenview, `.html` 은 Firefox 등. 하나 선택하면 파일 열기가 호스트의 `xdg-open` 으로 round-trip 해서 실제로 Linux 에서 설정한 앱에 도착.
|
||||
|
||||
동작 방식:
|
||||
|
||||
```
|
||||
Windows Explorer 우클릭 ─┐
|
||||
│ 슬러그별 winpodx-<slug>.exe
|
||||
│ (Rust shim, rcedit 으로 아이콘 embed)
|
||||
▼
|
||||
\\tsclient\home\.local\share\winpodx\reverse-open\incoming\<uuid>.json 에 atomic JSON 쓰기
|
||||
│
|
||||
▼
|
||||
host listener daemon 이 픽업, safe_open_unc 가 경로 검증
|
||||
│
|
||||
▼
|
||||
subprocess: <app.exec_argv> 와 실제 호스트 파일 경로
|
||||
```
|
||||
|
||||
기능은 **기본 활성** (`cfg.reverse_open.enabled = true`). 사용자가 Linux 기본 핸들러로 설정한 각 Linux 앱 — `xdg-mime default` 또는 DE 의 "기본 앱" 설정 통해 — 이 Windows 측에 매칭되는 확장자와 함께 등록. Discovery 가 `$XDG_DATA_HOME/applications` + `$XDG_DATA_DIRS` 와 freedesktop 검색 경로의 모든 `mimeapps.list` 를 walk.
|
||||
|
||||
CLI 로 관리:
|
||||
|
||||
```bash
|
||||
winpodx host-open status # listener + manifest 상태
|
||||
winpodx host-open list # push 될 앱들
|
||||
winpodx host-open refresh # 재스캔 + 게스트로 push
|
||||
winpodx host-open add <slug> # allowlist
|
||||
winpodx host-open remove <slug> # 제거 (또는 --deny)
|
||||
winpodx host-open disable # 기능 전체 끄기
|
||||
```
|
||||
|
||||
또는 GUI Settings 페이지 → reverse-open 패널 (같은 컨트롤).
|
||||
|
||||
슬러그별 아이콘이 짧은 Open With 메뉴 + 긴 "다른 앱 선택" 다이얼로그 양쪽에 렌더링되는 이유: 각 `winpodx-<slug>.exe` 가 Rust shim 의 독립 사본이고 매칭되는 `.ico` 가 PE resource section 에 embed 되어있기 때문 (vendored `rcedit.exe`, electron/rcedit v2.0.0, MIT). Chooser 아이콘 트레이드오프: 슬러그별 `.exe` 사본이 디스크에 ~500 KB × N 앱 차지 (hard-link inode 공유 없음) — Win10/Win11 에서 reliably 동작하는 유일한 chooser 아이콘 경로라서.
|
||||
|
||||
## 매끄러운 앱 윈도
|
||||
|
||||
- RemoteApp (RAIL) 이 각 앱을 네이티브 Linux 윈도로 렌더링 — 전체 데스크톱 아님
|
||||
- `WM_CLASS` 매칭 통한 앱별 taskbar 아이콘 (`/wm-class:<stem>` + `StartupWMClass`)
|
||||
- 파일 연결: Linux 파일 관리자에서 `.docx` 더블클릭 → Word 가 열림
|
||||
- 멀티세션 RDP: bundled rdprrap 이 최대 10개 독립 세션 자동 활성화
|
||||
- 실행 중인 세션을 GUI Dashboard (Running-sessions 스트립) 또는 시스템 트레이 메뉴에서 종료
|
||||
- RAIL 전제조건 (`fDisabledAllowList=1` + `fInheritInitialProgram=1` + `MaxInstanceCount=10`) 이 unattended 설치 중 자동 설정
|
||||
- 멀티모니터 RAIL 기본 활성 (`cfg.rdp.multimon = "span"`): 리모트 앱 윈도를 두 번째 모니터로 드래그해도 입력이 계속 동작
|
||||
- UWP/Store 앱도 다른 앱처럼 Linux taskbar 에 등장
|
||||
|
||||
## 제로 설정 실행
|
||||
|
||||
- 첫 앱 클릭이 모든 것 자동 프로비저닝: config, 컨테이너, desktop 엔트리
|
||||
- 첫 부팅 시 자동 discovery 가 실행 중인 Windows 게스트 스캔, 설치된 모든 앱 (Registry App Paths, Start Menu, UWP/MSIX, Chocolatey, Scoop) 을 실제 바이너리 아이콘과 함께 등록
|
||||
- `winpodx app refresh` 또는 GUI Refresh 버튼으로 언제든 수동 재스캔
|
||||
- 고급 설정용 대화형 setup 마법사
|
||||
- 로그인 시 pod 자동 시작 옵션 (opt-in, 기본 꺼짐): `winpodx autostart on|off|status` 또는 GUI 체크박스가 XDG autostart `.desktop` 엔트리(`~/.config/autostart/winpodx-tray.desktop`)를 설치 — 로그인 시 트레이가 떠 첫 앱 클릭 전에 Windows pod 이 미리 준비됨
|
||||
|
||||
## 다국어 UI
|
||||
|
||||
트레이, GUI, CLI 가 7개 언어로 완전 번역: 영어, 한국어, 중국어 (中文), 일본어 (日本語), 독일어 (Deutsch), 프랑스어 (Français), 이탈리아어 (Italiano).
|
||||
|
||||
- 첫 실행 시 시스템 로케일에서 자동 감지; 미지원 로케일은 영어로 fallback
|
||||
- `winpodx language <code>` (예: `winpodx language ja`) 또는 GUI 언어 드롭다운으로 언제든 전환
|
||||
- config `[ui] language` 에 저장
|
||||
|
||||
## Start-menu GUI & 대시보드
|
||||
|
||||
데스크톱 GUI 는 Start-menu 스타일 레이아웃으로 구성: 왼쪽 세로 내비게이션 사이드바 (페이지당 한 행) + 처음 진입하는 **Dashboard** 홈.
|
||||
|
||||
- **Dashboard** 는 실시간 Pod / RAM / CPU 링 게이지와 디스크 사용량, 자동 복구 상태 카드, 고정/최근 워크스페이스 타일, reverse-open 토글을 보여줌.
|
||||
- 앱 런처는 이제 **All apps** 페이지.
|
||||
- **Devices** 페이지는 USB / PCI 패스스루용 두 컬럼 host ↔ guest 디바이스 mover 를 제공.
|
||||
- 통합 디자인 시스템 + 자체 SVG 아이콘 셋 (더 이상 유니코드 글리프 아이콘 아님), 좁거나 분수 배율 윈도에서 reflow 하는 반응형 레이아웃, fit-to-screen 사이징.
|
||||
- 상단의 hero 검색이 커맨드 바 역할도 겸함.
|
||||
|
||||
## 주변기기 & 공유
|
||||
|
||||
| 기능 | 동작 방식 | 기본값 |
|
||||
|---------|-------------|---------|
|
||||
| **클립보드** | RDP 통한 양방향 복사-붙여넣기 (`+clipboard`) | 활성 |
|
||||
| **사운드** | ALSA 통한 오디오 스트리밍 (`/sound:sys:alsa`) | 활성 |
|
||||
| **프린터** | Linux 프린터가 Windows 에 공유 (`/printer`) | 활성 |
|
||||
| **홈 디렉토리** | `\\tsclient\home` 으로 공유 (`+home-drive`) | 활성 |
|
||||
| **USB 드라이브** | media 폴더가 `\\tsclient\media` 로 공유 (`/drive:media`); 세션 시작 후 꽂은 USB 도 서브폴더로 접근 가능. 마운트된 미디어가 없어도 게스트 측 USB 바로가기가 항상 정상 동작 | 활성 |
|
||||
| **USB 디바이스 패스스루** | 네이티브 USB 리디렉션 (`/usb:auto`) — FreeRDP urbdrc 플러그인 필요 | **Opt-in** (`extra_flags` 에 추가) |
|
||||
| **호스트 USB / PCI 패스스루** | 호스트 USB 또는 PCI 디바이스를 Windows 게스트로 직접 매핑 (`winpodx device list / attach <id> / detach <id>`, GUI Devices 탭, 트레이 USB 스위처). USB 는 라이브 핫플러그; PCI 는 부팅 시 추가되어 게스트 재시작 + 안전 확인 필요 | USB 라이브 (`cfg.pod.usb_live`, 기본 켜짐) |
|
||||
| **USB 드라이브 매핑** | Windows 측 스크립트가 FileSystemWatcher 로 USB 서브폴더를 드라이브 레터 (E:, F:, ...) 로 자동 매핑 | 활성 |
|
||||
| **Reverse 파일 열기** | Linux 앱이 Windows 게스트 우클릭 "Open with…" 메뉴에 등장; 선택 시 호스트 `xdg-open` 으로 round-trip | 활성 |
|
||||
|
||||
### USB 드라이브 흐름
|
||||
|
||||
```
|
||||
Linux 에서 USB 꽂음
|
||||
│
|
||||
▼
|
||||
Linux 가 /run/media/$USER/USBNAME 으로 마운트
|
||||
│
|
||||
▼
|
||||
FreeRDP 가 \\tsclient\media\USBNAME 으로 공유
|
||||
│
|
||||
▼
|
||||
media_monitor.ps1 감지 → net use E: \\tsclient\media\USBNAME
|
||||
│
|
||||
▼
|
||||
Windows Explorer 에 E: 드라이브 표시
|
||||
```
|
||||
|
||||
### 호스트 USB / PCI 디바이스 패스스루
|
||||
|
||||
공유 폴더가 아니라 실제 호스트 주변기기를 Windows 게스트 안으로 통째로 매핑:
|
||||
|
||||
```bash
|
||||
winpodx device list # 호스트 디바이스 + 현재 게스트 연결 상태
|
||||
winpodx device attach <id> # USB 또는 PCI 디바이스를 게스트에 연결
|
||||
winpodx device detach <id> # 다시 분리
|
||||
```
|
||||
|
||||
- **USB** 는 라이브 핫플러그 (`cfg.pod.usb_live`, 기본 켜짐) — 게스트 재시작 없이 연결/분리.
|
||||
- **PCI** 는 부팅 시 추가: 적용하려면 게스트 재시작이 필요하고 안전 확인을 요청 (CLI 는 `--force`, GUI 는 다이얼로그).
|
||||
- **GUI Devices 탭** 은 두 컬럼 host ↔ guest mover 를 제공하고, **시스템 트레이 USB 스위처** 로 전체 윈도를 열지 않고도 USB 디바이스를 넣고 뺄 수 있음.
|
||||
|
||||
**GPU 가속:** 아직 미지원. dockur/windows 가 QEMU/KVM 위에서 소프트웨어 그래픽으로 실행 — DirectX 무거운 게임과 3D 앱은 CPU 바운드. VFIO 통한 GPU 패스스루는 가능하지만 패키징 안 됨. ([COMPARISON.md](COMPARISON.ko.md) → WinPodX vs Wine 참조 — GPU 필요하면 Wine + DXVK 가 맞는 도구.)
|
||||
|
||||
## 자동화 & 보안
|
||||
|
||||
- 자동 suspend / resume: idle 시 컨테이너 pause, 다음 실행 시 resume
|
||||
- 비밀번호 자동 회전: 20자 암호학적 비밀번호, 7일 주기 + rollback
|
||||
- 스마트 DPI 스케일링: GNOME, KDE, Sway, Hyprland, Cinnamon, xrdb 에서 자동 감지
|
||||
- 멀티 백엔드: Podman (기본), Docker, manual RDP
|
||||
- Windows 빌드를 11 25H2 에 pin (`TargetReleaseVersionInfo=25H2`, 365일 feature-update 지연)
|
||||
- Windows debloat: 텔레메트리, 광고, Cortana, 검색 인덱싱, 서비스 (DiagTrack / dmwappushservice / WSearch / SysMain) 비활성화
|
||||
- 고성능 전원 플랜 + hibernation off + tzutil UTC + Cloudflare DNS
|
||||
- 시간 동기화: 호스트 sleep/wake 후 Windows 시계 강제 resync
|
||||
- FreeRDP `extra_flags` allowlist (regex 검증) 가 사용자 input 안전 경계
|
||||
|
||||
## 베어메탈 위장 (VM 탐지 회피)
|
||||
|
||||
v0.7.0 에 탑재. Opt-in, 기본 꺼짐. 하이퍼바이저 감지 시 실행을 거부하는 소프트웨어 — Nvidia GPU 패스스루 "code 43", launch-gate VM 체크, VM 비적합 설치 프로그램 — 가 QEMU/KVM 대신 실물 베어메탈 머신으로 인식합니다. 레벨 선택:
|
||||
|
||||
```bash
|
||||
winpodx config set pod.disguise_level off # 기본 — 위장 없음
|
||||
winpodx config set pod.disguise_level balanced # VM 별 args 만, 재빌드 없음
|
||||
winpodx config set pod.disguise_level max # 패치된 QEMU 이미지 (Hardened)
|
||||
```
|
||||
|
||||
또는 GUI Settings 페이지 → "Bare-metal level" 셀렉터 사용 (Hardened 선택 시 이미지 빌드가 자동으로 시작).
|
||||
|
||||
### 레벨
|
||||
|
||||
**`balanced`** (VM 별, 재빌드 불필요):
|
||||
- CPUID 하이퍼바이저 present 비트와 KVM 시그니처 제거 (`-cpu -hypervisor,kvm=off,-kvm-pv-*`)
|
||||
- 호스트의 실제 SMBIOS/DMI (시스템 / 보드 / BIOS 벤더 + 제품, CPU 벤더 + 모델) 를 게스트에 미러링
|
||||
- 합성 SMBIOS 센서/디스크립터 blob (전압 / 온도 프로브, 냉각 장치, 캐시, 메모리 어레이 + DIMM, 40+ 구조체) 주입하여 `Win32_*` / `CIM_*` WMI 센서 클래스가 실제 하드웨어처럼 보고하도록
|
||||
|
||||
**`max` ("Hardened")** (`balanced` 의 모든 것 + 추가):
|
||||
- `winpodx disguise build-image` 가 QEMU 를 **로컬에서** 컴파일 (~20–40 분), 커맨드 라인으로 override 불가한 VM 식별 문자열을 호스트 실제 값으로 재작성: ACPI OEM ID (`BOCHS`/`BXPC` → 호스트), FADT 하이퍼바이저 벤더 + PM-profile 바이트, 디바이스 `_HID`, `WAET` 테이블 시그니처, 디스크 / 광학 모델 **및 INQUIRY 벤더**; thermal-zone SSDT + WSMT 테이블도 주입
|
||||
- SATA 시스템 디스크, e1000 NIC, std VGA, `nec-usb-xhci` USB-3 컨트롤러 (USB3 유지하면서 Red Hat `VEN_1B36` 특징 제거); virtio-rng 디바이스 (`VEN_1AF4`) 제거 + 게스트 미사용 virtio 드라이버 서비스 키 정리
|
||||
|
||||
바이너리는 절대 shipped 되지 않습니다 — 이미지는 표준 QEMU 소스로 본인 머신에서 빌드됩니다.
|
||||
|
||||
### 패치된 이미지 빌드
|
||||
|
||||
```bash
|
||||
winpodx disguise build-image # 첫 빌드 ~20–40 분; 이후 캐시됨
|
||||
```
|
||||
|
||||
또는 GUI Settings → Bare-metal level 에서 "Hardened" 선택 시 빌드가 자동으로 시작.
|
||||
|
||||
### 개인정보
|
||||
|
||||
호스트에서 파생된 문자열은 *비식별* (벤더 / 모델 코드 등 디스크 모델) 이며 Docker build-arg 를 거쳐 **로컬 이미지 레이어에만 — git 에 절대 커밋되지 않고 push 도 없음**. 시리얼 / UUID / 자산 태그는 읽지 않습니다. 소스는 제네릭 폴백 (`ALASKA` / `Samsung SSD` / `ATA`) 을 ship.
|
||||
|
||||
### 검증
|
||||
|
||||
실제 Windows 에서 **al-khaser 0.82** 검증 완료: 디스크, 센서, SMBIOS, ACPI, CPUID, virtio 서비스, 사용자명 탐지 계열 clean. 남은 특징들은 컨테이너-QEMU 구조적 바닥 (RDTSC 타이밍, 실제 하드웨어에서도 비어있는 레거시 `Win32_MemoryDevice` 클래스, RDP 디스플레이 드라이버를 공유하는 Windows 내재 Hyper-V 통합 객체).
|
||||
|
||||
## Windows 디스크 자동 확장
|
||||
|
||||
Windows `C:` 드라이브가 채워질수록 스스로 커짐 — 거대한 가상 디스크를 미리 잡아둘 필요도, 설치 도중 공간이 떨어질 일도 없음.
|
||||
|
||||
- **자동 확장** 은 pod 이 idle 일 때만 동작, `C:` 가 거의 가득 차면 확장, 호스트 여유 공간으로 bounded 되어 기반 스토리지를 절대 overcommit 하지 않음. 디스크 끝에 있는 dockur 의 WinRE 복구 파티션도 올바르게 처리.
|
||||
- **수동 제어**: `winpodx install grow-disk [SIZE|--extend-only]` 로 공간 추가 (또는 기존 여유 공간으로 파티션만 확장), `winpodx install disk-usage` 로 현재 할당 확인.
|
||||
- Config 키: `disk_autogrow*` (활성 / 임계값 / 단계) 와 `disk_max_size` (상한).
|
||||
|
||||
## 게스트 동기화
|
||||
|
||||
실행 중인 Windows 게스트에 재설치 없이 호스트 측 업데이트를 push. WinPodX 가 더 새로운 guest agent, urlacl 예약, rdprrap 빌드, post-install fix 를 ship 하면 게스트가 그 자리에서 받아감.
|
||||
|
||||
- **자동**: `guest_autosync` 활성 시 pod 시작마다 — 게스트가 올라올 때마다 현재 호스트 버전으로 reconcile.
|
||||
- **수동**: `winpodx guest sync [--force]` 로 on-demand reconcile (`--force` 는 버전이 이미 일치해도 재-push).
|
||||
|
||||
## 앱 프로필
|
||||
|
||||
앱 프로필은 **메타데이터만**: Windows 앱이 어디 있는지를 기술해서 WinPodX 가 FreeRDP RemoteApp 통해 실행 가능. 실제 Windows 애플리케이션은 Windows 컨테이너 안에 설치되어 있어야 함.
|
||||
|
||||
### 자동 discovery (기본)
|
||||
|
||||
v0.1.9 부터 WinPodX 는 **큐레이트된 프로필 리스트 없음**. Windows pod 첫 부팅 시 provisioner 가 `winpodx app refresh` 실행, 실행 중인 게스트를 스캔:
|
||||
|
||||
- Registry `App Paths` (`HKLM` + `HKCU`)
|
||||
- Start Menu `.lnk` 재귀 (depth-cap)
|
||||
- UWP / MSIX 패키지 — `Get-AppxPackage` + `AppxManifest.xml`
|
||||
- Chocolatey + Scoop shim
|
||||
|
||||
각 결과에 대해 바이너리에서 아이콘 직접 추출 (UWP 는 패키지의 logo 자산) 하고 `~/.local/share/winpodx/discovered/<slug>/` 에 엔트리 작성. 언제든 재실행:
|
||||
|
||||
```bash
|
||||
winpodx app refresh # CLI
|
||||
# 또는 GUI All apps 페이지의 "Refresh Apps" 클릭
|
||||
```
|
||||
|
||||
### 사용자 정의 앱 프로필 수동 추가
|
||||
|
||||
사용자 작성 프로필은 `~/.local/share/winpodx/apps/` 에 위치, 같은 `name` 의 discovery 결과를 override:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/share/winpodx/apps/myapp
|
||||
cat > ~/.local/share/winpodx/apps/myapp/app.toml << 'EOF'
|
||||
name = "myapp"
|
||||
full_name = "My Application"
|
||||
executable = "C:\\Program Files\\MyApp\\myapp.exe"
|
||||
categories = ["Utility"]
|
||||
mime_types = []
|
||||
EOF
|
||||
|
||||
winpodx app install myapp # desktop 메뉴에 등록
|
||||
```
|
||||
|
||||
## 멀티세션 RDP
|
||||
|
||||
기본 Windows Desktop 에디션은 RDP 를 사용자당 1 세션으로 제한 — 두 번째 앱이 재연결하면서 첫 세션을 빼앗아감. WinPodX 는 [rdprrap](https://github.com/kernalix7/rdprrap) — RDPWrap 의 Rust 재구현 — 을 패키지 내부에 bundle 하고 Windows unattended 설치 중 자동 설치, 그래서 각 RemoteApp 윈도가 독립 세션을 받음.
|
||||
|
||||
**RAIL 전제조건.** RemoteApp 자체가 unattended setup 중 WinPodX 가 적용하는 세 개의 레지스트리 설정 필요: `fDisabledAllowList=1` (RemoteApp publishing 활성), `fInheritInitialProgram=1` (`/app:program:...` 가 셸이 아닌 타겟 실행파일을 실행하도록), `MaxInstanceCount=10` + `fSingleSessionPerUser=0` (단일 세션 제한 해제, 최대 10개 동시 RemoteApp 윈도). 이 키들은 rdprrap 설치 성공 여부와 관계없이 설정 — rdprrap 가 세션을 *독립적으로* 만들어주지만, 레지스트리 키들이 RemoteApp 을 일단 동작하게 만드는 것. rdprrap 설치 후 `TermService` 가 cycle 되어 wrapper DLL 이 재부팅 없이 활성화.
|
||||
|
||||
**인증 채널.** NLA 비활성 (`UserAuthentication=0`) 으로 FreeRDP 명령줄이 `podman unshare --rootless-netns` 아래에서 unattended 인증 가능, 하지만 `SecurityLayer=2` 가 RDP 채널 자체는 TLS 로 암호화 유지 (그래서 `127.0.0.1` 에 대한 `/sec:tls /cert:ignore` 가 완전 인증 + 암호화 경로 — NLA 가 꺼져있어도 wire 에 평문 없음).
|
||||
|
||||
**완전 오프라인 동작.** rdprrap zip 이 WinPodX 의 data 디렉토리 (`config/oem/`) 안에 ship 되고 게스트 첫 부팅 중 `C:\OEM\` 에 stage. 추출 전 pin 파일에 대해 sha256 검증. 설치 시점에 네트워크 접근 불필요.
|
||||
|
||||
설치는 일회성: dockur 의 unattended setup 단계 중 패치 적용. 그 단계의 무엇이라도 실패하면 (해시 불일치, 추출, installer 에러), WinPodX 가 경고 로그 + 게스트는 단일 세션 모드 유지 — 앱 실행이 이 단계에서 막히지 않음. guest 측 management 채널 (설치 후 활성/비활성/상태) 은 차후 릴리스 예정.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Features
|
||||
|
||||
**English** | [한국어](FEATURES.ko.md)
|
||||
|
||||
The full feature set: peripherals & sharing, multi-session RDP, app profiles, and reverse-open (Linux apps in the Windows "Open with…" menu).
|
||||
|
||||
## Reverse-open (Linux apps in Windows "Open with…")
|
||||
|
||||
Shipped in v0.5.0, default-on since. Right-click any file inside the Windows guest and your Linux-side handler appears in the "Open with…" menu — Kate for `.txt`, gwenview for `.png`, Firefox for `.html`, and so on. Picking one round-trips the file open back to the host's `xdg-open` so it lands in the Linux app you actually configured.
|
||||
|
||||
How it works:
|
||||
|
||||
```
|
||||
Windows Explorer right-click ─┐
|
||||
│ per-slug winpodx-<slug>.exe
|
||||
│ (Rust shim, icon embedded via rcedit)
|
||||
▼
|
||||
atomic JSON write to \\tsclient\home\.local\share\winpodx\reverse-open\incoming\<uuid>.json
|
||||
│
|
||||
▼
|
||||
host listener daemon picks it up, safe_open_unc validates the path
|
||||
│
|
||||
▼
|
||||
subprocess: <app.exec_argv> with the real host file path
|
||||
```
|
||||
|
||||
The feature is **on by default** (`cfg.reverse_open.enabled = true`). Each Linux app the user has set as a Linux default handler — via `xdg-mime default` or their DE's "Default Applications" settings — is registered on the Windows side with the matching extensions. Discovery walks `$XDG_DATA_HOME/applications` + `$XDG_DATA_DIRS` plus every `mimeapps.list` in the freedesktop search path.
|
||||
|
||||
Manage via the CLI:
|
||||
|
||||
```bash
|
||||
winpodx host-open status # listener + manifest state
|
||||
winpodx host-open list # apps that would be pushed
|
||||
winpodx host-open refresh # rescan + push to guest
|
||||
winpodx host-open add <slug> # allowlist
|
||||
winpodx host-open remove <slug> # remove (or --deny)
|
||||
winpodx host-open disable # turn the whole feature off
|
||||
```
|
||||
|
||||
Or via the GUI Settings page → reverse-open panel (same controls).
|
||||
|
||||
Per-slug icons render in both the short Open With menu and the long "Choose another app" dialog because each `winpodx-<slug>.exe` is an independent copy of the Rust shim with the matching `.ico` embedded into its PE resource section (via vendored `rcedit.exe`, electron/rcedit v2.0.0, MIT). The chooser icon trade-off: the per-slug `.exe` copies cost ~500 KB × N apps on disk (no hard-link inode sharing) because that's the only chooser-icon path Win10/Win11 reliably honour.
|
||||
|
||||
## Seamless app windows
|
||||
|
||||
- RemoteApp (RAIL) renders each app as a native Linux window — no full desktop
|
||||
- Per-app taskbar icons via `WM_CLASS` matching (`/wm-class:<stem>` + `StartupWMClass`)
|
||||
- File associations: double-click `.docx` in your Linux file manager → Word opens
|
||||
- Multi-session RDP: bundled rdprrap auto-enables up to 10 independent sessions
|
||||
- Terminate any running session from the GUI Dashboard (Running-sessions strip) or the system-tray menu
|
||||
- RAIL prerequisites (`fDisabledAllowList=1` + `fInheritInitialProgram=1` + `MaxInstanceCount=10`) set automatically during unattended install
|
||||
- Multi-monitor RAIL on by default (`cfg.rdp.multimon = "span"`): a remote-app window keeps working input when dragged onto a second monitor
|
||||
- UWP/Store apps now appear in the Linux taskbar like any other app
|
||||
|
||||
## Zero-config launch
|
||||
|
||||
- First app click auto-provisions everything: config, container, desktop entries
|
||||
- Auto-discovery on first boot scans the running Windows guest and registers every installed app (Registry App Paths, Start Menu, UWP/MSIX, Chocolatey, Scoop) with the real binary's icon
|
||||
- Manual rescan any time via `winpodx app refresh` or the GUI Refresh button
|
||||
- Interactive setup wizard for advanced configuration
|
||||
- Optional pod auto-start on login (opt-in, off by default): `winpodx autostart on|off|status` or the GUI checkbox installs an XDG autostart `.desktop` entry (`~/.config/autostart/winpodx-tray.desktop`) so the tray launches on login and warms the Windows pod before your first app click
|
||||
|
||||
## Multilingual UI
|
||||
|
||||
The tray, GUI, and CLI are fully translated into 7 languages: English, Korean (한국어), Chinese (中文), Japanese (日本語), German (Deutsch), French (Français), and Italian (Italiano).
|
||||
|
||||
- Auto-detected from the system locale on first run; falls back to English when the locale isn't covered
|
||||
- Switch any time with `winpodx language <code>` (e.g. `winpodx language ja`) or the GUI language dropdown
|
||||
- Persisted in config under `[ui] language`
|
||||
|
||||
## Start-menu GUI & Dashboard
|
||||
|
||||
The desktop GUI is built around a Start-menu-style layout: a left vertical navigation sidebar (one row per page) with a **Dashboard** home you land on first.
|
||||
|
||||
- **Dashboard** shows live Pod / RAM / CPU ring gauges plus disk usage, an auto-recovery status card, pinned and recent workspace tiles, and a reverse-open toggle.
|
||||
- The app launcher is now the **All apps** page.
|
||||
- A **Devices** page provides the two-column host ↔ guest device mover for USB / PCI passthrough.
|
||||
- A unified design system with an in-house SVG icon set (no more unicode-glyph icons), responsive layouts that reflow on narrow or fractionally-scaled windows, and fit-to-screen sizing.
|
||||
- A hero search at the top doubles as a command bar.
|
||||
|
||||
## Peripherals & Sharing
|
||||
|
||||
| Feature | How it works | Default |
|
||||
|---------|-------------|---------|
|
||||
| **Clipboard** | Bidirectional copy-paste via RDP (`+clipboard`) | Enabled |
|
||||
| **Sound** | Audio streaming via ALSA (`/sound:sys:alsa`) | Enabled |
|
||||
| **Printer** | Linux printers shared to Windows (`/printer`) | Enabled |
|
||||
| **Home directory** | Shared as `\\tsclient\home` (`+home-drive`) | Enabled |
|
||||
| **USB drives** | Media folder shared as `\\tsclient\media` (`/drive:media`); USB drives plugged in after session start are accessible as subfolders. The guest-side USB shortcut always resolves even when no media is mounted | Enabled |
|
||||
| **USB device passthrough** | Native USB redirection (`/usb:auto`) — requires FreeRDP urbdrc plugin | **Opt-in** (add to `extra_flags`) |
|
||||
| **Host USB / PCI passthrough** | Map a host USB or PCI device straight into the Windows guest (`winpodx device list / attach <id> / detach <id>`, GUI Devices tab, tray USB switcher). USB hot-plugs live; PCI is boot-added and needs a guest restart + safety confirmation | USB live (`cfg.pod.usb_live`, default on) |
|
||||
| **USB drive mapping** | Windows-side script auto-maps USB subfolders to drive letters (E:, F:, ...) via FileSystemWatcher | Enabled |
|
||||
| **Reverse file open** | Linux apps appear in the Windows guest's right-click "Open with…" menu; selecting one round-trips the file open to host `xdg-open` | Enabled |
|
||||
|
||||
### USB Drive Flow
|
||||
|
||||
```
|
||||
Plug in USB on Linux
|
||||
│
|
||||
▼
|
||||
Linux mounts to /run/media/$USER/USBNAME
|
||||
│
|
||||
▼
|
||||
FreeRDP shares as \\tsclient\media\USBNAME
|
||||
│
|
||||
▼
|
||||
media_monitor.ps1 detects → net use E: \\tsclient\media\USBNAME
|
||||
│
|
||||
▼
|
||||
Windows Explorer shows E: drive
|
||||
```
|
||||
|
||||
### Host USB / PCI device passthrough
|
||||
|
||||
Map a real host peripheral all the way into the Windows guest, not just a shared folder:
|
||||
|
||||
```bash
|
||||
winpodx device list # host devices + current guest attachment state
|
||||
winpodx device attach <id> # attach a USB or PCI device to the guest
|
||||
winpodx device detach <id> # detach it again
|
||||
```
|
||||
|
||||
- **USB** hot-plugs live (`cfg.pod.usb_live`, default on) — attach/detach without restarting the guest.
|
||||
- **PCI** is boot-added: it needs a guest restart to take effect and asks for a safety confirmation (`--force` on the CLI, or the dialog in the GUI).
|
||||
- A **GUI Devices tab** gives you a two-column host ↔ guest mover, and the **system-tray USB switcher** lets you flip a USB device in or out without opening the full window.
|
||||
|
||||
**GPU acceleration:** not yet supported. dockur/windows runs under QEMU/KVM with software graphics — DirectX-heavy games and 3D apps will be CPU-bound. GPU passthrough via VFIO is feasible but not packaged. (See [COMPARISON.md](COMPARISON.md) → WinPodX vs Wine — Wine + DXVK is the right tool when you need GPU.)
|
||||
|
||||
## Automation & Security
|
||||
|
||||
- Auto suspend / resume: container pauses when idle, resumes on next launch
|
||||
- Password auto-rotation: 20-char cryptographic password, 7-day cycle with rollback
|
||||
- Smart DPI scaling: auto-detects from GNOME, KDE, Sway, Hyprland, Cinnamon, xrdb
|
||||
- Multi-backend: Podman (default), Docker, manual RDP
|
||||
- Windows build pinned to 11 25H2 (`TargetReleaseVersionInfo=25H2`, 365-day feature-update defer)
|
||||
- Windows debloat: disable telemetry, ads, Cortana, search indexing, services (DiagTrack / dmwappushservice / WSearch / SysMain)
|
||||
- High-performance power plan + hibernation off + tzutil UTC + Cloudflare DNS
|
||||
- Time sync: force Windows clock resync after host sleep/wake
|
||||
- FreeRDP `extra_flags` allowlist (regex-validated) as the user-input safety boundary
|
||||
|
||||
## Bare-metal disguise (VM-detection avoidance)
|
||||
|
||||
Shipped in v0.7.0. Opt-in, off by default. Software that refuses to run when a hypervisor is detected — Nvidia GPU-passthrough "code 43", launch-gate VM checks, VM-hostile installers — sees a genuine-looking bare-metal box instead of QEMU/KVM. Select the level with:
|
||||
|
||||
```bash
|
||||
winpodx config set pod.disguise_level off # default — no disguise
|
||||
winpodx config set pod.disguise_level balanced # per-VM args only, no rebuild
|
||||
winpodx config set pod.disguise_level max # patched-QEMU image (Hardened)
|
||||
```
|
||||
|
||||
Or use the GUI Settings page → "Bare-metal level" selector (choosing Hardened triggers the image build automatically).
|
||||
|
||||
### Levels
|
||||
|
||||
**`balanced`** (per-VM, no rebuild):
|
||||
- Clears the CPUID hypervisor-present bit and the KVM signature (`-cpu -hypervisor,kvm=off,-kvm-pv-*`)
|
||||
- Mirrors the host's real SMBIOS/DMI (system / board / BIOS vendor + product, CPU vendor + model) into the guest
|
||||
- Injects a synthetic SMBIOS sensor/descriptor blob (voltage / temperature probes, cooling device, cache, memory array + DIMMs, 40+ structures) so `Win32_*` / `CIM_*` WMI sensor classes report like real hardware
|
||||
|
||||
**`max` ("Hardened")** (includes everything in `balanced`, plus):
|
||||
- `winpodx disguise build-image` compiles QEMU **locally** (~20–40 min) with the VM-identifying strings that can't be overridden via command line rewritten to the host's real values: ACPI OEM IDs (`BOCHS`/`BXPC` → host), FADT hypervisor-vendor + PM-profile byte, device `_HID`s, `WAET` table signature, disk / optical model **and INQUIRY vendor**; also injects a thermal-zone SSDT and a WSMT table
|
||||
- Presents a SATA system disk, e1000 NIC, std VGA, and `nec-usb-xhci` USB-3 controller (keeps USB3 while dropping the Red Hat `VEN_1B36` tell); removes the virtio-rng device (`VEN_1AF4`) and prunes unused virtio driver service keys in the guest
|
||||
|
||||
No binary is ever shipped — the image is built on your machine from the standard QEMU source.
|
||||
|
||||
### Building the patched image
|
||||
|
||||
```bash
|
||||
winpodx disguise build-image # ~20–40 min on first build; cached after that
|
||||
```
|
||||
|
||||
Or select "Hardened" in GUI Settings → Bare-metal level, which starts the build automatically.
|
||||
|
||||
### Privacy
|
||||
|
||||
The host-derived strings are *non-identifying* (vendor / model codes, like the disk model) and flow via Docker build-args into the **local image layer only — never committed to git, never pushed**. Serial / UUID / asset-tag are never read. The source ships generic fallbacks (`ALASKA` / `Samsung SSD` / `ATA`) so a build without host data is still valid.
|
||||
|
||||
### Verification
|
||||
|
||||
Verified against **al-khaser 0.82** on real Windows: the disk, sensor, SMBIOS, ACPI, CPUID, virtio-service, and username detection families read clean. Remaining tells are the container-QEMU structural floor (RDTSC timing, the legacy `Win32_MemoryDevice` class that is empty on real hardware too, and the Windows-inherent Hyper-V integration objects that share the RDP display driver).
|
||||
|
||||
## Windows disk auto-grow
|
||||
|
||||
The Windows `C:` drive grows itself as it fills, so you don't have to pre-provision a huge virtual disk or run out of space mid-install.
|
||||
|
||||
- **Auto-grow** runs only while the pod is idle, expands `C:` when it's nearly full, and is bounded by available host free space so it never overcommits the underlying storage. It correctly handles dockur's WinRE recovery partition that sits at the end of the disk.
|
||||
- **Manual control**: `winpodx install grow-disk [SIZE|--extend-only]` to add space (or just extend the partition into existing free space), and `winpodx install disk-usage` to inspect current allocation.
|
||||
- Config keys: `disk_autogrow*` (enable / thresholds / step) and `disk_max_size` (hard ceiling).
|
||||
|
||||
## Guest sync
|
||||
|
||||
Push host-side updates into a running Windows guest without reinstalling it. When WinPodX ships a newer guest agent, urlacl reservation, rdprrap build, or post-install fix, the guest picks it up in place.
|
||||
|
||||
- **Automatic** on pod start when `guest_autosync` is enabled — the guest is reconciled to the current host version every time it comes up.
|
||||
- **Manual**: `winpodx guest sync [--force]` to reconcile on demand (`--force` re-pushes even when versions already match).
|
||||
|
||||
## App Profiles
|
||||
|
||||
App profiles are **metadata only**: they describe where a Windows app lives so WinPodX can launch it through FreeRDP RemoteApp. The actual Windows application must be installed inside the Windows container.
|
||||
|
||||
### Auto-discovery (default)
|
||||
|
||||
Starting from v0.1.9 WinPodX ships **no curated profile list**. The first time the Windows pod boots, the provisioner runs `winpodx app refresh` and that scans the running guest:
|
||||
|
||||
- Registry `App Paths` (`HKLM` + `HKCU`)
|
||||
- Start Menu `.lnk` recursion (depth-capped)
|
||||
- UWP / MSIX packages via `Get-AppxPackage` + `AppxManifest.xml`
|
||||
- Chocolatey + Scoop shims
|
||||
|
||||
For each result it extracts the icon directly from the binary (or the package's logo asset for UWP) and writes the entry to `~/.local/share/winpodx/discovered/<slug>/`. Re-run any time:
|
||||
|
||||
```bash
|
||||
winpodx app refresh # CLI
|
||||
# or click "Refresh Apps" on the GUI All apps page
|
||||
```
|
||||
|
||||
### Adding a custom app profile manually
|
||||
|
||||
User-authored profiles live under `~/.local/share/winpodx/apps/` and override anything discovery finds with the same `name`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/share/winpodx/apps/myapp
|
||||
cat > ~/.local/share/winpodx/apps/myapp/app.toml << 'EOF'
|
||||
name = "myapp"
|
||||
full_name = "My Application"
|
||||
executable = "C:\\Program Files\\MyApp\\myapp.exe"
|
||||
categories = ["Utility"]
|
||||
mime_types = []
|
||||
EOF
|
||||
|
||||
winpodx app install myapp # Register in desktop menu
|
||||
```
|
||||
|
||||
## Multi-Session RDP
|
||||
|
||||
Stock Windows Desktop editions limit RDP to one session per user; a second app would otherwise reconnect and steal the first session. WinPodX bundles [rdprrap](https://github.com/kernalix7/rdprrap) — a Rust reimplementation of RDPWrap — inside the package itself and installs it automatically during the Windows unattended install, so each RemoteApp window gets its own independent session.
|
||||
|
||||
**RAIL prerequisites.** RemoteApp itself requires three registry settings that WinPodX applies during unattended setup: `fDisabledAllowList=1` (enables RemoteApp publishing), `fInheritInitialProgram=1` (required for `/app:program:...` to launch the target executable instead of a shell), and `MaxInstanceCount=10` paired with `fSingleSessionPerUser=0` (lifts the single-session cap up to 10 concurrent RemoteApp windows). These are set regardless of whether rdprrap installs successfully — rdprrap is what makes the sessions *independent*, but the registry keys are what make RemoteApp work at all. After rdprrap install `TermService` is cycled so the wrapper DLL activates without a reboot.
|
||||
|
||||
**Authentication channel.** NLA is disabled (`UserAuthentication=0`) so the FreeRDP command line can authenticate unattended from under `podman unshare --rootless-netns`, but `SecurityLayer=2` keeps the RDP channel itself encrypted with TLS (so `/sec:tls /cert:ignore` against `127.0.0.1` is the full authenticated + encrypted path — no cleartext on the wire even though NLA is off).
|
||||
|
||||
**Works fully offline.** The rdprrap zip ships inside WinPodX's data directory (`config/oem/`) and is staged into `C:\OEM\` during the guest's first boot. sha256 is verified against a pin file before extraction. No network access is required at install time.
|
||||
|
||||
Install is one-shot: the patch is applied during dockur's unattended setup phase. If anything in that step fails (hash mismatch, extraction, installer error), WinPodX logs a warning and the guest stays in single-session mode — app launch never blocks on this step. A guest-side management channel (enable/disable/status after install) is planned for a later release.
|
||||
@@ -0,0 +1,348 @@
|
||||
# 설치
|
||||
|
||||
[English](INSTALL.md) | **한국어**
|
||||
|
||||
WinPodX 설치하는 모든 방법 — 원라인 인스톨러, distro 패키지 매니저, Nix, 소스 빌드, 오프라인 시나리오.
|
||||
|
||||
## 원라인 설치
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
distro 를 감지하고, 누락된 시스템 의존성 (Podman, FreeRDP, KVM, Python 3.9+) 을 확인 후 설치, WinPodX 를 `~/.local/bin/winpodx-app/` 에 배치. Windows 앱 메뉴는 pod 첫 부팅 시 자동으로 채워짐 — discovery 가 실행 중인 Windows 게스트를 스캔하고 설치된 모든 앱을 실제 아이콘과 함께 등록. 의존성 설치 단계 외에는 root 불필요. openSUSE, Fedora (Atomic Desktops 포함: Silverblue, Kinoite, Sericea, Bluefin, Bazzite), Debian/Ubuntu, RHEL-family, Arch, NixOS 에서 동작.
|
||||
|
||||
> **Windows 라이선스.** dockur 가 pod 첫 부팅 시 Microsoft 에서 Windows ISO 를 다운로드. 결과로 만들어진 Windows 게스트의 사용은 Microsoft 의 Software License Terms (첫 활성화 시 표시되는 EULA) 의 적용을 받음. WinPodX 는 Windows 를 재배포하지 않음, 본인 머신에서의 설치를 오케스트레이션할 뿐. 활성화는 본인의 Windows 라이선스 키로 — Home / Pro / Enterprise 모두 dockur 가 지원.
|
||||
|
||||
기본적으로 인스톨러는 **가장 최신의 GitHub release** 에 pin. 프리릴리스 / 개발 버전은 opt-in.
|
||||
|
||||
## 버전 선택
|
||||
|
||||
`--main` (또는 `--ref TAG`) 로 개발 빌드 사용 가능, 그렇지 않으면 기본 release 사용:
|
||||
|
||||
```bash
|
||||
# 최신 안정 release 설치 (기본)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
|
||||
# 최신 main HEAD 설치 (개발용, 불안정할 수 있음)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --main
|
||||
|
||||
# 특정 태그, 브랜치, 커밋 설치
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --ref vX.Y.Z
|
||||
|
||||
# 환경변수 등가 (curl | bash 에서 -s -- 없이 동작)
|
||||
WINPODX_REF=main curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
WINPODX_REF=vX.Y.Z curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
> **업그레이드.** `install.sh` 재실행 (또는 패키지 업그레이드) 시 새 버전이 그 자리에서 설치됨. 실행 중인 시스템 트레이 / GUI 는 새 버전이 적용되도록 자동으로 재시작 — 수동 재시작 불필요. Windows pod 는 그동안 계속 실행됨.
|
||||
|
||||
## 수동 설치 (provisioning 건너뛰기)
|
||||
|
||||
바이너리만 먼저 설치하고 Windows 게스트 커스터마이즈 (edition / 언어 / debloat / tuning knob 선택) 를 ~7.5 GB ISO 다운로드 + Sysprep + OEM apply 시작 전에 하고 싶은 사용자는 `--manual` 전달:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --manual
|
||||
# 또는 env var:
|
||||
WINPODX_MANUAL=1 curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Manual 모드는 바이너리 + desktop entry + 아이콘만 설치 — `winpodx setup` / `pod wait-ready` / 앱 디스커버리 / reverse-open 설정 전부 skip. 다음 `winpodx` 실행 (CLI 또는 GUI) 시 first-run prompt 가 3가지 옵션 제공:
|
||||
|
||||
- **Auto** — 호스트 감지 default, non-interactive (= 기본 `install.sh` 가 했을 것)
|
||||
- **Customize** — wizard 모드 (모든 knob 선택); `winpodx setup --customize` 와 동등
|
||||
- **Skip** — 변경 없이 종료; 다음 실행시 prompt 재발
|
||||
|
||||
패키지 매니저 설치 후 발화하는 flow 와 동일. wizard 원하지만 `install.sh` 중간에 인터럽트 받기 싫을 때 사용.
|
||||
|
||||
## 오프라인 / 에어갭 설치
|
||||
|
||||
인스톨러는 registry / 패키지 저장소 접근이 없는 머신을 위한 세 가지 선택 플래그 제공:
|
||||
|
||||
```bash
|
||||
# git clone 대신 로컬 사본에서 winpodx 복사 (환경변수: WINPODX_SOURCE)
|
||||
./install.sh --source /media/usb/winpodx
|
||||
|
||||
# 첫 부팅 시 가져오는 대신 Windows 이미지 tar 미리 로드 (환경변수: WINPODX_IMAGE_TAR)
|
||||
./install.sh --image-tar /media/usb/windows-image.tar
|
||||
|
||||
# distro 패키지 설치 건너뛰기 (환경변수: WINPODX_SKIP_DEPS=1) — 의존성 없으면 일찍 실패
|
||||
./install.sh --skip-deps
|
||||
|
||||
# 한 번에:
|
||||
./install.sh --source /media/usb/winpodx --image-tar /media/usb/windows-image.tar --skip-deps
|
||||
```
|
||||
|
||||
환경변수는 `curl | bash` 에서도 동작 — `WINPODX_SKIP_DEPS=1 curl ... | bash` 가능.
|
||||
|
||||
## Windows 에디션 선택
|
||||
|
||||
기본은 dockur 의 최신 Windows 11 이미지. fresh install 시 `--win-version VER` (또는 `WINPODX_WIN_VERSION` 환경변수) 로 다른 큐레이트 에디션 선택 가능:
|
||||
|
||||
```bash
|
||||
# Win11 대신 Windows 10 LTSC 설치
|
||||
./install.sh --win-version ltsc10
|
||||
|
||||
# IoT Enterprise LTSC (kiosk / appliance 용 장기 지원)
|
||||
./install.sh --win-version iot11
|
||||
|
||||
# Debloat 커뮤니티 빌드
|
||||
./install.sh --win-version tiny11
|
||||
|
||||
# Server 2022
|
||||
./install.sh --win-version 2022
|
||||
```
|
||||
|
||||
큐레이트 셋: `11 | 10 | ltsc11 | ltsc10 | iot11 | tiny11 | tiny10 | 2025 | 2022 | 2019 | 2016`. Pre-Win10 에디션 (XP / Vista / 7 / 8 / Server 2003-2012) 은 Microsoft 보안 지원이 끝났고 WinPodX 의 rdprrap / agent.ps1 / install.bat 가정과 안 맞음 — WARNING 한 줄 로그하고 dockur 로 통과되지만 정식 지원 아님.
|
||||
|
||||
`--win-version` 플래그는 fresh install 에만 적용 (기존 `winpodx.toml` 없을 때). 기존 설치에서 에디션 변경은 GUI 설정 → Container/VM → **Windows Edition** 드롭다운 (또는 config 삭제 후 `winpodx setup --win-version VER`).
|
||||
|
||||
자체 커스텀 ISO 부팅은 [고급: 커스텀 Windows ISO](ARCHITECTURE.ko.md#고급-커스텀-windows-iso) 참고.
|
||||
|
||||
## Windows 언어 선택
|
||||
|
||||
기본은 **영어 (미국)**. installer 실행 후 `~/.config/winpodx/winpodx.toml` 편집해서 표시 언어, 지역 형식, 키보드 레이아웃 설정 가능 (또는 fresh install 전에 미리 생성):
|
||||
|
||||
```toml
|
||||
[pod]
|
||||
# 한국어 예시
|
||||
language = "Korean"
|
||||
region = "ko-KR"
|
||||
keyboard = "ko-KR"
|
||||
```
|
||||
|
||||
일반적인 언어 설정:
|
||||
|
||||
| 언어 | `language` | `region` | `keyboard` |
|
||||
|------|------------|----------|------------|
|
||||
| 영어 (미국) | `English` | `en-001` | `en-US` |
|
||||
| 한국어 | `Korean` | `ko-KR` | `ko-KR` |
|
||||
| 스페인어 (스페인) | `Spanish` | `es-ES` | `es-ES` |
|
||||
| 스페인어 (라틴 아메리카) | `Spanish` | `es-MX` | `la-Latin` |
|
||||
| 프랑스어 (프랑스) | `French` | `fr-FR` | `fr-FR` |
|
||||
| 독일어 (독일) | `German` | `de-DE` | `de-DE` |
|
||||
| 이탈리아어 (이탈리아) | `Italian` | `it-IT` | `it-IT` |
|
||||
| 포르투갈어 (브라질) | `Portuguese` | `pt-BR` | `pt-BR` |
|
||||
| 포르투갈어 (포르투갈) | `Portuguese` | `pt-PT` | `pt-PT` |
|
||||
| 일본어 | `Japanese` | `ja-JP` | `ja-JP` |
|
||||
| 중국어 (간체) | `Chinese` | `zh-CN` | `zh-CN` |
|
||||
|
||||
이 설정은 **fresh Windows 설치**에만 적용. 이미 `winpodx setup` 실행하고 Windows 를 한 번 부팅했으면:
|
||||
1. `winpodx pod stop` 으로 컨테이너 중지, storage volume 삭제, config 편집 후 `winpodx setup` 재실행, **또는**
|
||||
2. Windows 안에서 수동으로 설정 → 시간 및 언어 → 언어 및 지역에서 변경
|
||||
|
||||
지원 언어 및 지역 코드 전체 목록은 [dockur/windows 문서](https://github.com/dockur/windows#how-do-i-change-the-language) 참고.
|
||||
|
||||
## 네이티브 패키지 매니저
|
||||
|
||||
미리 빌드된 RPM 과 `.deb` 패키지가 모든 [GitHub Release](https://github.com/kernalix7/winpodx/releases/latest) 에 첨부됨 — openSUSE/Fedora RPM 은 [openSUSE Build Service (`home:Kernalix7/winpodx`)](https://build.opensuse.org/package/show/home:Kernalix7/winpodx) 에서, 나머지는 GitHub Actions 에서. [`winpodx` AUR 패키지](https://aur.archlinux.org/packages/winpodx) 는 v0.5.2 부터 라이브 — Arch 사용자는 `yay -S winpodx` 또는 `paru -S winpodx` 로 설치.
|
||||
|
||||
> **패키지 매니저 설치 후엔 `winpodx setup` 한번 실행.** 패키지 payload 는 바이너리 + 데스크탑 entry + 아이콘 + man page 만 — Windows VM provisioning 자동 트리거하는 post-install 훅 없음. 이유: (a) `winpodx setup` 가 인터랙티브 (backend / 자격증명 prompt), (b) `winpodx pod start` 는 ~7.5 GB Windows ISO 다운로드 + Sysprep + OEM apply (보통 회선 5–10분) 트리거, (c) `apt install` / `dnf install` / `yay -S` 가 root 로 돌며 사용자 네임스페이스 rootless podman provisioning 발화시키면 곤란. curl 원라이너는 동일한 `winpodx setup --non-interactive` + `winpodx pod wait-ready` 체인을 자체 실행해서 수동 setup 단계 안 보임. 첫 실행 흐름:
|
||||
>
|
||||
> ```bash
|
||||
> winpodx setup # 인터랙티브: backend / 자격증명 / 사양 / locale
|
||||
> winpodx app run desktop # 첫 호출시 pod 자동 provision (~5–10분)
|
||||
> ```
|
||||
|
||||
### openSUSE Tumbleweed / Leap 15.6 / Leap 16.0 / Slowroll
|
||||
|
||||
```bash
|
||||
sudo zypper addrepo \
|
||||
https://download.opensuse.org/repositories/home:/Kernalix7/openSUSE_Tumbleweed/home:Kernalix7.repo
|
||||
sudo zypper refresh
|
||||
sudo zypper install winpodx
|
||||
```
|
||||
|
||||
필요에 따라 `openSUSE_Tumbleweed` 를 `openSUSE_Leap_16.0`, `openSUSE_Leap_15.6`, `openSUSE_Slowroll` 로 교체.
|
||||
|
||||
### Fedora 42 / 43 / 44
|
||||
|
||||
```bash
|
||||
sudo dnf config-manager addrepo --from-repofile=\
|
||||
https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_43/home:Kernalix7.repo
|
||||
sudo dnf install winpodx
|
||||
```
|
||||
|
||||
`Fedora_43` 을 `Fedora_42` 또는 `Fedora_44` 로 교체.
|
||||
|
||||
> **메모:** Fedora 41+ 는 dnf5 — 위 syntax (`addrepo --from-repofile=`) 가 그것. dnf4 (Fedora ≤40, EOL) 는 `sudo dnf config-manager --add-repo <URL>`. @payayas 가 #228 에서 제보.
|
||||
|
||||
### Fedora Atomic Desktops (Silverblue / Kinoite / Sericea / Bluefin / Bazzite)
|
||||
|
||||
Atomic Fedora 는 `dnf` 대신 `rpm-ostree` 사용 — 동일 OBS RPM 을 부팅된 deployment 에 `--apply-live` 로 레이어링 (재부팅 불필요, 라이브 deployment 가 받으면). 받지 못하는 경우 다음 부팅용으로 staged. 범용 `install.sh` 가 `rpm-ostree` 를 autodetect 해서 레이어링 경로 실행; 수동으로도 가능:
|
||||
|
||||
```bash
|
||||
sudo curl -sSL \
|
||||
https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_43/home:Kernalix7.repo \
|
||||
-o /etc/yum.repos.d/home-Kernalix7-winpodx.repo
|
||||
sudo rpm-ostree install --apply-live winpodx # 먼저 라이브 적용 시도
|
||||
# 부팅된 deployment 에서 라이브 적용을 지원하지 않으면:
|
||||
sudo rpm-ostree install winpodx # staged; 재부팅으로 활성화
|
||||
```
|
||||
|
||||
`Fedora_43` 을 베이스 이미지에 맞게 `Fedora_42` 또는 `Fedora_44` 로 교체.
|
||||
|
||||
### Debian 12 / 13, Ubuntu 24.04 / 25.04 / 25.10
|
||||
|
||||
[최신 release](https://github.com/kernalix7/winpodx/releases/latest) 에서 맞는 `.deb` 를 다운로드 후 설치:
|
||||
|
||||
```bash
|
||||
sudo apt install ./winpodx_<version>_all_debian13.deb # 본인 환경에 맞는 것 선택
|
||||
```
|
||||
|
||||
### AlmaLinux / Rocky / RHEL 9 & 10
|
||||
|
||||
el9 는 `python3-tomli` 위해 EPEL 필요. [최신 release](https://github.com/kernalix7/winpodx/releases/latest) 에서 맞는 `.rpm` 다운로드 후 설치:
|
||||
|
||||
```bash
|
||||
sudo dnf install epel-release # el9 만
|
||||
sudo dnf install ./winpodx-<version>-0.noarch.el9.rpm # 또는 .el10.rpm
|
||||
```
|
||||
|
||||
### Arch Linux / Manjaro
|
||||
|
||||
선호하는 AUR helper 로 설치:
|
||||
|
||||
```bash
|
||||
yay -S winpodx
|
||||
# 또는
|
||||
paru -S winpodx
|
||||
```
|
||||
|
||||
PKGBUILD 는 [`packaging/aur/PKGBUILD`](../packaging/aur/PKGBUILD) 에 있고, 태그 푸시 (`v*.*.*`) 마다 버전 + tarball sha256 자동 stamp 후 `aur.archlinux.org/winpodx.git` 로 푸시.
|
||||
|
||||
**개발 빌드 — `main` 추적 (`winpodx-git`).** 릴리즈 대신 최신 미공개 코드를 돌리려면:
|
||||
|
||||
```bash
|
||||
yay -S winpodx-git
|
||||
```
|
||||
|
||||
`winpodx-git` 은 `main` 브랜치에서 빌드 (버전은 git 에서 자동 도출 — 리빌드할 때마다 최신 커밋을 가져옴, `yay -Syu --devel` 로 자동 업데이트). `winpodx` 를 `provides`/`conflicts` 하므로 둘 중 하나만 설치. 레시피 + 메인테이너 노트: [`packaging/aur-git/`](../packaging/aur-git/).
|
||||
|
||||
## AppImage (Thin 번들: Python + Qt + FreeRDP + WinPodX; 호스트 컨테이너 런타임 필요)
|
||||
|
||||
distro 무관 WinPodX AppImage 가 태그 release 마다 asset 으로 ship 됨. **0.6.0 에서 Thin AppImage 로 재설계 (item A).** 0.6.0 이전엔 ~296 MB fat 번들이 컨테이너 stack 전부 (Podman + podman-compose + conmon + crun + netavark + aardvark-dns + pasta + passt + slirp4netns + transitive lib) 를 AppImage 의 `PATH` / `LD_LIBRARY_PATH` 에 실음. 이미 podman 이 있는 distro 에선 호스트 stack 을 가려서 망가뜨림 — Ubuntu 26.04 의 `it seems that you do not have podman installed` (#357), Fedora Bluefin 의 aardvark-dns 가 던지는 `OPENSSL_3.4.0 not found` (#363) 등. 0.6.0 은 컨테이너 stack 전체를 AppImage 에서 제거해 **근본 원인을 제거**. 현 번들은 안전하게 묶을 수 있는 것만 — Python 3, WinPodX, Qt6 (PySide6), FreeRDP 3 클라이언트 (`xfreerdp`, `wlfreerdp`, `sdl-freerdp`) — 표준 `PATH` 해석으로 호스트 컨테이너 런타임 사용. 컨테이너 stack 제거만으론 ~274 MB 였고(≈296 MB fat 에서), 진짜 용량은 PySide6 의 Qt6 전체 번들(QtWebEngine 만 ~195 MB; winpodx 는 QtCore/QtGui/QtWidgets/QtSvg/QtDBus 만 사용)이라 안 쓰는 Qt6 모듈도 strip(`packaging/appimage/slim-pyside6.sh`)해서 **~110 MB** 로 줄입니다.
|
||||
|
||||
> **FreeRDP 클라이언트 소스.** FreeRDP 클라이언트 소스는 선택 가능, auto-discovery 는 Flatpak 클라이언트 (`com.freerdp.FreeRDP`) 를 우선하고 네이티브 클라이언트 (`PATH` 의 `xfreerdp` / `wlfreerdp` / `sdl-freerdp`) 를 fallback 으로 사용 (#366 / #393).
|
||||
|
||||
호스트 측 요구사항:
|
||||
|
||||
- distro 패키지 매니저로 설치한 컨테이너 런타임: **`podman ≥ 4` 권장**, `docker` 도 지원 (manual 백엔드는 기존 RDP 호스트를 사용). 없으면 위 RPM / DEB / AUR / `install.sh` 한 줄 설치 활용.
|
||||
- 호스트 커널이 `/dev/kvm` 노출 (BIOS 에서 VT-x / AMD-V 활성화 시 대부분 distro 기본 동작).
|
||||
- 현재 사용자가 `kvm` 그룹 멤버 (rootless Podman 용 `/etc/subuid` + `/etc/subgid` 엔트리 존재 — 최근 distro 는 사전 구성; `cat /etc/subuid` 로 확인).
|
||||
|
||||
`setup-host` 위저드는 AppImage 에 그대로 포함 — kvm 그룹 / subuid 단계를 수동 sudo 대신 polkit 한 번으로:
|
||||
|
||||
```bash
|
||||
# 1. 최신 GitHub release 에서 AppImage 다운로드
|
||||
# -> winpodx-x86_64.AppImage
|
||||
|
||||
# 2. 실행 권한 부여
|
||||
chmod +x winpodx-x86_64.AppImage
|
||||
|
||||
# 3. (선택) 첫 실행 호스트 setup — kvm 그룹, subuid/subgid, kvm 모듈
|
||||
./winpodx-x86_64.AppImage setup-host # detect + 대화형 프롬프트
|
||||
./winpodx-x86_64.AppImage setup-host --apply # 프롬프트 없이 적용
|
||||
./winpodx-x86_64.AppImage setup-host --status # detect 만, mutation 없음
|
||||
|
||||
# 4. 로그아웃 + 재로그인 — 새 kvm 그룹 멤버십 적용
|
||||
|
||||
# 5. 표준 winpodx setup (cores / RAM / timezone 자동감지)
|
||||
./winpodx-x86_64.AppImage setup
|
||||
|
||||
# 6. 데스크탑 (또는 설치된 Windows 앱 이름) 실행
|
||||
./winpodx-x86_64.AppImage app run desktop
|
||||
```
|
||||
|
||||
권장 사용 환경:
|
||||
|
||||
- **Immutable distro** (Fedora Silverblue / Kinoite, openSUSE Aeon, Steam Deck) — 호스트 podman 만 layering 후 사용.
|
||||
- **잠긴 환경** — `curl ... | bash` 못 돌지만 다운로드 받은 단일 실행 파일은 돌 수 있는 경우.
|
||||
- **친구 노트북 임시 시도** — 호스트에 podman / docker 가 이미 있을 때.
|
||||
|
||||
비권장:
|
||||
|
||||
- 시스템 패키지 매니저 통합 선호 (위 RPM / DEB / AUR 경로 추천).
|
||||
- 시스템 업데이트 cycle 로 자동 업데이트 받기 원함 (AppImage 는 수동 다운로드 + 교체; AppImageUpdate 연동 별도 작업).
|
||||
- 진정한 호스트 zero-prep — Thin AppImage 는 호스트 컨테이너 런타임 필요. 설치까지 위임하고 싶으면 `install.sh` 사용.
|
||||
|
||||
레시피와 CI 는 `packaging/appimage/` 에 — 로컬 빌드는 `packaging/appimage/README.md` 참조. 회귀 테스트 (`tests/test_appimage_recipe.py`) 가 `podman` / `podman-compose` / `conmon` / `crun` / `netavark` / `aardvark-dns` / `passt` / `pasta` / `slirp4netns` / `fuse-overlayfs` 중 어느 하나라도 레시피에 다시 들어오는 순간 CI 실패.
|
||||
|
||||
## Nix
|
||||
|
||||
NixOS / nix-on-any-distro 사용자를 위한 flake 제공:
|
||||
|
||||
```bash
|
||||
# 설치 없이 바로 실행
|
||||
nix run github:kernalix7/winpodx
|
||||
|
||||
# 프로필에 설치
|
||||
nix profile install github:kernalix7/winpodx
|
||||
|
||||
# flake input 으로
|
||||
inputs.winpodx.url = "github:kernalix7/winpodx";
|
||||
```
|
||||
|
||||
wrapper 가 FreeRDP, podman / podman-compose, iproute2, libnotify 를 bundle 해서 기본 Podman 백엔드는 바로 동작. Docker 백엔드는 Docker 가 호스트에 설치되어 있어야 함; manual 백엔드는 직접 제공하는 RDP 호스트에 연결.
|
||||
|
||||
## 소스에서
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kernalix7/winpodx.git
|
||||
cd winpodx
|
||||
./install.sh
|
||||
```
|
||||
|
||||
소스 인스톨러는 자동으로:
|
||||
1. distro 감지 (openSUSE, Fedora, Ubuntu, Arch, ...)
|
||||
2. 누락된 의존성 (Podman, FreeRDP, KVM) 설치, 설치 전 확인
|
||||
3. WinPodX 를 `~/.local/bin/winpodx-app/` 로 복사
|
||||
4. config 와 `compose.yaml` 생성
|
||||
5. pod 첫 부팅 시 자동 discovery (`winpodx app refresh`) fire 해서 메뉴 채우기
|
||||
|
||||
### 수동 실행 (설치 없이)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kernalix7/winpodx.git
|
||||
cd winpodx
|
||||
export PYTHONPATH="$PWD/src"
|
||||
python3 -m winpodx app run word
|
||||
```
|
||||
|
||||
## 언인스톨
|
||||
|
||||
설치 경로 (curl / pip / deb / rpm / aur) 무관하게 동일한 canonical 스크립트 하나.
|
||||
|
||||
두 진입점 동일 동작:
|
||||
|
||||
```bash
|
||||
# 모든 동작 중단, Windows 컨테이너 + config 유지 (apt remove 의미)
|
||||
./uninstall.sh
|
||||
# 또는
|
||||
winpodx uninstall
|
||||
|
||||
# 완전 삭제: 컨테이너 + 볼륨 + ~50GB Windows 디스크 + config + 런처 (apt purge 의미)
|
||||
./uninstall.sh --purge --yes
|
||||
# 또는
|
||||
winpodx uninstall --purge --yes
|
||||
```
|
||||
|
||||
`winpodx uninstall` 은 thin wrapper — 셋 중 하나에서 `uninstall.sh` 찾아서 exec: `/usr/share/winpodx/uninstall.sh` (deb/rpm/aur), `~/.local/bin/winpodx-app/uninstall.sh` (curl), wheel shared-data 디렉토리 (pip).
|
||||
|
||||
파이프 환경에서는 `--yes` 또는 `--purge` 필수 (curl 이 stdin 을 소비하는 동안 대화형 프롬프트가 터미널에서 읽을 수 없음):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --yes
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --purge
|
||||
```
|
||||
|
||||
패키지 매니저 (apt/dnf/zypper/pacman) 로 설치한 경우, `uninstall.sh` 가 이를 감지하고 패키지 제거를 먼저 권장 — 올바른 순서: 패키지 매니저의 post-remove hook 이 `uninstall.sh` 를 user-side cleanup 용으로 재실행, 패키지 DB 와 디스크 상태 동기화.
|
||||
|
||||
**기본 모드는 절대 안 건드림** (전부 지우려면 `--purge`):
|
||||
- Podman 컨테이너 / 볼륨 (Windows VM 디스크, ~50GB)
|
||||
- `~/.config/winpodx/` config + compose.yaml
|
||||
- Storage bind-mount 내용
|
||||
|
||||
**둘 다 절대 안 건드림:**
|
||||
- 시스템 패키지 (podman, freerdp, python3) — 패키지 매니저 제거 prompt 가 처리
|
||||
- 홈 디렉토리의 다른 파일들
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
# Install
|
||||
|
||||
**English** | [한국어](INSTALL.ko.md)
|
||||
|
||||
Every way to install WinPodX — the one-line installer, distro package managers, Nix, source builds, and offline scenarios.
|
||||
|
||||
## One-line install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Detects your distro, installs missing system dependencies (Podman, FreeRDP, KVM, Python 3.9+) with your confirmation, drops winpodx into `~/.local/bin/winpodx-app/`. The Windows-app menu populates automatically the first time the pod boots — discovery scans your running Windows guest and registers every installed app with its real icon. No root required except for the dependency install step. Works on openSUSE, Fedora (including Atomic Desktops: Silverblue, Kinoite, Sericea, Bluefin, Bazzite), Debian/Ubuntu, RHEL-family, Arch, and NixOS.
|
||||
|
||||
> **Windows licensing.** dockur downloads a Windows ISO from Microsoft at first pod boot. Your use of the resulting Windows guest is governed by Microsoft's Software License Terms (the EULA shown on first activation). WinPodX does not redistribute Windows; it only orchestrates the install on your machine. Bring your own Windows license key for activation — Home / Pro / Enterprise are all supported by dockur.
|
||||
|
||||
By default the installer pins to the **latest published GitHub release**. Pre-release / development versions stay opt-in.
|
||||
|
||||
## Choose a version
|
||||
|
||||
Pass `--main` (or `--ref TAG`) for development builds, otherwise stick with the default release:
|
||||
|
||||
```bash
|
||||
# Install the latest stable release (default)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
|
||||
# Install the latest main HEAD (development; may be unstable)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --main
|
||||
|
||||
# Install a specific tag, branch, or commit
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --ref vX.Y.Z
|
||||
|
||||
# Env-var equivalent (works under curl | bash without -s --)
|
||||
WINPODX_REF=main curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
WINPODX_REF=vX.Y.Z curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
> **Upgrading.** Re-running `install.sh` (or upgrading the package) installs the new version in place. A running system tray / GUI restarts itself automatically so the new version applies — no manual restart needed. The Windows pod keeps running throughout.
|
||||
|
||||
## Manual install (skip provisioning)
|
||||
|
||||
For users who want to install the binary now and customize the Windows guest later (pick edition / language / debloat / tuning knobs before the ~7.5 GB ISO download + Sysprep + OEM apply kicks off), pass `--manual`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --manual
|
||||
# or via env var:
|
||||
WINPODX_MANUAL=1 curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Manual mode installs the binary + desktop entry + icon only -- no `winpodx setup`, no `pod wait-ready`, no app discovery, no reverse-open setup. The next time you run `winpodx` (CLI or GUI), the first-run prompt offers three options:
|
||||
|
||||
- **Auto** -- host-detected defaults, non-interactive (= what default `install.sh` would have done)
|
||||
- **Customize** -- wizard mode (pick every knob); equivalent to `winpodx setup --customize`
|
||||
- **Skip** -- exits without changes; re-runs the prompt on next invocation
|
||||
|
||||
This is the same flow that fires after a package-manager install. Use it when you want the wizard but prefer not to interrupt `install.sh` half-way through.
|
||||
|
||||
## Offline / air-gapped install
|
||||
|
||||
The installer takes three optional flags for machines with no registry / package-repo access:
|
||||
|
||||
```bash
|
||||
# Copy winpodx from a local clone instead of git clone (also env: WINPODX_SOURCE)
|
||||
./install.sh --source /media/usb/winpodx
|
||||
|
||||
# Preload the Windows image tar instead of fetching at first boot (env: WINPODX_IMAGE_TAR)
|
||||
./install.sh --image-tar /media/usb/windows-image.tar
|
||||
|
||||
# Skip distro package install (env: WINPODX_SKIP_DEPS=1) — fails early if deps aren't present
|
||||
./install.sh --skip-deps
|
||||
|
||||
# Everything at once:
|
||||
./install.sh --source /media/usb/winpodx --image-tar /media/usb/windows-image.tar --skip-deps
|
||||
```
|
||||
|
||||
Env vars are honored even under `curl | bash`, so `WINPODX_SKIP_DEPS=1 curl ... | bash` works.
|
||||
|
||||
## Choosing the Windows edition
|
||||
|
||||
By default WinPodX installs the latest dockur Windows 11 image. Pass `--win-version VER` (or the `WINPODX_WIN_VERSION` env var) to pick a different curated edition during a fresh install:
|
||||
|
||||
```bash
|
||||
# Install Windows 10 LTSC instead of Win11
|
||||
./install.sh --win-version ltsc10
|
||||
|
||||
# IoT Enterprise LTSC (long-term-service for kiosks / appliances)
|
||||
./install.sh --win-version iot11
|
||||
|
||||
# Debloated community build
|
||||
./install.sh --win-version tiny11
|
||||
|
||||
# Server 2022
|
||||
./install.sh --win-version 2022
|
||||
```
|
||||
|
||||
Curated set: `11 | 10 | ltsc11 | ltsc10 | iot11 | tiny11 | tiny10 | 2025 | 2022 | 2019 | 2016`. Pre-Win10 editions (XP / Vista / 7 / 8 / Server 2003-2012) are out of Microsoft security support and don't match the rdprrap / agent.ps1 / install.bat assumptions WinPodX is built on — they'll still pass through to dockur with a WARNING but aren't first-class supported.
|
||||
|
||||
The `--win-version` flag only applies on fresh installs (no existing `winpodx.toml`). Existing installs change the edition via the GUI Settings → Container/VM → **Windows Edition** dropdown (or `winpodx setup --win-version VER` if you've removed the config).
|
||||
|
||||
For booting your own custom ISO with programs pre-installed, see [Advanced: Custom Windows ISO](ARCHITECTURE.md#advanced-custom-windows-iso).
|
||||
|
||||
## Choosing the Windows language
|
||||
|
||||
By default, Windows installs in **English (US)**. You can configure the display language, regional format, and keyboard layout by editing `~/.config/winpodx/winpodx.toml` after running the installer (or by creating it beforehand for a fresh install):
|
||||
|
||||
```toml
|
||||
[pod]
|
||||
# Spanish example
|
||||
language = "Spanish"
|
||||
region = "es-ES"
|
||||
keyboard = "es-ES"
|
||||
```
|
||||
|
||||
Common language configurations:
|
||||
|
||||
| Language | `language` | `region` | `keyboard` |
|
||||
|----------|------------|----------|------------|
|
||||
| English (US) | `English` | `en-001` | `en-US` |
|
||||
| Spanish (Spain) | `Spanish` | `es-ES` | `es-ES` |
|
||||
| Spanish (Latin America) | `Spanish` | `es-MX` | `la-Latin` |
|
||||
| French (France) | `French` | `fr-FR` | `fr-FR` |
|
||||
| German (Germany) | `German` | `de-DE` | `de-DE` |
|
||||
| Italian (Italy) | `Italian` | `it-IT` | `it-IT` |
|
||||
| Portuguese (Brazil) | `Portuguese` | `pt-BR` | `pt-BR` |
|
||||
| Portuguese (Portugal) | `Portuguese` | `pt-PT` | `pt-PT` |
|
||||
| Japanese | `Japanese` | `ja-JP` | `ja-JP` |
|
||||
| Chinese (Simplified) | `Chinese` | `zh-CN` | `zh-CN` |
|
||||
|
||||
These settings only apply to **fresh Windows installations**. If you've already run `winpodx setup` and booted Windows once, you'll need to either:
|
||||
1. Recreate the container with `winpodx pod stop`, delete the storage volume, edit the config, and run `winpodx setup` again, **or**
|
||||
2. Change the language manually inside Windows via Settings → Time & Language → Language & region
|
||||
|
||||
For the complete list of supported languages and region codes, see the [dockur/windows documentation](https://github.com/dockur/windows#how-do-i-change-the-language).
|
||||
|
||||
## Native package managers
|
||||
|
||||
Prebuilt RPM and `.deb` packages are attached to every [GitHub Release](https://github.com/kernalix7/winpodx/releases/latest) — openSUSE/Fedora RPMs come from the [openSUSE Build Service (`home:Kernalix7/winpodx`)](https://build.opensuse.org/package/show/home:Kernalix7/winpodx), the rest from GitHub Actions. The [`winpodx` AUR package](https://aur.archlinux.org/packages/winpodx) is live as of v0.5.2 — Arch users can install via `yay -S winpodx` or `paru -S winpodx`.
|
||||
|
||||
> **After any package-manager install, run `winpodx setup` once.** The package payload is binary + desktop entry + icon + man page only -- no post-install hook fires the Windows VM provisioning, because (a) `winpodx setup` is interactive (backend / credentials prompts), (b) `winpodx pod start` triggers a ~7.5 GB Windows ISO download + Sysprep + OEM apply (5–10 min on typical connections), and (c) `apt install` / `dnf install` / `yay -S` running as root shouldn't fire user-namespace rootless podman provisioning. The curl one-liner does the same `winpodx setup --non-interactive` + `winpodx pod wait-ready` chain itself, which is why it appears not to need a manual setup step. First-time flow:
|
||||
>
|
||||
> ```bash
|
||||
> winpodx setup # interactive: backend / credentials / specs / locale
|
||||
> winpodx app run desktop # auto-provisions the pod on first call (~5–10 min)
|
||||
> ```
|
||||
|
||||
### openSUSE Tumbleweed / Leap 15.6 / Leap 16.0 / Slowroll
|
||||
|
||||
```bash
|
||||
sudo zypper addrepo \
|
||||
https://download.opensuse.org/repositories/home:/Kernalix7/openSUSE_Tumbleweed/home:Kernalix7.repo
|
||||
sudo zypper refresh
|
||||
sudo zypper install winpodx
|
||||
```
|
||||
|
||||
Replace `openSUSE_Tumbleweed` with `openSUSE_Leap_16.0`, `openSUSE_Leap_15.6`, or `openSUSE_Slowroll` as needed.
|
||||
|
||||
### Fedora 42 / 43 / 44
|
||||
|
||||
```bash
|
||||
sudo dnf config-manager addrepo --from-repofile=\
|
||||
https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_43/home:Kernalix7.repo
|
||||
sudo dnf install winpodx
|
||||
```
|
||||
|
||||
Replace `Fedora_43` with `Fedora_42` or `Fedora_44` as needed.
|
||||
|
||||
> **Note:** Fedora 41 + ships dnf5; the syntax above (`addrepo --from-repofile=`) matches it. On dnf4 (Fedora ≤40, EOL) the equivalent is `sudo dnf config-manager --add-repo <URL>`. Reported by @payayas in #228.
|
||||
|
||||
### Fedora Atomic Desktops (Silverblue / Kinoite / Sericea / Bluefin / Bazzite)
|
||||
|
||||
Atomic Fedora uses `rpm-ostree` instead of `dnf` — the same OBS RPM is layered onto the booted deployment with `--apply-live` (no reboot needed) when the running system accepts it, otherwise staged for the next boot. The universal `install.sh` autodetects `rpm-ostree` and runs the layered path; you can also do it by hand:
|
||||
|
||||
```bash
|
||||
sudo curl -sSL \
|
||||
https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_43/home:Kernalix7.repo \
|
||||
-o /etc/yum.repos.d/home-Kernalix7-winpodx.repo
|
||||
sudo rpm-ostree install --apply-live winpodx # try live apply first
|
||||
# If live apply isn't supported on the booted deployment:
|
||||
sudo rpm-ostree install winpodx # staged; reboot to activate
|
||||
```
|
||||
|
||||
Replace `Fedora_43` with `Fedora_42` or `Fedora_44` to match your base image.
|
||||
|
||||
### Debian 12 / 13, Ubuntu 24.04 / 25.04 / 25.10
|
||||
|
||||
Download the matching `.deb` from the [latest release](https://github.com/kernalix7/winpodx/releases/latest) and install:
|
||||
|
||||
```bash
|
||||
sudo apt install ./winpodx_<version>_all_debian13.deb # pick your flavor
|
||||
```
|
||||
|
||||
### AlmaLinux / Rocky / RHEL 9 & 10
|
||||
|
||||
EPEL is required on el9 for `python3-tomli`. Download the matching `.rpm` from the [latest release](https://github.com/kernalix7/winpodx/releases/latest) and install:
|
||||
|
||||
```bash
|
||||
sudo dnf install epel-release # el9 only
|
||||
sudo dnf install ./winpodx-<version>-0.noarch.el9.rpm # or .el10.rpm
|
||||
```
|
||||
|
||||
### Arch Linux / Manjaro
|
||||
|
||||
Install from the AUR using your preferred helper:
|
||||
|
||||
```bash
|
||||
yay -S winpodx
|
||||
# or
|
||||
paru -S winpodx
|
||||
```
|
||||
|
||||
The PKGBUILD lives at [`packaging/aur/PKGBUILD`](../packaging/aur/PKGBUILD); each tag push (`v*.*.*`) auto-stamps the version + tarball sha256 and pushes to `aur.archlinux.org/winpodx.git`.
|
||||
|
||||
**Development build — track `main` (`winpodx-git`).** To run the latest unreleased code instead of the tagged release:
|
||||
|
||||
```bash
|
||||
yay -S winpodx-git
|
||||
```
|
||||
|
||||
`winpodx-git` builds from the `main` branch (the version is derived from git, so each rebuild pulls the newest commit — use `yay -Syu --devel` to auto-update). It `provides`/`conflicts` `winpodx`, so install one or the other, not both. Recipe + maintainer notes: [`packaging/aur-git/`](../packaging/aur-git/).
|
||||
|
||||
## AppImage (Thin bundle: Python + Qt + FreeRDP + winpodx; host container runtime required)
|
||||
|
||||
A distro-agnostic AppImage of WinPodX ships as a release asset on every tagged release. **0.6.0 redesigned this as a Thin AppImage (item A).** Pre-0.6.0 the AppImage was a ~296 MB fat bundle that carried the entire container stack (Podman + podman-compose + conmon + crun + netavark + aardvark-dns + pasta + passt + slirp4netns + transitive libs) into the AppImage's `PATH` / `LD_LIBRARY_PATH`. That shadowed and poisoned the host's working stack on every distro that already had a podman — `it seems that you do not have podman installed` on Ubuntu 26.04 (#357), `OPENSSL_3.4.0 not found` from aardvark-dns on Fedora Bluefin (#363), and similar elsewhere. 0.6.0 **removes the root cause** by dropping the entire container stack from the AppImage. The current bundle carries only what is safe to bundle — Python 3, winpodx, Qt6 (PySide6), and the FreeRDP 3 client (`xfreerdp`, `wlfreerdp`, `sdl-freerdp`) — and uses the host's container runtime via standard `PATH` resolution. Dropping the container stack alone only reached ~274 MB, though — the real bulk is PySide6, which bundles the whole Qt6 stack (QtWebEngine alone is ~195 MB) while winpodx uses only QtCore/QtGui/QtWidgets/QtSvg/QtDBus — so the unused Qt6 modules are stripped too (`packaging/appimage/slim-pyside6.sh`), bringing the AppImage to **~110 MB**.
|
||||
|
||||
> **FreeRDP client source.** The FreeRDP client source is selectable, and auto-discovery prefers the Flatpak client (`com.freerdp.FreeRDP`) with the native client (`xfreerdp` / `wlfreerdp` / `sdl-freerdp` on `PATH`) as a fallback (#366 / #393).
|
||||
|
||||
Host-side requirements:
|
||||
|
||||
- A container runtime installed via your distro's package manager: **`podman ≥ 4` recommended**, `docker` also supported (the manual backend uses an existing RDP host instead). Use the same `install.sh` (RPM / DEB / AUR) one-liner installs above if you don't have one yet.
|
||||
- `/dev/kvm` exposed by the host kernel (most distros do this by default once VT-x / AMD-V is enabled in BIOS).
|
||||
- The current user belongs to the `kvm` group (and `/etc/subuid` + `/etc/subgid` entries exist for rootless Podman — usually preconfigured on modern distros; check with `cat /etc/subuid`).
|
||||
|
||||
The `setup-host` wizard still ships in the AppImage for the kvm-group / subuid step (one polkit prompt instead of a manual sudo dance):
|
||||
|
||||
```bash
|
||||
# 1. Download the AppImage from the latest GitHub release
|
||||
# -> winpodx-x86_64.AppImage
|
||||
|
||||
# 2. Make it executable
|
||||
chmod +x winpodx-x86_64.AppImage
|
||||
|
||||
# 3. (Optional) First-run host setup — kvm group, subuid/subgid, kvm module
|
||||
./winpodx-x86_64.AppImage setup-host # detect + interactive prompt
|
||||
./winpodx-x86_64.AppImage setup-host --apply # apply without prompt
|
||||
./winpodx-x86_64.AppImage setup-host --status # detect only, no mutation
|
||||
|
||||
# 4. Log out + back in so the new kvm group membership takes effect.
|
||||
|
||||
# 5. Standard winpodx setup (auto-detect cores / RAM / timezone)
|
||||
./winpodx-x86_64.AppImage setup
|
||||
|
||||
# 6. Launch the desktop (or any installed Windows app by name)
|
||||
./winpodx-x86_64.AppImage app run desktop
|
||||
```
|
||||
|
||||
|
||||
Best fit for:
|
||||
|
||||
- **Immutable distros** (Fedora Silverblue / Kinoite, openSUSE Aeon, Steam Deck) once a host podman is layered in.
|
||||
- **Locked-down environments** where users can't run `curl ... | bash` but can run a single downloaded executable.
|
||||
- **Quick try-it-on-a-friend's-laptop** when a host podman / docker is already installed.
|
||||
|
||||
Not the best fit if you prefer:
|
||||
|
||||
- Native package-manager integration (use the RPM / DEB / AUR paths above instead).
|
||||
- Auto-update from the system update cycle (the AppImage is downloaded + replaced manually unless you wire it into AppImageUpdate yourself).
|
||||
- A truly zero-host-prep run — the Thin AppImage needs a host container runtime; if you want the installer to install one for you, use `install.sh`.
|
||||
|
||||
Recipe and CI live under `packaging/appimage/` — see `packaging/appimage/README.md` for local-build instructions if you want to roll your own. A regression test (`tests/test_appimage_recipe.py`) fails CI the moment the recipe re-introduces any of `podman` / `podman-compose` / `conmon` / `crun` / `netavark` / `aardvark-dns` / `passt` / `pasta` / `slirp4netns` / `fuse-overlayfs`.
|
||||
|
||||
## Nix
|
||||
|
||||
A flake is provided for NixOS / nix-on-any-distro users:
|
||||
|
||||
```bash
|
||||
# Run directly without installing
|
||||
nix run github:kernalix7/winpodx
|
||||
|
||||
# Install into your profile
|
||||
nix profile install github:kernalix7/winpodx
|
||||
|
||||
# As a flake input
|
||||
inputs.winpodx.url = "github:kernalix7/winpodx";
|
||||
```
|
||||
|
||||
The wrapper bundles FreeRDP, podman / podman-compose, iproute2 and libnotify, so the default Podman backend works out of the box. The Docker backend still requires Docker to be present on the host; the manual backend connects to an RDP host you provide.
|
||||
|
||||
## From source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kernalix7/winpodx.git
|
||||
cd winpodx
|
||||
./install.sh
|
||||
```
|
||||
|
||||
The source installer automatically:
|
||||
1. Detects your distro (openSUSE, Fedora, Ubuntu, Arch, ...)
|
||||
2. Installs missing dependencies (Podman, FreeRDP, KVM), asks before installing
|
||||
3. Copies winpodx to `~/.local/bin/winpodx-app/`
|
||||
4. Creates config and `compose.yaml`
|
||||
5. Auto-discovery (`winpodx app refresh`) fires on first pod boot to populate the menu
|
||||
|
||||
### Manual run (no install)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kernalix7/winpodx.git
|
||||
cd winpodx
|
||||
export PYTHONPATH="$PWD/src"
|
||||
python3 -m winpodx app run word
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
One canonical script -- same behavior regardless of how WinPodX was installed (curl / pip / deb / rpm / aur).
|
||||
|
||||
Two equivalent entry points:
|
||||
|
||||
```bash
|
||||
# Stop everything, keep Windows container + config (apt-remove semantics)
|
||||
./uninstall.sh
|
||||
# OR
|
||||
winpodx uninstall
|
||||
|
||||
# Full wipe: container, volume, ~50 GB Windows disk, config, launcher (apt-purge semantics)
|
||||
./uninstall.sh --purge --yes
|
||||
# OR
|
||||
winpodx uninstall --purge --yes
|
||||
```
|
||||
|
||||
`winpodx uninstall` is a thin wrapper that locates and execs the same `uninstall.sh` shipped at one of: `/usr/share/winpodx/uninstall.sh` (deb/rpm/aur), `~/.local/bin/winpodx-app/uninstall.sh` (curl), or the wheel's shared-data dir (pip).
|
||||
|
||||
`--yes` or `--purge` is required when piping from curl (interactive prompts can't read from a terminal while bash consumes stdin):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --yes
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --purge
|
||||
```
|
||||
|
||||
If installed via a package manager (apt/dnf/zypper/pacman), `uninstall.sh` detects this and prompts you to run the package-manager removal first -- that's the correct order: the package manager's post-remove hook re-runs `uninstall.sh` for the user-side cleanup, keeping the package database in sync with disk state.
|
||||
|
||||
**Default mode never touches** (use `--purge` to also remove these):
|
||||
- Podman container / volumes (Windows VM disk, ~50 GB)
|
||||
- `~/.config/winpodx/` config + compose.yaml
|
||||
- Storage bind-mount contents
|
||||
|
||||
**Never removed by either mode:**
|
||||
- System packages (podman, freerdp, python3) -- the package-manager removal prompt handles those
|
||||
- Other files in your home directory
|
||||
@@ -0,0 +1,296 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="images/CI.svg" alt="WinPodX" width="320">
|
||||
|
||||
### 앱 클릭하면 Word 가 뜬다. 끝.
|
||||
|
||||
<p>Windows 앱마다 네이티브 Linux 윈도 — 진짜 아이콘, 진짜 <code>WM_CLASS</code>,<br>
|
||||
태스크바 핀 가능. FreeRDP RemoteApp + dockur/windows. Zero config.</p>
|
||||
|
||||
<pre><code># 최신 안정 release (기본)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
|
||||
# 최신 main HEAD (개발용, 불안정할 수 있음)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --main
|
||||
|
||||
# 언인스톨 (Windows VM 데이터 유지; 전부 삭제는 --purge)
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --confirm</code></pre>
|
||||
|
||||
<a href="images/demo.png">
|
||||
<img src="images/demo.png" alt="WinPodX 실행 모습 — KDE 데스크톱 위에서 Windows 앱이 각각 네이티브 Linux 창으로" width="720">
|
||||
</a>
|
||||
|
||||
<sub>Windows 정보 / 작업 관리자 / PowerShell 이 각각 Linux 창으로, WinPodX Dashboard (라이브 Pod / RAM / CPU 게이지, 워크스페이스 타일) 와 나란히.</sub>
|
||||
|
||||
[](#상태-베타)
|
||||
[](https://github.com/kernalix7/winpodx/releases)
|
||||
|
||||
[](../LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[](#테스트)
|
||||
[](https://github.com/kernalix7/winpodx/actions/workflows/ci.yml)
|
||||
[](https://github.com/kernalix7/winpodx/stargazers)
|
||||
[](https://github.com/kernalix7/winpodx/releases)
|
||||
|
||||
###### Works on
|
||||
|
||||
[](https://www.opensuse.org/)
|
||||
[](https://fedoraproject.org/)
|
||||
[](https://fedoraproject.org/atomic-desktops/)
|
||||
[](https://www.debian.org/)
|
||||
[](https://ubuntu.com/)
|
||||
[](https://www.redhat.com/)
|
||||
[](https://archlinux.org/)
|
||||
[](INSTALL.ko.md#nix)
|
||||
[](INSTALL.ko.md)
|
||||
|
||||
<sub>[English](../README.md) · **한국어** · [설치](INSTALL.ko.md) · [사용법](USAGE.ko.md) · [기능](FEATURES.ko.md) · [아키텍처](ARCHITECTURE.ko.md) · [비교](COMPARISON.ko.md)</sub>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> ### 상태: 베타
|
||||
> WinPodX 는 활발히 개발 중입니다 (**v0.9.0**). **v0.7.0** 은 **베어메탈 위장** (#246, opt-in / 기본 꺼짐) 을 도입 — `pod.disguise_level balanced | max` 로 Windows 게스트가 VM 탐지 소프트웨어 (Nvidia GPU 패스스루 "code 43", launch-gate VM 체크, VM 비적합 설치 프로그램) 에게 물리 머신처럼 보임 (al-khaser 0.82 검증 완료), 기본 게스트 사용자명은 `WPX-User` 로 변경. **v0.7.1** 은 UX + 통합 릴리즈: 탐지된 Windows 앱이 **자동 파일 연결** 로 파일 관리자의 "다른 프로그램으로 열기" 메뉴에 표시 (#545, 기본 활성화, *추가* 만 — 기본 핸들러로 설정 안 함); GUI 에 **앱 관리** 추가 — 탐지값 재설정, 커스텀 아이콘 선택기, 다중 선택 일괄 숨기기/삭제, 삭제 앱 복원 목록 (#530); **빠른 앱 런처** (`winpodx launch`, #561) 가 DE 핫키에 바인딩 가능한 시작 메뉴 스타일 선택기 제공; **`winpodx gui` 가 더 이상 터미널을 막지 않음** (#549); `winpodx doctor` 가 RemoteApp 창이 깨지는 **구버전 FreeRDP 를 경고** (#546); `install.sh --main` 이 **Atomic Fedora** 에서 존중됨 (#548). **v0.7.2** 는 버그 수정 릴리즈: KDE/Plasma 에서 *Refresh Apps* GUI 크래시(#567) 와 트레이 *Terminate Session* / *USB Devices* 서브메뉴(#573) 수정, 중국어/일본어/한국어 이름 앱 디스커버리(#553), `pod stop` 시 컨테이너 보존(업데이트 시 재생성 없음), 자격증명 누락 시 명확한 에러(#569). **v0.7.3** 은 **Windows VM 자체의 파일을 reverse-open** — 공유 Home뿐 아니라 게스트 `C:` 를 SMB 공유 + kio-fuse 마운트해 호스트 앱이 실제 게스트 파일을 열고 편집이 원본에 저장됨 (#616, KDE) —, VM RAM을 해제하는 opt-in **idle 자동 정지** (#622), 터치스크린 / 스타일러스 / 펜 패스스루용 **`+multitouch`** 플래그 (#623) 추가; 또한 느린 게스트에서 `winpodx app refresh` 타임아웃(#619), 대시보드 RAM / Disk 게이지가 "n/a"에 멈추는 문제(#634) 수정, 설치를 불안정하게 만들던 USB 드라이브 문자 자동매핑 제거(#613, #638). **v0.7.4** 는 **Windows VM 저장 위치 선택**(`install.sh --storage-dir`, #646)과 **로컬 Windows ISO 설치**로 다운로드 생략(`--win-iso`, #647), 24GB+ 호스트의 **VM RAM 기본 8GB**(#630)를 추가; 그리고 수정 — 실행 중 앱에서 두 번째 문서 열기, Deepin 등 PATH가 잘리는 데스크탑의 *연결 프로그램*, RDP 비번 누락 시 명확한 에러(#569), GUI Logs 버튼의 Docker 백엔드 준수, `.deb`의 `podman-compose` 포함(#644), 유지보수 다이얼로그 크기(#550). **v0.8.0** 는 앱 검출을 **기본 시작메뉴 전용**으로 — Windows 시작메뉴에 실제로 뜨는 앱만, 중첩 폴더 하위그룹으로 그룹화, 옛 전체스캔은 `desktop.full_app_scan` opt-in (#581); `[pod] keyboard` 를 FreeRDP 세션 레이아웃으로 전달(#660); `--win-iso` 를 compose-up *전*에 스테이징해 다운로드 대신 그 ISO로 설치(#647); reverse-open 리스너 자가복구; 번들 rdprrap **0.3.0** 로 bump; GUI 크래시 다수 수정 — *Refresh Apps* SIGSEGV, off-thread 배율 SIGABRT, Debloat 작업창 자동닫힘 안 됨(#550), 길거나 CJK 이름서 레이아웃 재귀 SIGSEGV(#553). **v0.9.0** 는 Windows 앱이 Linux의 **URL-scheme 링크**를 처리하게 합니다 — `mailto:` 링크를 클릭하면 Outlook이 열리고, `slack:` / `vnc:` 같은 앱 스킴이 알맞은 Windows 앱으로 라우팅됩니다(디스커버리 중 자동 수확 → `x-scheme-handler` 등록, #421, #694). 또한 **신뢰 경계를 강화** — 발견된 게스트 앱이 호스트의 `http`/`https` 기본 핸들러를 조용히 탈취할 수 없고(명시적 opt-in만), 세션 창-reaper가 방금 재실행된 앱을 오살하거나 일시적 스캔 실패에 세션을 몰살하지 않습니다(#680 후속); 그리고 multiarch/KF6 레이아웃의 kio-fuse 검출(#697), 트레이와 함께 자동 시작되는 reverse-open 리스너(#691), 절전/복구 D-Bus 구독(#690), `git` 없을 때 자동 설치하는 설치 원라이너(#705) 수정. AppImage 는 **Thin** (~110 MB) — FreeRDP + Python + Qt + WinPodX 만 — 컨테이너 런타임은 호스트의 `podman` / `docker` 사용. 0.6.0 에서 정착된 CLI 표면 유지 — **`winpodx guest`** (게스트 작업), **`winpodx install`** (설치/디스크 작업), **`winpodx doctor`** (`--json` / `--quick` / `--fix` 진단); post-create 체인은 단일 **`winpodx provision`**. 첫 설치는 여전히 ~5–10분 소요 (Windows VM ISO 다운로드 + Sysprep + OEM apply); 진행 상황은 `winpodx pod wait-ready --logs` 로 확인. 문제 발생 시 <https://github.com/kernalix7/winpodx/issues> 에 이슈 등록해주세요.
|
||||
|
||||
**Full-screen RDP 아님.** Windows 앱이 각각 네이티브 Linux 윈도 — 진짜 아이콘, pin 가능, alt-tab, 파일 연결 양방향. 진짜 Windows 데스크톱 필요할 때만 `winpodx app run desktop`.
|
||||
|
||||
WinPodX 는 백그라운드에서 Windows 컨테이너 ([dockur/windows](https://github.com/dockur/windows)) 를 실행하고, FreeRDP RemoteApp 으로 Windows 앱을 네이티브 Linux 앱처럼 표시합니다. 게스트 안의 bearer-auth HTTP agent 가 host→guest 명령 채널을 처리해서 PowerShell 창이 깜빡이지 않음. 반대 방향 — Linux 앱이 Windows "Open with…" 메뉴에 노출 — 은 호스트 측 listener 가 게스트 내 슬러그별 Rust shim 이 작성한 JSON 요청을 소비하는 식으로 처리. **외부 Python 의존성 거의 없음** (Python 3.11+ 는 표준 라이브러리만; 3.9/3.10 은 순수 파이썬 `tomli` 폴백 1개).
|
||||
|
||||
## 최소 요구사항
|
||||
|
||||
**설치 전에** 머신이 가상화를 실제로 지원하는지 확인. WinPodX 는 KVM 기반 컨테이너에서 Windows 실행 — 아래 셋 없으면 설치는 끝까지 진행되지만 Windows 가 절대 부팅 안 됨.
|
||||
|
||||
| 요구사항 | 확인 명령 | 해결 |
|
||||
|---|---|---|
|
||||
| **BIOS / UEFI 에 Intel VT-x 또는 AMD-V 활성** | `lscpu \| grep -i virtualization` 에 `VT-x` 또는 `AMD-V` 표시 | 재부팅 → 펌웨어 설정 → "Intel Virtualization Technology" / "SVM Mode" / "VT-x" 활성. 노트북에서 기본 OFF 인 경우 많음. |
|
||||
| **kvm 커널 모듈 로드** | `lsmod \| grep kvm` 에 `kvm_intel` 또는 `kvm_amd` | `sudo modprobe kvm_intel` (Intel) 또는 `sudo modprobe kvm_amd` (AMD). BIOS 활성 후 자동 로드. |
|
||||
| **사용자가 `kvm` 그룹에 속함** | `id -nG \| tr ' ' '\n' \| grep kvm` 가 `kvm` 반환 | `sudo usermod -aG kvm $USER` 후 로그아웃 → 재로그인. |
|
||||
|
||||
하드웨어: 가상화 확장 지원 x86_64 또는 aarch64 CPU, 8GB+ RAM (12GB+ 권장), Windows 이미지용 디스크 여유 ~30GB. `install.sh` 가 패키지 설치 단계 후 `/dev/kvm` 없으면 같은 진단으로 중단. "설치는 잘 됐는데 Windows 가 안 떠요" 류 버그 리포트 대부분이 위 세 줄 중 하나가 원인.
|
||||
|
||||
## 빠른 설치
|
||||
|
||||
원라인 (지원되는 모든 Linux distro):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
|
||||
```
|
||||
|
||||
또는 네이티브 패키지 매니저:
|
||||
|
||||
```bash
|
||||
# openSUSE Tumbleweed / Leap / Slowroll
|
||||
sudo zypper addrepo https://download.opensuse.org/repositories/home:/Kernalix7/openSUSE_Tumbleweed/home:Kernalix7.repo
|
||||
sudo zypper install winpodx
|
||||
|
||||
# Fedora 42 / 43 / 44 (dnf5 — Fedora 41+)
|
||||
sudo dnf config-manager addrepo --from-repofile=https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_43/home:Kernalix7.repo
|
||||
sudo dnf install winpodx
|
||||
|
||||
# Debian / Ubuntu — 최신 release 에서 맞는 .deb 다운로드 후
|
||||
sudo apt install ./winpodx_<version>_all_debian13.deb
|
||||
|
||||
# AlmaLinux / Rocky / RHEL 9 / 10 — 최신 release 에서 맞는 .rpm
|
||||
sudo dnf install ./winpodx-<version>-0.noarch.el10.rpm
|
||||
|
||||
# Arch
|
||||
yay -S winpodx
|
||||
|
||||
# Nix
|
||||
nix run github:kernalix7/winpodx
|
||||
|
||||
# AppImage (distro-agnostic, single file)
|
||||
# 최신 GitHub release 에서 winpodx-<version>-x86_64.AppImage 다운로드
|
||||
chmod +x winpodx-*-x86_64.AppImage
|
||||
./winpodx-*-x86_64.AppImage setup
|
||||
```
|
||||
|
||||
> **패키지 매니저 / AppImage 설치 후:** `winpodx setup` 한번 실행 → `~/.config/winpodx/winpodx.toml` + compose.yaml 생성. curl 원라이너는 이 단계를 자동으로 해주고 Windows 첫 부팅까지 ~5–10분 대기; 패키지 설치는 바이너리만 ship — `apt install` / `dnf install` / `yay -S` / 첫 AppImage 실행이 갑자기 10분짜리 Windows ISO 다운로드 트리거하지 않게. setup 후엔 그냥 앱 실행 (`winpodx app run desktop`) 만 해도 첫 실행시 pod 자동 provision.
|
||||
>
|
||||
> Thin AppImage (0.6.0) 는 Python + Qt + WinPodX + FreeRDP 만 번들 — 컨테이너 런타임은 호스트 (`podman` ≥ 4 권장, `docker` 도 지원) 에 두어서 이미 있는 호스트 stack 과 충돌하지 않음 (#357, #363). 0.6.0 이전 fat AppImage 는 podman stack 전체를 번들하고 호스트 것을 가렸음. 남은 호스트 측 요건: 패키지 매니저로 설치한 컨테이너 런타임, `/dev/kvm`, `kvm` 그룹 멤버십, rootless Podman 용 `/etc/subuid` / `/etc/subgid`. `winpodx setup-host` 가 kvm / subuid 부분을 `pkexec` 한 번으로 처리; `winpodx doctor` 가 그래도 부족한 걸 안내.
|
||||
|
||||
오프라인 / 에어갭 빌드, 소스 설치, 버전 pin, 언인스톨은 [docs/INSTALL.ko.md](INSTALL.ko.md) 참조.
|
||||
|
||||
## 첫 실행 설정
|
||||
|
||||
`curl install.sh` 원라이너 썼으면 setup 이 이미 돌았고 Windows VM 부팅 중 — [실행](#실행) 으로 skip. 그 외 install 경로 (패키지 매니저, AppImage, source, pip) 는 첫 앱 실행 전에 setup 한번 돌려야 함:
|
||||
|
||||
```bash
|
||||
# 자동 setup — 호스트 자동감지 기본값, 프롬프트 없음
|
||||
winpodx setup
|
||||
|
||||
# 대화형 wizard — backend, cores, RAM, edition, language, timezone, debloat preset 선택
|
||||
winpodx setup --customize
|
||||
```
|
||||
|
||||
Setup 이 `~/.config/winpodx/winpodx.toml` + `compose.yaml` 작성, GUI 런처 등록, 호스트의 FreeRDP + Podman / Docker + KVM 확인. 부족하면 출력 마지막에 distro 별 설치 명령 (예: Debian / Ubuntu 면 `sudo apt install xfreerdp3 podman podman-compose`, Fedora 면 `sudo dnf install ...`) — 실행 후 `winpodx setup` 재실행.
|
||||
|
||||
첫 앱 실행이 pod provision, dockur 이미지 pull, Windows ISO 다운로드 + Sysprep + OEM apply 거쳐 사용 가능한 RDP 세션까지 ~5-10분. `winpodx pod wait-ready --logs` 가 컨테이너 진행 라이브 출력:
|
||||
|
||||
```bash
|
||||
winpodx app run desktop # 첫 실행 ~5-10분, 이후 실행은 거의 즉시
|
||||
winpodx pod wait-ready --logs # 선택: 첫 부팅 진행상황 라이브 보기
|
||||
```
|
||||
|
||||
이후 언제든 `winpodx doctor` 로 호스트 상태 재확인 + drift 시 다음 fix 명령 surface:
|
||||
|
||||
```bash
|
||||
winpodx doctor # read-only — 필요한 fix 만 출력
|
||||
winpodx guest apply-fixes # guest 측 런타임 fix 재적용 (RDP timeout, NIC power-save 등)
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
winpodx app run word # Word 실행
|
||||
winpodx app run word ~/doc.docx # 파일 열기
|
||||
winpodx app run desktop # 전체 Windows 데스크톱
|
||||
```
|
||||
|
||||
또는 그냥 애플리케이션 메뉴에서 앱 아이콘 클릭. 전체 CLI, Qt6 GUI, 헬스 체크, 설정은 [docs/USAGE.ko.md](USAGE.ko.md) 참조.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
<table>
|
||||
<tr><td width="50%">
|
||||
|
||||
**Reverse-open**
|
||||
- Linux 앱이 Windows 게스트 우클릭 "Open with…" 메뉴에 기본 노출
|
||||
- 짧은 메뉴 + 긴 "다른 앱 선택" 다이얼로그 양쪽에 앱별 정확한 아이콘
|
||||
- 선택 시 호스트 `xdg-open` 으로 파일 열기 round-trip
|
||||
- 호스트 측 Linux 앱 + MIME 연결을 freedesktop 표준에서 자동 발견
|
||||
- `winpodx host-open` CLI 또는 GUI Settings 패널로 관리
|
||||
- [상세 →](FEATURES.ko.md#reverse-open-linux-앱이-windows-open-with-에)
|
||||
|
||||
</td><td width="50%">
|
||||
|
||||
**매끄러운 앱 윈도**
|
||||
- RemoteApp (RAIL) 이 각 Windows 앱을 네이티브 Linux 윈도로 렌더링 — 전체 데스크톱 아님
|
||||
- `WM_CLASS` 매칭 통한 앱별 taskbar 아이콘 (`/wm-class:<stem>` + `StartupWMClass`)
|
||||
- 양방향 파일 연결: Linux 파일 관리자에서 `.docx` 더블클릭 → Word 가 열림
|
||||
- 멀티세션 RDP: bundled [rdprrap](https://github.com/kernalix7/rdprrap) 이 최대 10개 독립 세션 자동 활성화
|
||||
- 멀티모니터 RAIL (0.6.0): remote-app 윈도를 두 번째 모니터로 끌어도 입력이 계속 동작 — 기본 활성 (`cfg.rdp.multimon`, 기본 `span`)
|
||||
- RAIL 전제조건이 unattended 설치 중 자동 설정
|
||||
|
||||
</td></tr>
|
||||
<tr><td width="50%">
|
||||
|
||||
**제로 설정 실행**
|
||||
- 첫 앱 클릭이 모든 것 자동 프로비저닝: config, 컨테이너, desktop 엔트리
|
||||
- 첫 부팅 시 자동 discovery 가 실행 중인 Windows 게스트 스캔, 설치된 모든 앱 (Registry App Paths, Start Menu, UWP/MSIX, Chocolatey, Scoop) 을 실제 아이콘과 함께 등록
|
||||
- `winpodx app refresh` 또는 GUI Refresh 버튼으로 언제든 수동 재스캔
|
||||
- 멀티 백엔드: Podman (기본), Docker, manual RDP (libvirt 백엔드는 0.6.0 에서 제거 — 자체 libvirt 도메인을 쓰려면 ≤0.5.x 유지 또는 manual 백엔드 사용)
|
||||
|
||||
</td><td width="50%">
|
||||
|
||||
**주변기기 & 공유**
|
||||
- **클립보드**: 양방향 복사-붙여넣기 (텍스트 + 이미지) — 기본 활성
|
||||
- **사운드**: RDP 오디오 스트리밍 (`/sound:sys:alsa`) — 기본 활성
|
||||
- **프린터**: Linux 프린터가 Windows 에 공유 — 기본 활성
|
||||
- **홈 디렉토리**: `\\tsclient\home` 으로 공유
|
||||
- **USB 드라이브**: FileSystemWatcher 통한 드라이브 레터 (E:, F:, …) 자동 매핑; 세션 시작 후 꽂은 USB 도 서브폴더로 접근 가능; USB 데스크톱 바로가기 (`\\tsclient\media`) 가 항상 열림 — 마운트된 미디어 없으면 에러 대신 빈 폴더로 열림
|
||||
- **호스트 USB / PCI 디바이스 패스스루** (0.6.0): 실제 호스트 디바이스를 Windows 게스트로 전달 — `winpodx device list / attach <id> / detach <id>`, GUI "Devices" 탭 (호스트↔게스트 2열 mover), 시스템 트레이 USB 스위처. USB 는 라이브 핫플러그 (`cfg.pod.usb_live`, 기본 활성); PCI 는 boot-add 라 게스트 재시작 + `--force` / 다이얼로그 확인 필요
|
||||
|
||||
</td></tr>
|
||||
<tr><td width="50%">
|
||||
|
||||
**자동화 & 보안**
|
||||
- 자동 suspend / resume: idle 시 컨테이너 pause, 다음 실행 시 resume
|
||||
- 로그인 시 pod 자동 시작 (v0.5.9, opt-in): `winpodx autostart on` 이 트레이 autostart 엔트리 설치 → 로그인 시 pod 시작/resume. 기본 off (`autostart off|status`, 또는 GUI Settings 체크박스)
|
||||
- UNRESPONSIVE → 자동 복구 (v0.5.5): stalled RDP 게스트 감지 후 in-guest TermService 사이클로 self-heal — `pod restart` 불필요
|
||||
- 호스트 적응형 Windows-on-KVM 튜닝 프로파일 (v0.5.5): `+invtsc`, `platform_tick` 등 호스트 capability gating — `tuning_profile = auto|safe|off`
|
||||
- 비밀번호 자동 회전: 20자 암호학적 비밀번호, 7일 주기, atomic rollback
|
||||
- 스마트 DPI 스케일링: GNOME, KDE, Sway, Hyprland, Cinnamon, xrdb 자동 감지
|
||||
- Windows debloat: 텔레메트리, 광고, Cortana, 검색 인덱싱 기본 비활성
|
||||
- FreeRDP `extra_flags` allowlist (regex 검증) 가 사용자 input 안전 경계
|
||||
- 시간 동기화: 호스트 sleep/wake 후 Windows 시계 강제 resync
|
||||
- **베어메탈 위장** (0.7.0, opt-in): `pod.disguise_level balanced|max` 로 Windows 게스트가 VM 비적합 소프트웨어에게 물리 머신처럼 보임 (Nvidia GPU code 43, launch-gate 체크, VM 비적합 설치 프로그램) — balanced 는 CPUID/KVM 시그니처 은폐 + 호스트 SMBIOS 미러링; max 는 로컬 빌드 패치된 QEMU 이미지 추가 (`winpodx disguise build-image`); al-khaser 검증 완료; 기본 꺼짐
|
||||
|
||||
</td><td width="50%">
|
||||
|
||||
**운영 & 회복력**
|
||||
- 다국어 UI (v0.5.9): 트레이 / GUI / CLI 가 7개 언어로 완전 번역 (en / ko / zh / ja / de / fr / it), `$LANG` 에서 자동 감지 — `winpodx language <code>` 또는 GUI Settings → "WinPodX UI language" 로 변경
|
||||
- Windows 디스크 자동 확장 (v0.5.9): C: 가 idle 중 임계치 넘게 차면 호스트 여유 공간 한도 내에서 스스로 확장 — 수동은 `winpodx install grow-disk [SIZE]`, `winpodx install disk-usage`, GUI Tools → Grow Disk
|
||||
- Guest sync (v0.5.9): 호스트 업그레이드 후 갱신된 agent / urlacl / rdprrap / fixes 를 실행 중인 게스트에 push — pod 시작 시 1회 자동, 또는 `winpodx guest sync [--force]`
|
||||
- 오프라인 / 에어갭 설치 (`--source` + `--image-tar`)
|
||||
- 원라인 언인스톨 (Windows VM 데이터 유지; `--purge` 로 전부 삭제)
|
||||
- `winpodx doctor` 통한 헬스 체크 (deps / pod / RDP / agent / disk / round-trip / 비밀번호 age; `--json` 머신리더블, `--quick` 가벼운 서브셋, `--fix` 흔한 finding 의 idempotent 자동 복구)
|
||||
- 재설계된 Qt6 GUI (0.6.0): 좌측 Start-menu 스타일 네비게이션 사이드바 + 새로운 **Dashboard** 홈 (라이브 Pod / RAM / CPU 링 게이지, 디스크 사용량, 자동 복구 상태 카드, pinned/recent 워크스페이스 타일, reverse-open 토글); 앱 런처는 이제 "All apps" 페이지가 되어 Devices / Settings / Tools / Terminal / Info 와 나란히 — 가벼운 시스템 트레이도 별도. 자체 SVG 아이콘 세트, 반응형 reflow, 커맨드 바를 겸하는 hero search
|
||||
- stdlib 지향 Python (3.11+ 는 pip-deps 없음; 3.9 / 3.10 은 `tomli` 폴백 1개)
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
[docs/FEATURES.ko.md](FEATURES.ko.md) 에서 멀티세션 RDP 내부, 앱 프로필 스키마, reverse-open 아키텍처 등 자세한 deep dive 확인.
|
||||
|
||||
## 문서
|
||||
|
||||
| 문서 | 내용 |
|
||||
|----------|---------------|
|
||||
| [INSTALL.ko.md](INSTALL.ko.md) | 모든 설치 경로 — 원라인, 패키지 매니저, AppImage, 오프라인, Nix, 소스 |
|
||||
| [USAGE.ko.md](USAGE.ko.md) | CLI 레퍼런스, Qt6 GUI 투어, 헬스 체크, 설정 파일 |
|
||||
| [FEATURES.ko.md](FEATURES.ko.md) | Reverse-open, 멀티세션 RDP, 주변기기, 앱 프로필, 자동 discovery |
|
||||
| [ARCHITECTURE.ko.md](ARCHITECTURE.ko.md) | 동작 방식 (다이어그램), 기술 스택, 소스 트리, 데이터 흐름 |
|
||||
| [COMPARISON.ko.md](COMPARISON.ko.md) | WinPodX vs winapps / LinOffice / winboat, 그리고 WinPodX vs Wine |
|
||||
| [CHANGELOG.ko.md](CHANGELOG.ko.md) | 전체 버전 이력 |
|
||||
| [CONTRIBUTING.ko.md](CONTRIBUTING.ko.md) | 개발 셋업 + 워크플로우 |
|
||||
| [SECURITY.ko.md](SECURITY.ko.md) | 보안 공개 프로세스 |
|
||||
|
||||
## 지원 distro
|
||||
|
||||
| Distro | 패키지 매니저 | 상태 |
|
||||
|--------|-----------------|--------|
|
||||
| openSUSE Tumbleweed / Leap 15.6 / Leap 16.0 / Slowroll | zypper | Tested |
|
||||
| Fedora 42 / 43 / 44 / Rawhide | dnf | Supported |
|
||||
| Fedora Silverblue / Kinoite / Sericea / Bluefin / Bazzite (42 / 43 / 44) | rpm-ostree (OBS, `--apply-live`) | Supported |
|
||||
| Debian 12 / 13, Ubuntu 24.04 / 25.04 / 25.10 / 26.04 | apt | Supported |
|
||||
| AlmaLinux / Rocky / RHEL 9 / 10 | dnf | Supported |
|
||||
| Arch / Manjaro | pacman + `yay -S winpodx` | Supported |
|
||||
| NixOS (와 모든 distro 위의 Nix) | nix flake | Supported |
|
||||
|
||||
각 태그 푸시 (`v*.*.*`) 마다 모든 채널에 자동 publish — 메인테이너 상세는 [packaging/](../packaging/) 참조.
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# 리포 루트에서 (설치 불필요)
|
||||
export PYTHONPATH="$PWD/src"
|
||||
python3 -m pytest tests/ # 1800+ 테스트
|
||||
ruff check src/ tests/ # 린트
|
||||
ruff format --check src/ tests/
|
||||
```
|
||||
|
||||
## 기여
|
||||
|
||||
개발 셋업, 브랜치 명명, 커밋 컨벤션, CI 기대치는 [CONTRIBUTING.ko.md](CONTRIBUTING.ko.md) 참조.
|
||||
|
||||
## 보안
|
||||
|
||||
보안 이슈는 [SECURITY.ko.md](SECURITY.ko.md) 의 프로세스 따르기.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#kernalix7/winpodx&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kernalix7/winpodx&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kernalix7/winpodx&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kernalix7/winpodx&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 후원 / Support
|
||||
|
||||
WinPodX 가 Linux 데스크톱을 조금이라도 더 좋게 만들었다면:
|
||||
|
||||
[](https://github.com/sponsors/kernalix7)
|
||||
[](https://ko-fi.com/kernalix7)
|
||||
[](https://fairy.hada.io/@kernalix7)
|
||||
|
||||
GitHub Sponsors 는 정기 / 일시 후원; Ko-fi 는 해외 카드 / PayPal 결제; fairy.hada.io 는 국내 결제용.
|
||||
버그 리포트, PR, 별점도 환영합니다 —
|
||||
Bug reports, PRs, and stars on the repo are equally appreciated and free.
|
||||
|
||||
## 라이선스
|
||||
|
||||
[MIT](../LICENSE) — Kim DaeHyun (kernalix7@kodenet.io)
|
||||
@@ -0,0 +1,26 @@
|
||||
# docs/
|
||||
|
||||
User-facing documentation. Engineering / internal design notes live under [`design/`](design/).
|
||||
|
||||
Most documents have an English original and a Korean (`.ko.md`) mirror linked from the toggle at the top of each page. The repo-root [`README.md`](../README.md), [`CHANGELOG.md`](../CHANGELOG.md), [`CODE_OF_CONDUCT.md`](../CODE_OF_CONDUCT.md), [`CONTRIBUTING.md`](../CONTRIBUTING.md), [`SECURITY.md`](../SECURITY.md), and [`THIRD_PARTY_LICENSES.md`](../THIRD_PARTY_LICENSES.md) are the English canonical copies; the corresponding `.ko.md` files in this folder are their Korean mirrors.
|
||||
|
||||
## Index
|
||||
|
||||
| Topic | English | 한국어 |
|
||||
|---|---|---|
|
||||
| Quick install | [INSTALL.md](INSTALL.md) | [INSTALL.ko.md](INSTALL.ko.md) |
|
||||
| Day-to-day usage | [USAGE.md](USAGE.md) | [USAGE.ko.md](USAGE.ko.md) |
|
||||
| Feature list | [FEATURES.md](FEATURES.md) | [FEATURES.ko.md](FEATURES.ko.md) |
|
||||
| How it's put together | [ARCHITECTURE.md](ARCHITECTURE.md) | [ARCHITECTURE.ko.md](ARCHITECTURE.ko.md) |
|
||||
| vs. winapps, etc. | [COMPARISON.md](COMPARISON.md) | [COMPARISON.ko.md](COMPARISON.ko.md) |
|
||||
| README mirror | — | [README.ko.md](README.ko.md) |
|
||||
| Changelog mirror | — | [CHANGELOG.ko.md](CHANGELOG.ko.md) |
|
||||
| Code of conduct mirror | — | [CODE_OF_CONDUCT.ko.md](CODE_OF_CONDUCT.ko.md) |
|
||||
| Contributing mirror | — | [CONTRIBUTING.ko.md](CONTRIBUTING.ko.md) |
|
||||
| Security mirror | — | [SECURITY.ko.md](SECURITY.ko.md) |
|
||||
|
||||
Screenshots and diagrams referenced from the docs above live in [`images/`](images/).
|
||||
|
||||
## Engineering docs
|
||||
|
||||
For design specs, roadmaps, reference docs (lifecycle, transport ABC), and security reviews, see [`design/`](design/) and [`design/README.md`](design/README.md).
|
||||
@@ -0,0 +1,97 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
# 릴리즈 테스트 체크리스트
|
||||
|
||||
[English](RELEASE_TESTING.md) | **한국어**
|
||||
|
||||
자동 테스트가 못 보는 회귀를 릴리즈에 실어 보내지 않기 위한 반복 가능한 점검.
|
||||
winpodx 버그 대부분은 **게스트측** — Windows VM, FreeRDP/RAIL, OEM 스크립트,
|
||||
설치 흐름 — 인데 Linux CI 러너의 `pytest`로는 이걸 실행할 수 없습니다. 이
|
||||
체크리스트는 그 수동·실윈도우 부분을 기억에 의존하지 않고 명시화합니다.
|
||||
|
||||
> **핵심 규칙:** 게스트를 건드리는 변경(`config/oem/`, `scripts/windows/`,
|
||||
> reverse-open shim, `compose` 포트/QEMU args, 에이전트, install.bat, discovery,
|
||||
> RAIL 실행)은 **머지 전 실제 Windows 게스트로 스모크 필수** — `pytest`만으론
|
||||
> 부족. CI를 통과하고 실윈도우 스모크 없이 머지돼 깨진 릴리즈가 여러 번
|
||||
> 있었습니다(media_monitor #613/#638, 4445/`USER_PORTS` 포트 버그 #616).
|
||||
|
||||
## 언제 돌리나
|
||||
|
||||
- **태그 전** — 기능 체크리스트 해당 섹션 + 최소 1회 fresh 설치 스모크.
|
||||
- **게스트측 변경 후, 머지 전** — 건드린 표면의 게스트측 스모크 (CI가 못 잡는 걸 잡는 게이트).
|
||||
- **`install.sh` / `compose` / OEM 변경 후** — 깨끗한 머신의 설치 + 기존 머신 위 업그레이드 둘 다.
|
||||
|
||||
## 1. 자동 게이트 (CI — green 필수)
|
||||
|
||||
모든 PR에서 돌아감; floor이지 전부가 아님.
|
||||
|
||||
- [ ] `lint` — `ruff check` + `ruff format --check`
|
||||
- [ ] `test (3.9 … 3.14)` — 지원 Python 전체 `pytest tests/ -v`
|
||||
- [ ] `audit` — `pip-audit`
|
||||
- [ ] `discover-apps-ps` — PowerShell discovery 스크립트 문법
|
||||
- [ ] `verify_versions` — `pyproject.toml` ↔ `packaging/rpm/winpodx.spec` ↔ 설치 메타데이터 일치
|
||||
|
||||
로컬 pre-push (CI lint와 동일, 전체 트리 — per-file 아님):
|
||||
`ruff check src/ tests/ && ruff format --check src/ tests/ && pytest tests/ -q`
|
||||
|
||||
## 2. 게스트측 스모크 (실윈도우 — CI 불가)
|
||||
|
||||
실제 설치본에서. 각 단계 후 `winpodx doctor`가 빠른 헬스 게이트.
|
||||
|
||||
### 설치 / 업데이트
|
||||
- [ ] **Fresh 설치** 완주: `curl … install.sh | bash -s -- --main` → `Provisioning complete`
|
||||
(`[3/4]`/`[4/4]` hang 없음, `Invalid port` 없음, 에이전트 up).
|
||||
- [ ] **기존 위 업데이트** (`--main` 재실행): compose 재생성, 멈춘 pod recreate+기동,
|
||||
apply-fixes 실행(`guest_share: ok` 등) — `Skipping` 없음.
|
||||
- [ ] **`--ref <branch>`** 가 브랜치 **최신** 커밋 설치(`git -C ~/.local/bin/winpodx-app log -1`로
|
||||
실제 갱신 확인 — 재실행이 업데이트했다고 가정 금지).
|
||||
- [ ] `apply_fixes: N/N fixes OK`(현재 7) + `discovery: N apps` + `reverse_open: ok`.
|
||||
|
||||
### 앱 / RDP / RAIL
|
||||
- [ ] `winpodx app run desktop` — 풀 데스크탑 렌더.
|
||||
- [ ] `winpodx app run <app>` — RAIL 창 표시(자체 창, 작업표시줄 항목), 로그온/잠금화면 아님,
|
||||
`Invalid appWindow` 깨짐 없음.
|
||||
- [ ] `winpodx app refresh` — `/exec timed out` 없이 완료(느린/콜드 게스트 포함).
|
||||
- [ ] 다중 앱 창 / 멀티세션(rdprrap) 동작.
|
||||
|
||||
### Reverse-open (#616) — KDE 호스트
|
||||
- [ ] `\\tsclient\home` 아래 호스트 파일 → *연결 프로그램* Linux 앱 → 열림.
|
||||
- [ ] **게스트-로컬 파일**(Windows 데스크탑 `C:\Users\…`) → *연결 프로그램* Linux 앱 →
|
||||
호스트에서 열림, 편집 저장됨. (kio-fuse 필요; `winpodx doctor`의 `guest_mount`.)
|
||||
|
||||
### 대시보드 / GUI / 트레이
|
||||
- [ ] 대시보드 Pod / CPU / **RAM** / **Disk** 게이지 전부 숫자 표시(`n/a` 아님).
|
||||
- [ ] Settings → **UI Language** 인터페이스 전환; **Idle Action**(Pause/Stop) 존재.
|
||||
- [ ] 트레이 아이콘 표시; 서브메뉴(세션 / USB) KDE Plasma에서 열림.
|
||||
|
||||
### 전원 / idle / 장치 / 위장 / debloat
|
||||
- [ ] Idle **Pause**(기본) suspend + 실행 시 자동 resume.
|
||||
- [ ] Idle **Stop**(`pod.idle_action=stop`) pod 정지(RAM 해제); 다음 실행 콜드 부팅.
|
||||
- [ ] `winpodx device` USB attach/detach(live hot-plug).
|
||||
- [ ] 위장(`pod.disguise_level balanced|max`) 부팅 + RDP 렌더(#557 블랙스크린 없음).
|
||||
- [ ] `winpodx debloat` + undo가 활성화/업데이트 안 깨고 실행.
|
||||
- [ ] `winpodx rotate-password` 호스트 config ↔ 게스트 계정 동기 유지.
|
||||
|
||||
## 3. 플랫폼 / 채널 매트릭스
|
||||
|
||||
분기하는 표면을 spot-check; 매 릴리즈 전수는 불필요하되 주기적으로 돌아가며 커버.
|
||||
|
||||
| 축 | 커버 |
|
||||
|------|-------|
|
||||
| 설치 채널 | pip/curl · AppImage · AUR · RPM(Fedora/openSUSE/AlmaLinux) · `.deb`(Debian/Ubuntu) |
|
||||
| 데스크탑 | KDE Plasma · GNOME (주의: reverse-open 게스트-디스크는 KDE/kio-fuse 전용) |
|
||||
| 디스플레이 | Wayland(XWayland RAIL) · X11 |
|
||||
| 백엔드 | Podman(기본) · Docker |
|
||||
|
||||
## 4. 릴리즈 sign-off
|
||||
|
||||
- [ ] `pyproject.toml` + `packaging/rpm/winpodx.spec` + `debian/changelog` 버전 범프
|
||||
(`python scripts/ci/verify_versions.py` → consistent).
|
||||
- [ ] `CHANGELOG.md` **및** `docs/CHANGELOG.ko.md`: `[X.Y.Z] - <날짜>` +
|
||||
**### Contributors** 섹션(모든 외부 리포터/기여자 감사 — `gh issue view <N>` 작성자,
|
||||
maintainer 제외).
|
||||
- [ ] `README.md` + `docs/README.ko.md` "active development" 줄 + 요약 갱신.
|
||||
- [ ] 릴리즈 커밋 CI green.
|
||||
- [ ] **양쪽 태그** push: `vX.Y.Z`(publish 워크플로: OBS / RHEL / deb / AUR / AppImage)
|
||||
**및** `REL-vX.Y.Z`(Release 워크플로 → CHANGELOG 섹션에서 GitHub 릴리즈 본문 생성).
|
||||
- [ ] GitHub 릴리즈 게시 — **Contributors** 섹션 + 전체 자산(wheel, sdist, AppImage, RPM, deb).
|
||||
- [ ] 수정된 이슈에 "shipped in vX.Y.Z" 코멘트; 리포터 미해결 질문 없는 건 닫기.
|
||||
@@ -0,0 +1,105 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
# Release Testing Checklist
|
||||
|
||||
**English** | [한국어](RELEASE_TESTING.ko.md)
|
||||
|
||||
A repeatable pass so a release doesn't ship a regression that the automated
|
||||
tests can't see. Most winpodx breakage is **guest-side** — the Windows VM,
|
||||
FreeRDP/RAIL, the OEM scripts, the install flow — which `pytest` on a Linux CI
|
||||
runner cannot exercise. This checklist makes the manual, real-Windows part
|
||||
explicit instead of relying on memory.
|
||||
|
||||
> **The load-bearing rule:** any change that touches the guest (`config/oem/`,
|
||||
> `scripts/windows/`, the reverse-open shim, `compose` ports/QEMU args, the
|
||||
> agent, install.bat, discovery, RAIL launch) **must be smoke-tested against a
|
||||
> real Windows guest before merge** — not just `pytest`. Several releases broke
|
||||
> because a guest-side change passed CI and was merged without a real-Windows
|
||||
> smoke (media_monitor #613/#638, the 4445/`USER_PORTS` port bugs #616).
|
||||
|
||||
## When to run
|
||||
|
||||
- **Before tagging any release** — run the relevant sections of the feature
|
||||
checklist + at least one full fresh-install smoke.
|
||||
- **After any guest-side change, before merge** — the guest-side smoke for the
|
||||
surface you touched (this is the gate that catches what CI can't).
|
||||
- **After an `install.sh` / `compose` / OEM change** — the install + update
|
||||
smoke on a clean machine *and* an upgrade-over-existing machine.
|
||||
|
||||
## 1. Automated gates (CI — must be green)
|
||||
|
||||
These run on every PR; they are the floor, not the whole story.
|
||||
|
||||
- [ ] `lint` — `ruff check src/ tests/` + `ruff format --check src/ tests/`
|
||||
- [ ] `test (3.9 … 3.14)` — `pytest tests/ -v` on every supported Python
|
||||
- [ ] `audit` — `pip-audit`
|
||||
- [ ] `discover-apps-ps` — PowerShell discovery script syntax
|
||||
- [ ] `verify_versions` — `pyproject.toml` ↔ `packaging/rpm/winpodx.spec` ↔ installed metadata agree
|
||||
|
||||
Local pre-push mirror of CI lint (whole tree, not per-file):
|
||||
`ruff check src/ tests/ && ruff format --check src/ tests/ && pytest tests/ -q`
|
||||
|
||||
## 2. Guest-side smoke (real Windows — CI cannot do this)
|
||||
|
||||
Run on a real install. `winpodx doctor` after each step is a quick health gate.
|
||||
|
||||
### Install / update
|
||||
- [ ] **Fresh install** completes: `curl … install.sh | bash -s -- --main` → reaches
|
||||
`Provisioning complete` (no `[3/4]`/`[4/4]` hang, no `Invalid port`, agent up).
|
||||
- [ ] **Update over existing** (`--main` again): regenerates compose, recreates +
|
||||
starts a stopped pod, runs apply-fixes (`guest_share: ok`, etc.) — no `Skipping`.
|
||||
- [ ] **`--ref <branch>`** installs the branch's **latest** commit (verify
|
||||
`git -C ~/.local/bin/winpodx-app log -1` advanced — don't assume a re-run updated).
|
||||
- [ ] `apply_fixes: N/N fixes OK` (currently 7) + `discovery: N apps` + `reverse_open: ok`.
|
||||
|
||||
### Apps / RDP / RAIL
|
||||
- [ ] `winpodx app run desktop` — full desktop renders.
|
||||
- [ ] `winpodx app run <app>` — RAIL window appears (own window, taskbar entry), not the
|
||||
logon/lock screen, no `Invalid appWindow` corruption.
|
||||
- [ ] `winpodx app refresh` — completes without `/exec timed out` (slow/cold guest too).
|
||||
- [ ] Multiple app windows / multi-session (rdprrap) work.
|
||||
|
||||
### Reverse-open (#616) — KDE host
|
||||
- [ ] Host file under `\\tsclient\home` → *Open with* a Linux app → opens.
|
||||
- [ ] **Guest-local file** (Windows Desktop `C:\Users\…`) → *Open with* a Linux app →
|
||||
opens on the host; edits save back. (Requires kio-fuse; `winpodx doctor` `guest_mount`.)
|
||||
|
||||
### Dashboard / GUI / tray
|
||||
- [ ] Dashboard Pod / CPU / **RAM** / **Disk** gauges all show numbers (not `n/a`).
|
||||
- [ ] Settings → **UI Language** switches the interface; **Idle Action** (Pause/Stop) present.
|
||||
- [ ] Tray icon appears; submenus (sessions / USB) open on KDE Plasma.
|
||||
|
||||
### Power / idle / devices / disguise / debloat
|
||||
- [ ] Idle **Pause** (default) suspends + auto-resumes on launch.
|
||||
- [ ] Idle **Stop** (`pod.idle_action=stop`) stops the pod (frees RAM); next launch cold-boots.
|
||||
- [ ] `winpodx device` USB attach/detach (live hot-plug).
|
||||
- [ ] Disguise (`pod.disguise_level balanced|max`) boots + RDP renders (no #557 black screen).
|
||||
- [ ] `winpodx debloat` + undo run without breaking activation/updates.
|
||||
- [ ] `winpodx rotate-password` keeps host config ↔ guest account in sync.
|
||||
|
||||
## 3. Platform / channel matrix
|
||||
|
||||
Spot-check across the surfaces that diverge; full coverage isn't required every
|
||||
release, but rotate so each is hit periodically.
|
||||
|
||||
| Axis | Cover |
|
||||
|------|-------|
|
||||
| Install channel | pip/curl · AppImage · AUR · RPM (Fedora/openSUSE/AlmaLinux) · `.deb` (Debian/Ubuntu) |
|
||||
| Desktop | KDE Plasma · GNOME (note: reverse-open guest-disk is KDE/kio-fuse only) |
|
||||
| Display | Wayland (XWayland RAIL) · X11 |
|
||||
| Backend | Podman (default) · Docker |
|
||||
|
||||
## 4. Release sign-off
|
||||
|
||||
- [ ] Version bumped in `pyproject.toml` + `packaging/rpm/winpodx.spec` + `debian/changelog`
|
||||
(`python scripts/ci/verify_versions.py` → consistent).
|
||||
- [ ] `CHANGELOG.md` **and** `docs/CHANGELOG.ko.md`: `[X.Y.Z] - <date>` + a
|
||||
**### Contributors** section thanking every external reporter/contributor
|
||||
(`gh issue view <N>` for the author; exclude the maintainer).
|
||||
- [ ] `README.md` + `docs/README.ko.md` "active development" line + summary updated.
|
||||
- [ ] CI green on the release commit.
|
||||
- [ ] Push **both** tags: `vX.Y.Z` (publish workflows: OBS / RHEL / deb / AUR / AppImage)
|
||||
**and** `REL-vX.Y.Z` (Release workflow → GitHub release body from the CHANGELOG section).
|
||||
- [ ] GitHub release published with the **Contributors** section + all assets
|
||||
(wheel, sdist, AppImage, RPMs, debs).
|
||||
- [ ] Comment "shipped in vX.Y.Z" on the fixed issues; close the ones with no
|
||||
outstanding reporter question.
|
||||
@@ -0,0 +1,72 @@
|
||||
# 보안 정책
|
||||
|
||||
[English](../SECURITY.md) | **한국어**
|
||||
|
||||
## 지원 버전
|
||||
|
||||
| 버전 | 지원 여부 |
|
||||
|------|----------|
|
||||
| 최신 | 예 |
|
||||
|
||||
## 취약점 보고
|
||||
|
||||
보안 취약점은 GitHub Security Advisories를 통해 보고해 주세요:
|
||||
|
||||
**[취약점 보고하기](https://github.com/kernalix7/winpodx/security/advisories/new)**
|
||||
|
||||
**보안 취약점에 대해 공개 이슈를 열지 마세요.**
|
||||
|
||||
### 포함해야 할 내용
|
||||
|
||||
- **설명**: 취약점에 대한 명확한 설명
|
||||
- **재현 단계**: 문제를 재현하기 위한 상세한 단계
|
||||
- **영향**: 취약점의 잠재적 영향
|
||||
- **영향받는 구성 요소**: 영향받는 모듈 또는 파일
|
||||
- **환경**:
|
||||
- 운영 체제 및 버전
|
||||
- Python 버전
|
||||
- FreeRDP 버전
|
||||
- 데스크톱 환경
|
||||
- 디스플레이 서버 (X11/Wayland)
|
||||
|
||||
## 대응 일정
|
||||
|
||||
| 단계 | 기간 |
|
||||
|------|------|
|
||||
| 확인 | 48시간 이내 |
|
||||
| 평가 | 7일 이내 |
|
||||
| 수정 | 30일 이내 |
|
||||
|
||||
## 범위
|
||||
|
||||
다음 영역은 보안 보고의 범위에 포함됩니다:
|
||||
|
||||
- **서브프로세스 호출의 명령어 주입**: 셸 명령어에 전달되는 검증되지 않은 입력
|
||||
- **설정 파일의 자격 증명 노출**: 평문으로 저장된 비밀번호 또는 비밀 정보
|
||||
- **RDP 세션 하이재킹**: 활성 RDP 세션에 대한 무단 접근
|
||||
- **UNC 변환의 경로 탐색**: Windows/Linux 경로 변환 조작
|
||||
- **백엔드 관리를 통한 권한 상승**: 컨테이너 또는 VM 백엔드를 통한 무단 권한 획득
|
||||
|
||||
## 범위 외
|
||||
|
||||
다음은 범위 밖으로 간주됩니다:
|
||||
|
||||
- 물리적 접근이 필요한 공격
|
||||
- 소셜 엔지니어링 공격
|
||||
- 서드파티 의존성의 취약점 (해당 업스트림 프로젝트에 보고해 주세요)
|
||||
|
||||
## 보안 모범 사례
|
||||
|
||||
이 프로젝트는 다음 보안 관행을 따릅니다:
|
||||
|
||||
- **서브프로세스 인자에 대한 입력 검증**: 서브프로세스 호출에 전달되는 모든 인자는 검증 및 정제됩니다
|
||||
- **평문 비밀번호보다 askpass 우선**: 비밀번호를 평문으로 저장하는 대신 대화형 비밀번호 프롬프트(askpass)를 사용합니다
|
||||
- **XDG 준수 파일 권한**: 설정 파일은 적절한 파일 권한과 함께 XDG Base Directory 사양을 따릅니다
|
||||
- **코드나 git에 비밀 정보 없음**: 비밀 정보, 자격 증명, API 키는 저장소에 절대 커밋하지 않습니다
|
||||
- **TLS 전용 RDP**: SecurityLayer=2 (TLS) RDP 채널 암호화 적용; NLA는 127.0.0.1 바인딩 환경에서만 비활성화
|
||||
- **Windows 빌드 고정**: 레지스트리 정책으로 Feature update 차단; 보안 업데이트는 정상 설치
|
||||
- **컨테이너 격리 RDP**: RDP 포트는 127.0.0.1에만 바인딩, 네트워크에 노출되지 않음
|
||||
|
||||
## 기여 인정
|
||||
|
||||
책임감 있는 공개에 감사드리며, 보고자를 릴리스 노트에 표기합니다 (익명을 원하는 경우 제외).
|
||||
@@ -0,0 +1,346 @@
|
||||
# 사용법
|
||||
|
||||
[English](USAGE.md) | **한국어**
|
||||
|
||||
CLI, GUI, 설정, 헬스 체크. 설치 후 필요한 모든 것.
|
||||
|
||||
## 앱 실행
|
||||
|
||||
```bash
|
||||
winpodx app run word # Word 실행
|
||||
winpodx app run word ~/doc.docx # 파일 열기
|
||||
winpodx app run desktop # 전체 Windows 데스크톱
|
||||
```
|
||||
|
||||
또는 그냥 애플리케이션 메뉴에서 앱 아이콘 클릭 — WinPodX 가 pod 첫 부팅 시 발견된 모든 Windows 앱을 `.desktop` 엔트리로 등록함.
|
||||
|
||||
## CLI 레퍼런스
|
||||
|
||||
```bash
|
||||
# 앱
|
||||
winpodx app list # 사용 가능한 앱 목록
|
||||
winpodx app run word # Word 실행 (첫 실행 시 자동 프로비저닝)
|
||||
winpodx app run word ~/doc.docx # Word 에서 파일 열기
|
||||
winpodx app run desktop # 전체 Windows 데스크톱 세션
|
||||
winpodx app install-all # 모든 앱을 desktop 메뉴에 등록
|
||||
winpodx app sessions # 활성 세션 표시
|
||||
winpodx app kill word # 활성 세션 종료
|
||||
winpodx app refresh # 게스트 재스캔 후 앱 목록 재구성
|
||||
|
||||
# Pod 라이프사이클 (컨테이너 상태만 — 게스트 작업은 `guest`, 디스크/설치는 `install`)
|
||||
winpodx pod start --wait # 시작 후 RDP 준비 대기
|
||||
winpodx pod stop # 중지 (활성 세션 있으면 경고)
|
||||
winpodx pod status # 상태 + 세션 수
|
||||
winpodx pod restart
|
||||
winpodx pod recreate # stop + remove + start (깨끗한 컨테이너)
|
||||
winpodx pod wait-ready --logs # Windows 첫 부팅 대기 + 진행 + 컨테이너 로그 (느린 ISO 다운로드 시 자동 연장)
|
||||
|
||||
# 게스트 작업 (0.6.0 에서 `pod <x>` → `guest <x>` 로 개명, 기존 이름도 0.6.x 동안 deprecation 경고 + 동작)
|
||||
winpodx guest apply-fixes # Windows 측 런타임 fix 재적용 (idempotent)
|
||||
winpodx guest sync # host 업데이트(agent / urlacl / rdprrap / 픽스)를 guest 에 푸시 — 재설치 없이
|
||||
winpodx guest sync --force # guest 버전 stamp 가 일치해도 재sync
|
||||
winpodx guest sync-password # 비밀번호 drift 복구 (cfg ↔ Windows)
|
||||
winpodx guest multi-session on # bundled rdprrap 멀티세션 RDP 토글
|
||||
winpodx guest multi-session status
|
||||
winpodx guest recover-oem # dockur 첫 부팅 OEM 복사 실패 시 install.bat 를 수동 실행하는 noVNC PowerShell 단계 출력 (#287)
|
||||
|
||||
# 설치 / 디스크 작업 (0.6.0 에서 `pod install-* / pod grow-disk / pod disk-usage` → `install <x>` 로 개명)
|
||||
winpodx install status # 설치 진행 / 보류 단계 (#271 agent-first 설치)
|
||||
winpodx install resume # 미뤄진 설치 단계 재개
|
||||
winpodx install disk-usage # Windows C: 크기 / 여유 / 사용% + 자동확장 상태 (#318)
|
||||
winpodx install grow-disk # 자동확장 increment(기본 32G) 만큼 디스크 확장 + C: extend (#318)
|
||||
winpodx install grow-disk 128G # 절대 크기로 확장
|
||||
winpodx install grow-disk --extend-only # 기존 미할당 공간으로 C: 만 확장
|
||||
|
||||
# 전원 관리
|
||||
winpodx power --suspend # 컨테이너 pause (CPU 해제, 메모리 유지)
|
||||
winpodx power --resume # pause 된 컨테이너 resume
|
||||
|
||||
# 호스트 장치 패스스루 (USB / PCI → Windows 게스트, #286)
|
||||
winpodx device list # 호스트 USB / PCI 장치 + attach 상태 목록
|
||||
winpodx device attach <id> # 호스트 장치를 게스트에 attach (USB 는 live hot-plug; PCI 는 부팅 시 추가)
|
||||
winpodx device detach <id> # 게스트에서 장치 detach
|
||||
winpodx device attach <id> --force # PCI 장치의 게스트 재시작 안전 확인 건너뛰기
|
||||
|
||||
# 보안
|
||||
winpodx rotate-password # Windows RDP 비밀번호 회전 (host config + Windows guest 계정 둘 다)
|
||||
|
||||
# Reverse-open (host listener / guest sync)
|
||||
winpodx host-open status # listener daemon + manifest 상태
|
||||
winpodx host-open list # 발견된 호스트 앱 목록 (live 또는 --cached)
|
||||
winpodx host-open refresh # 호스트 재스캔 + 게스트로 manifest push
|
||||
winpodx host-open enable # reverse-open 켜기
|
||||
winpodx host-open disable # reverse-open 끄기
|
||||
winpodx host-open add <slug> # allowlist 에 앱 추가
|
||||
winpodx host-open remove <slug> # allowlist 에서 제거 (또는 --deny)
|
||||
winpodx host-open start-listener
|
||||
winpodx host-open stop-listener
|
||||
winpodx host-open daemon-status
|
||||
|
||||
# 유지보수
|
||||
winpodx cleanup # Office lock 파일 제거 (~$*.*)
|
||||
winpodx timesync # Windows 시간 강제 동기화
|
||||
winpodx debloat # 텔레메트리, 광고, bloat 비활성화
|
||||
winpodx uninstall # winpodx 파일 제거 (컨테이너 유지)
|
||||
winpodx uninstall --purge # config 포함 전부 제거
|
||||
|
||||
# 시스템
|
||||
winpodx setup # full 셋업: config + 컨테이너 + wait-ready + discovery + reverse-open
|
||||
winpodx setup --customize # wizard: backend / specs / edition / language / region / keyboard / timezone / tuning
|
||||
winpodx setup-host # 호스트 준비 wizard (kvm 그룹, /etc/subuid, kvm 모듈) pkexec 한 번 — AppImage 사용자
|
||||
winpodx provision # pod 기동 후 체인 (wait-ready → apply-fixes → discovery → reverse-open) — install.sh / setup / migrate / GUI 가 모두 호출하는 단일 SoT (0.6.0 item B)
|
||||
winpodx provision --retries N # discovery 재시도 횟수 재정의 (기본 2 — 0.6.0 item M)
|
||||
winpodx provision --require-agent # 게스트 agent 강제 게이트 (신규 설치용, #271)
|
||||
winpodx migrate # 기존 guest in-place 업그레이드 (agent.ps1 / 스크립트 갱신, 픽스 재적용, 재discovery, reverse-open 갱신)
|
||||
winpodx doctor # read-only 헬스 진단 + per-check fix 힌트 (deps / pod / RDP / agent / disk / config / install 상태)
|
||||
winpodx doctor --json # 같은 체크, machine-readable JSON Finding 배열
|
||||
winpodx doctor --quick # slow probe(컨테이너 헬스 / guest exec) 생략 — 가벼운 로컬 체크만 (< 1 s)
|
||||
winpodx doctor --fix # warn/fail finding 중 fixer 가진 것을 idempotent 자동 복구 (dead agent / stale lock / 누락 desktop entry / OEM 버전 drift)
|
||||
winpodx autostart on|off|status # 로그인 시 Windows pod 자동시작 (opt-in; 기본 꺼짐)
|
||||
winpodx language # 현재 UI 언어 표시
|
||||
winpodx language ko # UI 언어 설정: auto | en | ko | zh | ja | de | fr | it (auto = 호스트 로케일)
|
||||
# `winpodx info` 와 `winpodx check` 는 `winpodx doctor` 의 deprecated alias (0.6.x 동안 deprecation 경고와 함께 동작; 0.7.0 에서 제거).
|
||||
winpodx gui # Qt6 메인 윈도 실행 (Dashboard / All apps / Devices / Settings / Tools / Terminal)
|
||||
winpodx tray # Qt 시스템 트레이 아이콘 실행
|
||||
winpodx config show # 현재 config 표시
|
||||
winpodx config set rdp.scale 140 # config 값 변경
|
||||
winpodx config import # 기존 winapps.conf import
|
||||
```
|
||||
|
||||
## GUI
|
||||
|
||||
`winpodx gui` 로 실행. Qt6 메인 윈도는 Start-menu 스타일 셸 (#460-#471): 페이지당 한 줄인 좌측 세로 네비게이션 사이드바, 커맨드 바 역할도 하는 히어로 검색바, 자체 제작 SVG 아이콘 세트, 좁은/분수 배율 창에서 reflow 되고 화면에 맞춰지는 반응형 레이아웃. 페이지:
|
||||
|
||||
| 페이지 | 동작 |
|
||||
|------|------|
|
||||
| **Dashboard** | 홈 화면 — 라이브 Pod / RAM / CPU ring 게이지 + 디스크 사용량, 자동 회복 상태 카드, pinned / recent 워크스페이스 타일, 라이브 RDP 앱 세션을 세션별 종료 버튼과 함께 나열하는 "Running sessions" 스트립, reverse-open 토글 |
|
||||
| **All apps** | 설치된 앱 프로필의 grid / list view (구 "Apps"), 검색 + 카테고리 필터, 앱별 실행 (3초 cooldown), Add / Edit / Delete 앱 프로필 다이얼로그 |
|
||||
| **Devices** | USB / PCI 장치 패스스루용 2열 호스트 ↔ 게스트 mover (#286) — 왼쪽에서 호스트 장치 선택, 오른쪽에서 Windows 게스트에 attach (USB 는 live hot-plug; PCI 는 안전 확인과 함께 게스트 재시작 필요) |
|
||||
| **Settings** | RDP (user / IP / port / scale / DPI / 비밀번호 회전 / 멀티모니터), Container (backend / CPU / RAM / idle timeout), 그리고 reverse-open 패널 (enable 토글, allowlist + denylist, 라이브 daemon 상태, refresh / start / stop 버튼) 한 화면에 |
|
||||
| **Tools** | Suspend / Resume / Full Desktop 버튼, Clean Locks / Sync Time / Debloat, Grow Disk / Sync Guest, 그리고 원클릭 Windows Update **활성/비활성** 토글 |
|
||||
| **Terminal** | 명령 allowlist 제한된 embedded 셸 (`podman`, `docker`, `winpodx`, `xfreerdp`, `systemctl`, `journalctl`, `ss`, `ip`, `ping`, ...) + 퀵 버튼 (Status / Logs / Inspect / RDP Test / Clear) |
|
||||
| **Info** | 라이브 **Health** 카드 (pod / RDP / agent / OEM / disk / 비밀번호 age / 앱 수) + System / Display / Dependencies / Pod / Config 스냅샷 |
|
||||
|
||||
시스템 트레이 (`winpodx tray`) 는 가벼운 대안 — pod 컨트롤, 앱 런처 서브메뉴 (상위 20 + Full Desktop), USB 장치 스위처 (#300, 호스트 USB 장치를 게스트에 attach / detach), 유지보수 서브메뉴 (Clean Locks / Sync Time / Suspend), 라이브 RDP 앱 세션을 종료할 수 있는 running-sessions 서브메뉴, 선택적 idle-monitor 스레드.
|
||||
|
||||
### Tray 자동 spawn + UNRESPONSIVE 자동 회복 (v0.5.5)
|
||||
|
||||
v0.5.5 부터 tray 가 GUI 창과 pod 를 건드리는 모든 CLI 서브커맨드 (`setup` / `gui` / `tray` 제외) 에서 자동 spawn — `winpodx app run` 만 쓰는 사용자도 시스템 트레이 아이콘 + UNRESPONSIVE 자동 회복 드라이버 활용 가능. `$XDG_RUNTIME_DIR/winpodx/tray.lock` flock 으로 중복 인스턴스 차단.
|
||||
|
||||
트레이 컨텍스트 메뉴 최상단에 **Open Dashboard** (메인 GUI 창 원클릭). **Quit** 가 다이얼로그로 확인 후 `stop_pod` + `pkill -f 'winpodx gui'` + `app.quit` 실행 — 실수 클릭으로 pod ~30초 재시작 비용 발생 방지.
|
||||
|
||||
매 로그인 시 tray 자동 실행하려면 GUI → Settings → **"Launch WinPodX tray at login (system tray icon + idle-stall auto-recovery)"** 체크. 토글이 XDG autostart 스펙 통해 `~/.config/autostart/winpodx-tray.desktop` 작성/삭제; KDE / GNOME / XFCE / Cinnamon 모두 portable. 파일이 source of truth — GUI 안 띄우고 손으로 떼서 opt out 가능. 토글 즉시 적용, Save Settings 클릭 불필요.
|
||||
|
||||
tray 가 pod 상태 30초 주기로 감시. `RUNNING → UNRESPONSIVE` 전이 시 (컨테이너가 fresh boot 와 헷갈릴 수 없을 만큼 오래 살아있는데 RDP 포트 miss) 데스크톱 알림 발사 + 백그라운드 worker 가 agent 에게 Windows `TermService` cycle 요청. 회복 시 "Pod recovered" 알림; 실패 시 "needs manual restart" 알림이 `winpodx pod restart` 안내. `install.sh` 의 `[3/4]` / `[4/4]` Sysprep + OEM 재부팅 단계 진행 중에는 marker 파일 `~/.config/winpodx/.install_in_progress` 가 회복 경로 억제 — install 단계의 정당한 RDP 공백에 spurious 알림 발사 안 함.
|
||||
|
||||
## 호스트 장치 패스스루
|
||||
|
||||
호스트 USB 또는 (GPU 가 아닌) PCI 장치를 Windows 게스트로 패스스루 (#286). 세 가지 표면이 같은 백엔드를 구동:
|
||||
|
||||
* **CLI** — `winpodx device list` 가 각 호스트 장치 + attach 상태 표시; `winpodx device attach <id>` / `winpodx device detach <id>` 로 하나씩 넣고 뺌.
|
||||
* **GUI Devices 페이지** — 2열 호스트 ↔ 게스트 mover (왼쪽에서 선택, 오른쪽에서 attach).
|
||||
* **시스템 트레이** — 호스트 USB 장치 원클릭 attach / detach 용 USB 스위처 서브메뉴 (#300).
|
||||
|
||||
USB 장치는 live hot-plug (`cfg.pod.usb_live`, 기본 on) — 재시작 불필요. PCI 장치는 부팅 시 추가되고 게스트 재시작 후에만 보이므로 attach 가 안전 확인으로 보호됨; CLI 에서 `--force` 전달 (또는 GUI 에서 다이얼로그 확인) 으로 진행.
|
||||
|
||||
## bare-metal 호환 모드 (하이퍼바이저 숨김)
|
||||
|
||||
일부 소프트웨어는 하이퍼바이저가 감지되면 실행을 거부합니다 — 대표적으로 Nvidia 소비자 GPU 드라이버(KVM 감지 시 **code 43**, GPU 패스스루의 흔한 차단 원인)와 런치게이트 VM 체크가 있는 앱들. `cfg.pod.disguise_level` (#246) 이 KVM/QEMU 시그니처를 게스트에서 숨겨 물리 PC 처럼 보이게 합니다. **3단계, 기본값 `balanced`**:
|
||||
|
||||
| 레벨 | 동작 | 성능 |
|
||||
|------|------|------|
|
||||
| `off` | 숨김 없음 — 정직한 VM | 최고(호환성도) |
|
||||
| `balanced` (기본) | CPUID 하이퍼바이저 비트 + KVM 시그니처 제거, 호스트 SMBIOS/DMI 미러링, 합성 센서 디스크립터, 물리 PC 같은 디스크 크기 광고 | 손실 없음 |
|
||||
| `max` | `balanced` + **에뮬레이트 가상 하드웨어** — 디스크 → SATA(AHCI), 네트워크 → e1000(`MTU=1500`), GPU → std VGA, virtio-rng 제거 — 그리고 `HV=N`. virtio(`VEN_1AF4`)/QXL(`VEN_1B36`) PCI ID와 `vioscsi`/`viostor`/`netkvm` 드라이버 제거. (`MTU=1500`은 dockur가 e1000이 거부하는 `host_mtu=`를 안 붙이게 하려고 필수.) **wipe+재설치 필요**(부팅 디스크 컨트롤러가 바뀌어 기존 설치 부팅 불가) → max 전환 시 강력 확인 후 Windows 처음부터 재설치. | **대폭 느려짐** — 에뮬레이트 디스크+NIC는 virtio보다 throughput 훨씬 낮음, Hyper-V enlightenment도 꺼짐 |
|
||||
|
||||
```bash
|
||||
winpodx config set pod.disguise_level off # 정직한 VM
|
||||
winpodx config set pod.disguise_level balanced # 기본 — 무손실 숨김, 자동 적용
|
||||
winpodx config set pod.disguise_level max # 최대 숨김 (에뮬레이트 HW, 느려짐)
|
||||
# off <-> balanced 는 자동 적용됨. max 전환은 가상 하드웨어가 바뀌어 파괴적 재설치 필요:
|
||||
winpodx pod recreate --wipe-storage # Windows 초기화 후 새 하드웨어로 재설치
|
||||
```
|
||||
|
||||
GUI에서도 선택 가능: **Settings → Bare-metal compatibility**. 어느 쪽이든 `winpodx pod recreate` 후 적용됨 (QEMU `-cpu` 라인 + `HV` env + 디스크 크기를 바꾸므로; recreate는 Windows 디스크 유지).
|
||||
|
||||
**고급 — 패치된 QEMU 이미지 (`cfg.pod.disguise_image`):** 일부 VM 마커(ACPI OEM `BOCHS`, 디스크 모델 `QEMU HARDDISK`)는 QEMU에 컴파일된 문자열이라 커맨드라인 인자로 못 바꿉니다. `packaging/qemu-disguise/`가 그 문자열을 패치한 커스텀 dockur 이미지를 빌드하며, `disguise_image`에 지정하면 `max`에서 사용됩니다. winpodx는 패치 레시피만 배포(패치된 바이너리 없음). PCI 벤더 ID는 일부러 안 건드림(스푸핑하면 dockur virtio-serial이 깨짐). 그 디렉터리 README 참고.
|
||||
|
||||
**안티치트 우회 아님.** 캐주얼 탐지기와 VM 거부 앱(code 43, DRM/런치게이트)용 시그니처 레벨 숨김입니다. 커널 안티치트(EAC/BattlEye/Vanguard)는 **못 피합니다** — 하드웨어 attestation(TPM + Secure Boot)과 게스트가 위조 못 하는 VM-exit 타이밍에 의존하며, 온라인 게임 안티치트 우회는 게임 ToS 위반입니다.
|
||||
|
||||
디스크 확대는 게이트됨: dockur 디스크는 sparse 라 광고 크기를 키워도 호스트 공간을 즉시 먹지 않지만, 호스트 여유 공간이 충분할 때만(10 GiB / 10 % 예약 유지) 올립니다. 작은 호스트에선 그대로 두고 경고만 출력. 구버전 `disguise_hypervisor = false` 키도 계속 작동 — `off` 로 매핑됩니다.
|
||||
|
||||
> **안티치트 우회가 아닙니다.** 시그니처 레벨만이며 커널모드 안티치트(EAC / BattlEye / Vanguard)는 못 뚫습니다. 온라인 게임 안티치트 우회는 ToS 위반이고 밴 위험 — winpodx 는 그 용도를 지원하지 않습니다.
|
||||
|
||||
## 멀티모니터
|
||||
|
||||
멀티모니터 RAIL 기본 on (`cfg.rdp.multimon`, 기본 `"span"`): 원격 앱 창을 두 번째 모니터로 끌어도 입력이 계속 동작. 값:
|
||||
|
||||
| `cfg.rdp.multimon` | 효과 |
|
||||
|---|---|
|
||||
| `span` (기본) | RDP 세션을 모든 모니터에 걸쳐 span — 원격 앱 창이 어느 모니터에서든 인터랙티브 유지 |
|
||||
| `multimon` | FreeRDP 의 개별 `/multimon` 모드 (모니터별 geometry) 사용 |
|
||||
| `off` | 단일 모니터만 |
|
||||
|
||||
`winpodx config set rdp.multimon off` 또는 GUI Settings 페이지로 변경.
|
||||
|
||||
## 헬스 체크
|
||||
|
||||
`winpodx doctor` 가 GUI Health 카드가 쓰는 모든 프로브를 실행하고 각각 한 줄 결과 출력:
|
||||
|
||||
```
|
||||
=== WinPodX doctor ===
|
||||
|
||||
[OK ] pod_running running (ip=127.0.0.1) (58ms)
|
||||
[OK ] rdp_port 127.0.0.1:3390 reachable (0ms)
|
||||
[OK ] agent_health version=0.2.2-rev4 (63ms)
|
||||
[OK ] agent_auth_ready bearer token available (1ms)
|
||||
[OK ] oem_version bundle=24 (3ms)
|
||||
[OK ] password_age 7d remaining (max_age=7d) (0ms)
|
||||
[OK ] apps_discovered 41 app(s) in /home/.../discovered (3ms)
|
||||
[OK ] disk_free 401.0/3725 GiB free (0ms)
|
||||
|
||||
Overall: OK
|
||||
```
|
||||
|
||||
상태 범례: `OK` (녹색) / `WARN` (노랑 — 정보성, exit 0) / `FAIL` (빨강 — exit 1) / `SKIP` (회색 — config 로 비활성). machine-readable output 은 `--json`.
|
||||
|
||||
## Windows 비밀번호 변경
|
||||
|
||||
`winpodx rotate-password` 를 쓰세요 — 절대로 `winpodx setup` 으로 비밀번호 바꾸지 마세요. 이미 실행 중인 설치에 대해 두 명령의 효과는 완전히 다릅니다:
|
||||
|
||||
| 명령 | Host config (`winpodx.toml`) | Windows guest 계정 |
|
||||
|---|---|---|
|
||||
| `winpodx rotate-password` | 원자적 업데이트 (실패 시 rollback) | Windows-side 비밀번호 변경 메커니즘 통해 변경 |
|
||||
| `winpodx setup` (재실행) | 그대로 보존 (v0.5.5 이상) | 변경 안 함 |
|
||||
| `winpodx setup` (신규 설치, 기존 config 없음) | 생성/입력 받음 | 첫 부팅 시 dockur `USERNAME`/`PASSWORD` env var 로 적용 |
|
||||
|
||||
cores / RAM / `win_version` 만 바꾸려고 `winpodx setup` 재실행은 안전, 자격증명 건드리지 않음. v0.5.5 이전 릴리스에서는 wizard 가 매번 비밀번호를 reprompt 하고 `winpodx.toml` 을 조용히 덮어썼습니다 — 하지만 dockur 는 첫 부팅에만 password env var 를 적용하므로 호스트 config 와 Windows guest 계정이 desync 되어 다음 RDP launch 가 `LOGON_FAILED_BAD_PASSWORD` 로 실패했습니다.
|
||||
|
||||
### desync 된 비밀번호에서 복구 (v0.5.5 이전 lockout)
|
||||
|
||||
이전 릴리스에서 `winpodx setup` 돌리고 로그인 불가 상태라면:
|
||||
|
||||
1. **옛 비밀번호가 남아있다면 복원** (`winpodx.toml` 백업, 패스워드 매니저, shell history 등):
|
||||
```bash
|
||||
winpodx config set rdp.password '<old-password>'
|
||||
winpodx pod start
|
||||
winpodx rotate-password
|
||||
```
|
||||
2. **그렇지 않으면** `winpodx uninstall --purge` + 재설치가 유일한 길. Windows 안의 모든 상태 (설치된 앱, 문서, 설정) 손실. 재설치 후 첫 단계로 `winpodx setup` 한 번 돌리고, 이후 비밀번호 변경은 절대 `setup` 으로 하지 말고 `rotate-password` 만 사용하세요.
|
||||
|
||||
## 성능 튜닝 프로파일
|
||||
|
||||
`cfg.pod.tuning_profile` 이 호스트에 대한 WinPodX 의 dockur compose 튜닝 적극성을 제어. 기본값 `"auto"` — WinPodX 가 compose 생성 시점에 호스트를 한 번 probe 하고 매칭되는 안전한 Windows-on-KVM 튜닝을 활성화. `winpodx doctor` 의 `[Tuning]` 블록에서 무엇이 감지되고 적용됐는지 확인 가능:
|
||||
|
||||
```
|
||||
[Tuning]
|
||||
invtsc: yes (intel)
|
||||
io_uring: yes (kernel 6.18, need >= 5.6)
|
||||
hugepages: no (sysctl vm.nr_hugepages)
|
||||
dedicated: yes
|
||||
nested_kvm: yes (/sys/module/kvm_*/parameters/nested)
|
||||
|
||||
Profile: auto
|
||||
+invtsc: yes
|
||||
io_uring aio: yes
|
||||
hugepages: no
|
||||
CPU pinning: yes
|
||||
platform_tick: yes
|
||||
no balloon: yes
|
||||
hv-* + no-hpet: yes
|
||||
virtio-rng: yes
|
||||
nested virt: yes
|
||||
hv-evmcs: yes
|
||||
```
|
||||
|
||||
프로파일:
|
||||
|
||||
| `tuning_profile` | 동작 |
|
||||
|---|---|
|
||||
| `auto` (기본) | 호스트 capability 감지 + 호스트가 지원하는 모든 안전 튜닝 적용 (Hyper-V enlightenments, virtio-rng, `/sys/module/kvm_*/parameters/nested == Y` 시 nested-virt pass-through 포함). CPU pinning + no-balloon 은 `dedicated_host` (idle CPU + free RAM ≥ VM 할당의 2배) gate 통과 시에만 — 다른 호스트 워크로드 starve 방지. 대부분 사용자에게 권장. |
|
||||
| `performance` | `auto` 와 동일하나 `dedicated_host` gate 우회: CPU pinning + no-balloon 이 호스트 현재 부하 무관하게 강제 on. 박스가 WinPodX 에 거의 dedicated 이고 다른 호스트 워크로드 희생해서라도 게스트 latency 최소화하고 싶을 때 사용. Hard-gated 항목 (`+invtsc`, `io_uring`) 은 여전히 capability 감지 존중 — `performance` 가 QEMU 가 거부할 CPU flag 나 kernel crash 일으킬 feature 를 강제할 수는 없음. |
|
||||
| `safe` | 호스트 설정 무관한 Windows-guest-only 부분만 적용: `+invtsc` (지원 시), `platform_tick` BCD, Hyper-V enlightenments (`hv-relaxed`, `hv-vapic`, `hv-vpindex`, `hv-runtime`, `hv-synic`, `hv-reset`, `hv-frequencies`, `hv-reenlightenment`, `hv-tlbflush`, `hv-ipi`, `hv-spinlocks=0x1fff`, `hv-stimer`, `hv-stimer-direct`, `-no-hpet`), `virtio-rng`. 호스트 측 명시적 opt-in 필요한 nested-virt + `hv-evmcs` 는 제외. |
|
||||
| `off` | 아무것도 적용 안 함; dockur 기본만 유지. 튜닝 간섭 디버깅 시 사용. |
|
||||
| `manual` | `safe` 와 동일 shape; 향후 개별 knob override 용 예약. |
|
||||
|
||||
### 각 튜닝 설명
|
||||
|
||||
* **`+invtsc`** — invariant TSC 노출, Windows 가 HPET 대신 TSC 를 clock source 로 사용 (IRQ overhead 감소).
|
||||
* **`hv-*` enlightenments + `-no-hpet`** (#245) — Windows 에게 paravirtualised hypervisor 환경임을 알림. spinlock / VM-exit overhead 모든 워크로드에서 감소; multi-vCPU 게스트에서 효과 큼. `hv-spinlocks=0x1fff` 은 upstream 권장 retry 한도.
|
||||
* **`virtio-rng-pci` (`/dev/urandom` backed)** (#245) — Windows 엔트로피 풀 빠르게 채움, 첫 부팅 시 CryptoAPI / TLS handshake stall 방지.
|
||||
* **`+vmx` / `+svm` nested virt** (#245) — `/sys/module/kvm_intel/parameters/nested` 또는 `kvm_amd` 가 `Y` 일때 자동 활성화. Windows 게스트 안에서 Hyper-V / WSL2 / Docker Desktop 실행 필수. 호스트 kernel 미 opt-in 시 무영향.
|
||||
* **`hv-evmcs`** (#245) — Intel 전용 nested-VMCS 최적화, `+vmx` 와 페어. nested VM 미실행 시 overhead zero.
|
||||
* **`io_uring` AIO** — kernel ≥ 5.6 디스크 I/O backend; 기존 thread 보다 latency 낮음.
|
||||
* **Hugepages** — QEMU 메모리를 2 MB 페이지로 backing. 호스트 `vm.nr_hugepages` 예약 필요 (WinPodX 자동 예약 없음).
|
||||
* **CPU pinning** — 호스트 idle CPU + RAM ≥ VM 할당의 2배일때 `dedicated` flag 세움, QEMU vCPU pinning 적용.
|
||||
|
||||
### One-shot override
|
||||
|
||||
`winpodx pod start --tuning {auto,safe,off,manual}` 이 컨테이너 실행 동안만 `cfg.pod.tuning_profile` 을 override. `winpodx.toml` 의 사용자 영구 설정은 그대로. A/B 테스트 시 `winpodx config set` round-trip 없이 왔다 갔다 가능.
|
||||
|
||||
### 호스트 사전 설정 필요 항목 (자동 적용 안 됨)
|
||||
|
||||
다음은 Linux 호스트에서 운영자가 사전 작업해야 WinPodX 가 활용 가능한 표준 Windows-on-KVM 튜닝. 호스트 미설정 시 `winpodx doctor` 의 `[Tuning]` 블록에 `no` 로 표시; 호스트 설정 후 다음 `cfg.pod.tuning_profile = auto` 실행 시 자동 `yes`.
|
||||
|
||||
* **Transparent hugepages / explicit hugepages.** `sysctl vm.nr_hugepages` 설정 (또는 `madvise` THP 사용) 으로 QEMU 프로세스 메모리 hugepage backing. WinPodX 가 `/proc/meminfo` 의 `HugePages_Total > 0` 감지 시 auto-apply, 미예약 시 skip.
|
||||
* **CPU pinning.** WinPodX 가 현재 idle CPU + RAM 이 VM 할당량의 2배 이상일 때 호스트를 `dedicated` 로 flag. QEMU 스레드를 특정 코어에 `taskset` 또는 systemd `CPUAffinity=` 으로 pin 하는 건 운영자 책임; WinPodX 는 호스트 스케줄링 수정 안 함.
|
||||
* **VFIO GPU passthrough.** RDP 기반 WinPodX 아키텍처에 scope 밖. (GPU 가 아닌 USB / PCI 장치 패스스루는 지원됨 — 아래 "호스트 장치 패스스루" 참고.) 베어메탈 GPU 성능 필요 시 직접 GPU 패스스루 Windows VM (예: libvirt / virt-manager) 을 띄우고 `manual` 백엔드로 그 RDP 엔드포인트에 WinPodX 를 연결.
|
||||
|
||||
## 설정
|
||||
|
||||
설정 파일: `~/.config/winpodx/winpodx.toml` (자동 생성, `0600` 권한)
|
||||
|
||||
```toml
|
||||
[rdp]
|
||||
user = "User"
|
||||
password = "" # 자동 생성 랜덤 비밀번호
|
||||
password_updated = "" # ISO 8601 timestamp
|
||||
password_max_age = 7 # 자동 회전까지 일수 (0 = 비활성)
|
||||
ip = "127.0.0.1"
|
||||
port = 3390
|
||||
scale = 100 # DE 에서 자동 감지
|
||||
dpi = 0 # Windows DPI % (0 = 자동)
|
||||
multimon = "span" # 멀티모니터 RAIL: span | multimon | off
|
||||
extra_flags = "" # 추가 FreeRDP 플래그 (allowlist); 예:
|
||||
# "+multitouch" — 터치스크린 / 스타일러스 / 펜을
|
||||
# Windows 앱에 패스스루 (#623)
|
||||
# "-gfx" — 레거시 GDI 경로 (RAIL 렌더 워크어라운드)
|
||||
|
||||
[pod]
|
||||
backend = "podman"
|
||||
win_version = "11" # 11 | 10 | ltsc11 | ltsc10 | iot11 | tiny11 | tiny10 | 2025 | 2022 | 2019 | 2016 — 커스텀 ISO 는 ARCHITECTURE.md 참고
|
||||
keyboard = "en-US" # Windows 설치 로케일; FreeRDP 세션 레이아웃(/kbd:layout)으로도 매핑돼 비-US 키보드가 RemoteApp 창에서 동작 (#660)
|
||||
cpu_cores = 4
|
||||
ram_gb = 4
|
||||
vnc_port = 8007
|
||||
auto_start = false # opt-in 로그인 자동시작: 로그인 시 트레이가 pod 시작 (`winpodx autostart on|off|status` 로 토글)
|
||||
idle_timeout = 0 # 자동 suspend 까지 초 (0 = 비활성)
|
||||
boot_timeout = 300 # 첫 부팅 unattended 설치 대기 초
|
||||
image = "docker.io/dockurr/windows:latest" # 컨테이너 이미지 (에어갭 미러용 override)
|
||||
usb_live = true # attach 된 USB 장치를 실행 중 게스트에 hot-plug (재시작 없이) — `winpodx device` 참고
|
||||
# disguise_level = "balanced" # bare-metal 모드: off | balanced(기본, 무손실 숨김) | max(Hyper-V 끔, 느려짐) — Nvidia code-43 / VM 거부 앱; 안티치트 우회 아님 (#246)
|
||||
disk_size = "64G" # dockur 에 전달하는 가상 디스크 크기 (`install grow-disk` 로 확장)
|
||||
disk_autogrow = true # C: 가 임계 넘으면 자동 확장 (idle 일 때만)
|
||||
disk_autogrow_threshold_pct = 80 # 자동 확장 트리거 사용% (50-99)
|
||||
disk_autogrow_target_free_pct = 30 # 확장 후 회복할 여유 비율 (고정 step 아님)
|
||||
disk_autogrow_increment = "32G" # 확장 granularity / 최소 step
|
||||
disk_max_size = "" # 선택적 상한; 빈값 = host 여유공간만이 한계
|
||||
guest_autosync = true # host 업데이트 후 guest 아티팩트 자동 푸시 (재설치 없이)
|
||||
|
||||
[ui]
|
||||
language = "auto" # UI 언어: auto | en | ko | zh | ja | de | fr | it (auto = 호스트 로케일, 영어로 폴백; `winpodx language` 또는 GUI 설정으로 변경)
|
||||
|
||||
[desktop]
|
||||
mime_associations = true # 탐지된 앱이 파일 관리자 "다른 프로그램으로 열기"에 실제 파일 타입 제공 (#545); 기본 핸들러로 설정 안 함
|
||||
full_app_scan = false # false = 시작메뉴 전용 검출(깨끗한 메뉴, 폴더 그룹화); true = 레지스트리 App Paths / Chocolatey / Scoop / 전체 UWP 도 스캔 (#581) — 시작메뉴 항목 없는 포터블 앱용
|
||||
|
||||
[reverse_open]
|
||||
enabled = true # v0.5.0 부터 기본 활성
|
||||
allow = [] # 비어있으면 발견된 모든 앱
|
||||
deny = [] # manifest 에서 제외할 앱
|
||||
|
||||
[logging]
|
||||
level = "INFO" # DEBUG | INFO | WARNING | ERROR | CRITICAL | RAW — RAW = DEBUG + pod 로그 (podman logs -f) 를 GUI Terminal 에 interleave
|
||||
```
|
||||
|
||||
`winpodx config set <key> <value>` 또는 에디터로 직접 수정 — TOML 은 3.11+ 에서 stdlib (`tomli` on 3.9/3.10) 로 파싱.
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
# Usage
|
||||
|
||||
**English** | [한국어](USAGE.ko.md)
|
||||
|
||||
CLI, GUI, configuration, and health checks. Everything you need after install.
|
||||
|
||||
## Launch an app
|
||||
|
||||
```bash
|
||||
winpodx app run word # Launch Word
|
||||
winpodx app run word ~/doc.docx # Open a file
|
||||
winpodx app run desktop # Full Windows desktop
|
||||
```
|
||||
|
||||
Or just click an app icon in your application menu — WinPodX registers every discovered Windows app as a `.desktop` entry the first time the pod boots.
|
||||
|
||||
## CLI reference
|
||||
|
||||
```bash
|
||||
# Apps
|
||||
winpodx app list # List available apps
|
||||
winpodx app run word # Launch Word (auto-provisions on first run)
|
||||
winpodx app run word ~/doc.docx # Open a file in Word
|
||||
winpodx app run desktop # Full Windows desktop session
|
||||
winpodx app install-all # Register all apps in desktop menu
|
||||
winpodx app sessions # Show active sessions
|
||||
winpodx app kill word # Kill an active session
|
||||
winpodx app refresh # Re-scan the guest and rebuild the app list
|
||||
|
||||
# Pod lifecycle (container state only — see `guest` for in-guest ops, `install` for disk / install)
|
||||
winpodx pod start --wait # Start and wait for RDP readiness
|
||||
winpodx pod stop # Stop (warns about active sessions)
|
||||
winpodx pod status # Status with session count
|
||||
winpodx pod restart
|
||||
winpodx pod recreate # Stop + remove + start (clean container)
|
||||
winpodx pod wait-ready --logs # Wait for Windows first-boot with progress + container logs (auto-extends on slow ISO download)
|
||||
|
||||
# Guest-side operations (renamed from `pod <x>` in 0.6.0 — old spellings still work through 0.6.x with a deprecation notice)
|
||||
winpodx guest apply-fixes # Re-apply Windows-side runtime fixes (idempotent)
|
||||
winpodx guest sync # Push host updates (agent / urlacl / rdprrap / fixes) into the guest — no reinstall
|
||||
winpodx guest sync --force # Re-sync even when the guest version stamp already matches
|
||||
winpodx guest sync-password # Recover from password drift (cfg ↔ Windows)
|
||||
winpodx guest multi-session on # Toggle bundled rdprrap multi-session RDP
|
||||
winpodx guest multi-session status
|
||||
winpodx guest recover-oem # Print noVNC PowerShell steps to download + run install.bat manually when dockur's first-boot OEM copy failed (#287)
|
||||
|
||||
# Install / disk operations (renamed from `pod install-* / pod grow-disk / pod disk-usage` in 0.6.0)
|
||||
winpodx install status # Install progress / pending steps (#271 agent-first installs)
|
||||
winpodx install resume # Resume a deferred install step
|
||||
winpodx install disk-usage # Show Windows C: size / free / used% + auto-grow status (#318)
|
||||
winpodx install grow-disk # Add the auto-grow increment (default 32G) to the disk + extend C: (#318)
|
||||
winpodx install grow-disk 128G # Grow to an absolute size
|
||||
winpodx install grow-disk --extend-only # Just extend C: into existing unallocated space
|
||||
|
||||
# Power management
|
||||
winpodx power --suspend # Pause container (free CPU, keep memory)
|
||||
winpodx power --resume # Resume paused container
|
||||
|
||||
# Host device passthrough (USB / PCI → Windows guest, #286)
|
||||
winpodx device list # List host USB / PCI devices + their attach state
|
||||
winpodx device attach <id> # Attach a host device to the guest (USB hot-plugs live; PCI is boot-added)
|
||||
winpodx device detach <id> # Detach a device from the guest
|
||||
winpodx device attach <id> --force # Skip the guest-restart safety confirmation for a PCI device
|
||||
|
||||
# Security
|
||||
winpodx rotate-password # Rotate Windows RDP password (host config + Windows-side guest account)
|
||||
|
||||
# Reverse-open (host listener / guest sync)
|
||||
winpodx host-open status # Show listener daemon + manifest state
|
||||
winpodx host-open list # List discovered host apps (live or --cached)
|
||||
winpodx host-open refresh # Rescan host + push manifest to guest
|
||||
winpodx host-open enable # Turn reverse-open on
|
||||
winpodx host-open disable # Turn reverse-open off
|
||||
winpodx host-open add <slug> # Add app to allowlist
|
||||
winpodx host-open remove <slug> # Remove from allowlist (or --deny)
|
||||
winpodx host-open start-listener
|
||||
winpodx host-open stop-listener
|
||||
winpodx host-open daemon-status
|
||||
|
||||
# Maintenance
|
||||
winpodx cleanup # Remove Office lock files (~$*.*)
|
||||
winpodx timesync # Force Windows time synchronization
|
||||
winpodx debloat # Disable telemetry, ads, bloat
|
||||
winpodx uninstall # Remove winpodx files (keeps container)
|
||||
winpodx uninstall --purge # Remove everything including config
|
||||
|
||||
# System
|
||||
winpodx setup # Full setup: config + container + wait-ready + discovery + reverse-open
|
||||
winpodx setup --customize # Wizard: backend / specs / edition / language / region / keyboard / timezone / tuning
|
||||
winpodx setup-host # Host prep wizard (kvm group, /etc/subuid, kvm module) via one pkexec prompt — AppImage users
|
||||
winpodx provision # Post-pod-running chain (wait-ready → apply-fixes → discovery → reverse-open) — the single source of truth used by install.sh, setup, migrate, and the GUI bring-up (0.6.0 item B)
|
||||
winpodx provision --retries N # Override discovery retry count (default 2 — see 0.6.0 item M)
|
||||
winpodx provision --require-agent # Hard-gate on the in-guest agent (used by fresh installs, #271)
|
||||
winpodx migrate # Upgrade an existing guest in place (refresh agent.ps1 + scripts, re-apply fixes, re-discover, refresh reverse-open)
|
||||
winpodx doctor # Read-only health diagnostic with per-check fix hints (deps / pod / RDP / agent / disk / config / install state)
|
||||
winpodx doctor --json # Same checks, machine-readable JSON array of findings
|
||||
winpodx doctor --quick # Skip slow probes (container-health, guest exec) — cheap local checks only (< 1 s)
|
||||
winpodx doctor --fix # Idempotent auto-remediation for warn/fail findings that carry a fixer (dead agent, stale locks, missing desktop entries, OEM-version drift)
|
||||
winpodx autostart on|off|status # Start the Windows pod on login (opt-in; off by default)
|
||||
winpodx language # Show the current UI language
|
||||
winpodx language ko # Set UI language: auto | en | ko | zh | ja | de | fr | it (auto = host locale)
|
||||
# `winpodx info` and `winpodx check` are deprecated aliases of `winpodx doctor` (work through 0.6.x with a notice; removed in 0.7.0).
|
||||
winpodx gui # Launch Qt6 main window (Dashboard / All apps / Devices / Settings / Tools / Terminal)
|
||||
winpodx tray # Launch Qt system tray icon
|
||||
winpodx config show # Show current config
|
||||
winpodx config set rdp.scale 140 # Change a config value
|
||||
winpodx config import # Import existing winapps.conf
|
||||
```
|
||||
|
||||
## GUI
|
||||
|
||||
Launch with `winpodx gui`. The Qt6 main window is a Start-menu-style shell (#460-#471): a left vertical navigation sidebar with one row per page, a hero search bar that doubles as a command bar, an in-house SVG icon set, and responsive layouts that reflow on narrow / fractionally-scaled windows and fit themselves to the screen. The pages:
|
||||
|
||||
| Page | What it does |
|
||||
|------|--------------|
|
||||
| **Dashboard** | Home screen — live Pod / RAM / CPU ring gauges + disk usage, an auto-recovery status card, pinned / recent workspace tiles, a "Running sessions" strip that lists live RDP app sessions with a per-session terminate button, and a reverse-open toggle |
|
||||
| **All apps** | Grid / list view of installed app profiles (formerly "Apps"), search + category filter, per-app launch with 3 s cooldown, Add / Edit / Delete app profile dialogs |
|
||||
| **Devices** | Two-column host ↔ guest mover for USB / PCI device passthrough (#286) — pick a host device on the left, attach it to the Windows guest on the right (USB hot-plugs live; PCI needs a guest restart with a safety confirmation) |
|
||||
| **Settings** | RDP (user / IP / port / scale / DPI / password rotation / multi-monitor), Container (backend / CPU / RAM / idle timeout), and the reverse-open panel (enable toggle, allowlist + denylist, live daemon status, refresh / start / stop buttons) all in one screen |
|
||||
| **Tools** | Suspend / Resume / Full Desktop buttons, Clean Locks / Sync Time / Debloat, Grow Disk / Sync Guest, and a one-click Windows Update **enable / disable** toggle |
|
||||
| **Terminal** | Embedded shell limited to a command allowlist (`podman`, `docker`, `winpodx`, `xfreerdp`, `systemctl`, `journalctl`, `ss`, `ip`, `ping`, ...) with quick buttons (Status / Logs / Inspect / RDP Test / Clear) |
|
||||
| **Info** | Live **Health** card (pod / RDP / agent / OEM / disk / password age / app count) + System / Display / Dependencies / Pod / Config snapshot |
|
||||
|
||||
The system tray (`winpodx tray`) is a lighter-weight alternative — pod controls, app launcher submenu (top 20 + Full Desktop), a USB device switcher (#300, attach / detach host USB devices to the guest), maintenance submenu (Clean Locks / Sync Time / Suspend), a running-sessions submenu that can terminate live RDP app sessions, and an optional idle-monitor thread.
|
||||
|
||||
### Tray auto-spawn + UNRESPONSIVE recovery (v0.5.5)
|
||||
|
||||
Since v0.5.5 the tray spawns itself automatically from the GUI window and from every CLI subcommand that touches the pod (everything except `setup` / `gui` / `tray`), so a user who only ever runs `winpodx app run` still gets the system-tray indicator + the UNRESPONSIVE auto-recovery driver. A flock under `$XDG_RUNTIME_DIR/winpodx/tray.lock` prevents stacked instances when the user manually re-launches the tray.
|
||||
|
||||
The tray context menu now starts with **Open Dashboard** (one-click to the main GUI window). **Quit** confirms via a dialog and on confirmation runs `stop_pod` + `pkill -f 'winpodx gui'` + `app.quit` so a stray click can't cycle the pod's ~30 s restart.
|
||||
|
||||
To launch the tray at every login, open the GUI → Settings → tick **"Launch WinPodX tray at login (system tray icon + idle-stall auto-recovery)"**. The toggle writes / removes `~/.config/autostart/winpodx-tray.desktop` via the XDG autostart spec; portable across KDE / GNOME / XFCE / Cinnamon. The file is the source of truth — you can also drop it by hand to opt out without launching the GUI. Toggle applies immediately; no Save Settings click needed.
|
||||
|
||||
The tray watches the pod state every 30 s. On a `RUNNING → UNRESPONSIVE` transition (container alive long enough that an RDP-port miss can't be confused with a fresh boot) it fires a desktop notification and spawns a background worker that asks the agent to cycle Windows `TermService`. On recovery a "Pod recovered" notification fires; on failure a "needs manual restart" notification points at `winpodx pod restart`. While `install.sh` is running its `[3/4]` / `[4/4]` Sysprep + OEM-reboot phases, the marker file `~/.config/winpodx/.install_in_progress` suppresses the recovery path so genuine install-time RDP gaps don't fire spurious notifications.
|
||||
|
||||
## Host device passthrough
|
||||
|
||||
Pass a host USB or (non-GPU) PCI device through to the Windows guest (#286). Three surfaces drive the same backend:
|
||||
|
||||
* **CLI** — `winpodx device list` shows each host device + its attach state; `winpodx device attach <id>` / `winpodx device detach <id>` move one in or out.
|
||||
* **GUI Devices page** — a two-column host ↔ guest mover (pick on the left, attach on the right).
|
||||
* **System tray** — a USB switcher submenu (#300) for one-click attach / detach of host USB devices.
|
||||
|
||||
USB devices hot-plug live (`cfg.pod.usb_live`, default on) — no restart needed. A PCI device is boot-added and only becomes visible after a guest restart, so the attach is guarded by a safety confirmation; pass `--force` on the CLI (or confirm the dialog in the GUI) to proceed.
|
||||
|
||||
## Bare-metal compatibility mode (hide the hypervisor)
|
||||
|
||||
Some software refuses to run under a detected hypervisor — most notably Nvidia's consumer GPU drivers (they fault with **code 43** when they see KVM, the common GPU-passthrough blocker) and apps with launch-gate VM checks. `cfg.pod.disguise_level` (#246) hides the KVM/QEMU signature from the guest so it presents as a physical PC. It has **three levels, default `balanced`**:
|
||||
|
||||
| Level | What it does | Performance |
|
||||
|-------|--------------|-------------|
|
||||
| `off` | No disguise — an honest VM. | Best (and most compatible) |
|
||||
| `balanced` (default) | Clears the CPUID hypervisor bit + KVM signature, mirrors the host's SMBIOS/DMI, adds synthetic sensor descriptors, and advertises a bare-metal-looking disk size. | No measurable cost |
|
||||
| `max` | Everything in `balanced` **plus** emulated virtual hardware — disk → SATA (AHCI), network → e1000 (`MTU=1500`), GPU → std VGA, no virtio-rng — and `HV=N`. This removes the virtio (`VEN_1AF4`) / QXL (`VEN_1B36`) PCI IDs and the `vioscsi`/`viostor`/`netkvm` drivers that give a KVM guest away. (`MTU=1500` is required so dockur omits the `host_mtu=` flag that an e1000 NIC rejects.) **Requires a wipe + reinstall** (changing the boot-disk controller makes the existing install unbootable), so switching into/out of `max` prompts a strong confirmation and reinstalls Windows from scratch. | **Significantly slower** — emulated disk + NIC have much lower throughput than virtio, and Hyper-V enlightenments are off |
|
||||
|
||||
```bash
|
||||
winpodx config set pod.disguise_level off # honest VM
|
||||
winpodx config set pod.disguise_level balanced # default — free hiding, applied automatically
|
||||
winpodx config set pod.disguise_level max # maximum hiding (emulated HW, slower)
|
||||
# off <-> balanced apply automatically. Switching into/out of `max` changes the
|
||||
# virtual hardware, so it needs a destructive reinstall — apply it with:
|
||||
winpodx pod recreate --wipe-storage # WIPES Windows, reinstalls on the new hardware
|
||||
```
|
||||
|
||||
You can also pick the level in the GUI: **Settings → Bare-metal compatibility**. Either way it takes effect after a `winpodx pod recreate` (it edits the QEMU `-cpu` line, the `HV` env, and the disk size; recreate keeps your Windows disk).
|
||||
|
||||
**Advanced — patched-QEMU image (`winpodx disguise build-image`):** a couple of VM markers live in QEMU's compiled-in strings (ACPI OEM `BOCHS`, disk model `QEMU HARDDISK`) that command-line args can't reach. Run `winpodx disguise build-image` — it builds a custom dockur image whose QEMU has those strings patched to **your host's real vendor + disk model** (read from `/sys`, no root, nothing committed; ~20–40 min compile, local image only), then sets `cfg.pod.disguise_image` so `max` uses it. winpodx ships only the patch recipe (`packaging/qemu-disguise/`), never a patched binary. PCI vendor IDs are deliberately left alone (spoofing them breaks dockur's virtio-serial). See that directory's README.
|
||||
|
||||
**Not an anti-cheat bypass.** This is signature-level VM hiding for casual detectors and VM-hostile apps (code 43, DRM/launch gates). It does **not** defeat kernel-mode anti-cheat (EAC / BattlEye / Vanguard) — those anchor on hardware attestation (TPM + Secure Boot) and VM-exit timing the guest can't spoof — and bypassing online-game anti-cheat violates the game's ToS.
|
||||
|
||||
The disk bump is gated: the dockur disk is sparse, so a larger advertised size costs ~0 host space up front, but winpodx only raises it when the host has enough free space (keeping a 10 GiB / 10 % reserve). On a small host it leaves the disk as-is and logs a warning. The pre-0.6.x `disguise_hypervisor = false` key still works — it maps to `off`.
|
||||
|
||||
> **This is not an anti-cheat bypass.** It is signature-level only and does **not** defeat kernel-mode anti-cheat (EAC / BattlEye / Vanguard). Bypassing anti-cheat in online games violates their terms of service and risks a ban — winpodx does not support that use.
|
||||
|
||||
## Multi-monitor
|
||||
|
||||
Multi-monitor RAIL is on by default (`cfg.rdp.multimon`, default `"span"`): a remote-app window keeps working input when you drag it onto a second monitor. Values:
|
||||
|
||||
| `cfg.rdp.multimon` | Effect |
|
||||
|---|---|
|
||||
| `span` (default) | Span the RDP session across all monitors so a remote-app window stays interactive on any of them |
|
||||
| `multimon` | Use FreeRDP's discrete `/multimon` mode (per-monitor geometry) |
|
||||
| `off` | Single-monitor only |
|
||||
|
||||
Change it with `winpodx config set rdp.multimon off` or via the GUI Settings page.
|
||||
|
||||
## Health checks
|
||||
|
||||
`winpodx doctor` runs every probe used by the GUI Health card and prints a one-line verdict for each:
|
||||
|
||||
```
|
||||
=== WinPodX doctor ===
|
||||
|
||||
[OK ] pod_running running (ip=127.0.0.1) (58ms)
|
||||
[OK ] rdp_port 127.0.0.1:3390 reachable (0ms)
|
||||
[OK ] agent_health version=0.2.2-rev4 (63ms)
|
||||
[OK ] agent_auth_ready bearer token available (1ms)
|
||||
[OK ] oem_version bundle=24 (3ms)
|
||||
[OK ] password_age 7d remaining (max_age=7d) (0ms)
|
||||
[OK ] apps_discovered 41 app(s) in /home/.../discovered (3ms)
|
||||
[OK ] disk_free 401.0/3725 GiB free (0ms)
|
||||
|
||||
Overall: OK
|
||||
```
|
||||
|
||||
Status legend: `OK` (green) / `WARN` (yellow — informational, exit 0) / `FAIL` (red — exit 1) / `SKIP` (grey — disabled by config). Use `--json` for machine-readable output.
|
||||
|
||||
## Changing the Windows password
|
||||
|
||||
Use `winpodx rotate-password` — never reuse `winpodx setup` for this. The two have very different effects on an already-running install:
|
||||
|
||||
| Command | Host config (`winpodx.toml`) | Windows guest account |
|
||||
|---|---|---|
|
||||
| `winpodx rotate-password` | Updated atomically (with rollback on failure) | Updated via Windows-side change mechanism |
|
||||
| `winpodx setup` (rerun) | Preserved as-is (since v0.5.5) | Not touched |
|
||||
| `winpodx setup` (fresh install, no prior config) | Generated / prompted | Applied on first boot via dockur `USERNAME`/`PASSWORD` env vars |
|
||||
|
||||
Re-running `winpodx setup` to bump cores / RAM / `win_version` is safe and will not touch your credentials. On pre-v0.5.5 releases the wizard reprompted for the password every run and silently overwrote `winpodx.toml` — but dockur honors the password env var only on first boot, so the host config desynced from the Windows guest account and the next RDP launch failed with `LOGON_FAILED_BAD_PASSWORD`.
|
||||
|
||||
### Recovering from a desynced password (pre-v0.5.5 lockout)
|
||||
|
||||
If you ran `winpodx setup` on an older release and can no longer log in:
|
||||
|
||||
1. **Restore the old password** if you still have it (in a `winpodx.toml` backup, your password manager, or your shell history):
|
||||
```bash
|
||||
winpodx config set rdp.password '<old-password>'
|
||||
winpodx pod start
|
||||
winpodx rotate-password
|
||||
```
|
||||
2. **Otherwise**, the only path is `winpodx uninstall --purge` + reinstall, which loses any in-Windows state (installed apps, documents, settings). Make a fresh `winpodx setup` your first step after reinstall, then never touch the password through `setup` again — use `rotate-password`.
|
||||
|
||||
## Performance tuning profile
|
||||
|
||||
`cfg.pod.tuning_profile` controls how aggressively WinPodX tunes the dockur compose for the underlying host. It defaults to `"auto"` — WinPodX probes the host once at compose time and turns on the matching subset of safe Windows-on-KVM tweaks. Look at the `[Tuning]` block in `winpodx doctor` to see what was detected and applied:
|
||||
|
||||
```
|
||||
[Tuning]
|
||||
invtsc: yes (intel)
|
||||
io_uring: yes (kernel 6.18, need >= 5.6)
|
||||
hugepages: no (sysctl vm.nr_hugepages)
|
||||
dedicated: yes
|
||||
nested_kvm: yes (/sys/module/kvm_*/parameters/nested)
|
||||
|
||||
Profile: auto
|
||||
+invtsc: yes
|
||||
io_uring aio: yes
|
||||
hugepages: no
|
||||
CPU pinning: yes
|
||||
platform_tick: yes
|
||||
no balloon: yes
|
||||
hv-* + no-hpet: yes
|
||||
virtio-rng: yes
|
||||
nested virt: yes
|
||||
hv-evmcs: yes
|
||||
```
|
||||
|
||||
Profiles:
|
||||
|
||||
| `tuning_profile` | What it does |
|
||||
|---|---|
|
||||
| `auto` (default) | Detect host capability + apply every safe tuning the host can support, including the Hyper-V enlightenments, virtio-rng, and nested-virt pass-through when `/sys/module/kvm_*/parameters/nested` is set. CPU pinning + no-balloon gated on `dedicated_host` (idle CPU + free RAM ≥ 2× VM allocation) so we don't starve other host workloads. Recommended for most users. |
|
||||
| `performance` | Same as `auto` but bypasses the `dedicated_host` gate: CPU pinning + no-balloon flip on regardless of current host load. Use when the box is mostly dedicated to WinPodX and you want minimum guest latency at the cost of other host workloads. Hard-gated knobs (`+invtsc`, `io_uring`) still respect capability detection -- `performance` can't force a CPU flag QEMU would reject or a kernel feature that crashes. |
|
||||
| `safe` | Apply the Windows-guest-only subset that requires no host configuration: `+invtsc` (when supported), `platform_tick` BCD tweak, Hyper-V enlightenments (`hv-relaxed`, `hv-vapic`, `hv-vpindex`, `hv-runtime`, `hv-synic`, `hv-reset`, `hv-frequencies`, `hv-reenlightenment`, `hv-tlbflush`, `hv-ipi`, `hv-spinlocks=0x1fff`, `hv-stimer`, `hv-stimer-direct`, `-no-hpet`), and `virtio-rng`. Excludes nested-virt + `hv-evmcs` which need explicit host-side opt-in. |
|
||||
| `off` | Apply nothing; the dockur defaults stand. Use when troubleshooting suspected tuning interaction. |
|
||||
| `manual` | Same shape as `safe`; reserved for future per-knob overrides. |
|
||||
|
||||
### What each tuning does
|
||||
|
||||
* **`+invtsc`** — exposes invariant TSC so Windows uses TSC as the clock source instead of HPET (lower IRQ overhead).
|
||||
* **`hv-*` enlightenments + `-no-hpet`** (#245) — tells Windows it's running under a paravirtualised hypervisor. Cuts spinlock / VM-exit overhead on every workload; doubly noticeable on multi-vCPU guests. `hv-spinlocks=0x1fff` is the upstream-recommended retry budget.
|
||||
* **`virtio-rng-pci` backed by `/dev/urandom`** (#245) — fills the Windows entropy pool quickly on first boot so CryptoAPI / TLS handshakes don't stall waiting for kernel randomness.
|
||||
* **`+vmx` / `+svm` nested virt** (#245) — auto-enabled when `/sys/module/kvm_intel/parameters/nested` or `kvm_amd` reads `Y`. Required for Hyper-V / WSL2 / Docker Desktop inside the Windows guest. No effect when the host kernel hasn't opted in.
|
||||
* **`hv-evmcs`** (#245) — Intel-only nested-VMCS optimisation, paired with `+vmx`. Zero overhead when the guest doesn't run nested VMs.
|
||||
* **`io_uring` AIO** — kernel ≥ 5.6 disk I/O backend; lower latency than legacy threads.
|
||||
* **Hugepages** — backs the QEMU memory with 2 MB pages. Requires `vm.nr_hugepages` reserved on the host (WinPodX does not auto-reserve).
|
||||
* **CPU pinning** — WinPodX flags the host as `dedicated` and applies QEMU vCPU pinning when host idle CPU + RAM ≥ 2× VM allocation.
|
||||
|
||||
### One-shot override
|
||||
|
||||
`winpodx pod start --tuning {auto,safe,off,manual}` overrides `cfg.pod.tuning_profile` for the lifetime of that container run only. The user's persisted preference in `winpodx.toml` is left untouched. Useful for A/B testing — flip back and forth without `winpodx config set` round-trips.
|
||||
|
||||
### Items that require host-side setup (not auto-applied)
|
||||
|
||||
These are standard Windows-on-KVM tweaks that need operator action on the Linux host before WinPodX can take advantage of them. The `[Tuning]` block in `winpodx doctor` will show them as `no` until the host is set up; flipping to `yes` happens automatically the next time `cfg.pod.tuning_profile = auto` runs.
|
||||
|
||||
* **Transparent hugepages / explicit hugepages.** Set `vm.nr_hugepages` via `sysctl` (or use `madvise` THP) so the QEMU process can back its memory with hugepages. WinPodX detects `HugePages_Total > 0` in `/proc/meminfo` and skips the auto-apply if hugepages aren't reserved.
|
||||
* **CPU pinning.** WinPodX flags the host as `dedicated` when the current idle CPU + RAM is at least twice the VM's allocation. Pinning the QEMU thread to specific cores via `taskset` (or systemd `CPUAffinity=`) is then up to the operator; WinPodX will not modify host scheduling.
|
||||
* **VFIO GPU passthrough.** Out of scope for the RDP-based WinPodX architecture. (Non-GPU USB / PCI device passthrough *is* supported — see "Host device passthrough" below.) If you need bare-metal GPU performance, run your own GPU-passthrough Windows VM (for example with libvirt / virt-manager) and point WinPodX at its RDP endpoint using the `manual` backend.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file: `~/.config/winpodx/winpodx.toml` (auto-created, `0600` permissions)
|
||||
|
||||
```toml
|
||||
[rdp]
|
||||
user = "User"
|
||||
password = "" # Auto-generated random password
|
||||
password_updated = "" # ISO 8601 timestamp
|
||||
password_max_age = 7 # Days before auto-rotation (0 = disable)
|
||||
ip = "127.0.0.1"
|
||||
port = 3390
|
||||
scale = 100 # Auto-detected from your DE
|
||||
dpi = 0 # Windows DPI % (0 = auto)
|
||||
multimon = "span" # Multi-monitor RAIL: span | multimon | off
|
||||
extra_flags = "" # Additional FreeRDP flags (allowlisted); e.g.
|
||||
# "+multitouch" — touchscreen / stylus / pen
|
||||
# passthrough into Windows apps (#623)
|
||||
# "-gfx" — legacy GDI path (RAIL render workaround)
|
||||
|
||||
[pod]
|
||||
backend = "podman"
|
||||
win_version = "11" # 11 | 10 | ltsc11 | ltsc10 | iot11 | tiny11 | tiny10 | 2025 | 2022 | 2019 | 2016 — see ARCHITECTURE.md for custom ISOs
|
||||
keyboard = "en-US" # Windows install locale; also mapped to the FreeRDP session layout (/kbd:layout) so non-US keyboards work in RemoteApp windows (#660)
|
||||
cpu_cores = 4
|
||||
ram_gb = 4
|
||||
vnc_port = 8007
|
||||
auto_start = false # Opt-in login auto-start: tray starts the pod on login (toggle via `winpodx autostart on|off|status`)
|
||||
idle_timeout = 0 # Seconds before auto-suspend (0 = disabled)
|
||||
boot_timeout = 300 # Seconds to wait for first-boot unattended install
|
||||
image = "docker.io/dockurr/windows:latest" # Container image (override for air-gapped mirror)
|
||||
usb_live = true # Hot-plug attached USB devices into the running guest (no restart) — see `winpodx device`
|
||||
# disguise_level = "balanced" # Bare-metal mode: off | balanced (default, free hiding) | max (Hyper-V off, slower) — Nvidia code-43 / VM-hostile apps; not an anti-cheat bypass (#246)
|
||||
disk_size = "64G" # Virtual disk size passed to dockur (grows via `install grow-disk`)
|
||||
disk_autogrow = true # Auto-grow C: when it fills past the threshold (idle only)
|
||||
disk_autogrow_threshold_pct = 80 # Used-% that triggers an auto-grow (50-99)
|
||||
disk_autogrow_target_free_pct = 30 # Grow is sized to restore this much free (not a flat step)
|
||||
disk_autogrow_increment = "32G" # Grow granularity / minimum step
|
||||
disk_max_size = "" # Optional hard ceiling; empty = bounded only by host free space
|
||||
guest_autosync = true # After a host upgrade, push updated guest artifacts in (no reinstall)
|
||||
|
||||
[ui]
|
||||
language = "auto" # UI language: auto | en | ko | zh | ja | de | fr | it (auto = host locale, falls back to English; change via `winpodx language` or GUI Settings)
|
||||
|
||||
[desktop]
|
||||
mime_associations = true # Discovered apps offer their real file types in the file manager's "Open with" (#545); never set as the default handler
|
||||
full_app_scan = false # false = Start-Menu-only discovery (clean menu, folder-grouped); true = also scan registry App Paths / Chocolatey / Scoop / all UWP (#581) — for portable apps with no Start Menu entry
|
||||
|
||||
[reverse_open]
|
||||
enabled = true # Default since v0.5.0
|
||||
allow = [] # Empty = all discovered apps
|
||||
deny = [] # Apps to exclude from the manifest
|
||||
|
||||
[logging]
|
||||
level = "INFO" # DEBUG | INFO | WARNING | ERROR | CRITICAL | RAW — RAW = DEBUG + pod logs (podman logs -f) interleaved in GUI Terminal
|
||||
```
|
||||
|
||||
Edit via `winpodx config set <key> <value>` or directly with your editor — TOML is parsed via the stdlib on Python 3.11+ (`tomli` on 3.9/3.10).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
# Guest HTTP Agent — v2 Design
|
||||
|
||||
**Status**: Draft, feature branch `feat/agent-v2`. Targeting a future release
|
||||
after end-to-end verification on a real container.
|
||||
|
||||
**Background**: v0.2.2 / v0.2.2.1 / v0.2.2.2 attempted to introduce a guest
|
||||
HTTP agent. The attempt shipped broken (compose port never exposed, token
|
||||
delivery had chicken-and-egg, gate detection unreliable). All those releases
|
||||
were rolled back; main is back at v0.2.1 + install.sh improvements.
|
||||
|
||||
This document is the new specification. It is written so each component can
|
||||
be implemented and tested independently, and so the lessons from the v0.2.2.x
|
||||
chain are explicit constraints rather than implicit assumptions.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Sub-100ms host -> guest call path for non-secret operations (apply payloads,
|
||||
log streaming, discovery progress).
|
||||
- Hide the brief PowerShell-window flash that FreeRDP RemoteApp exec produces.
|
||||
- Provide a definitive "Windows is ready" signal so the host can stop
|
||||
guessing whether install.bat / OOBE has finished.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replace FreeRDP RemoteApp. The RemoteApp channel keeps password rotation,
|
||||
app launches (those need a real RDP session anyway), and any operation that
|
||||
must stay sensitive.
|
||||
- Be reachable from anywhere except the host that owns the pod. Bind to
|
||||
loopback only, on both legs.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host Container (Linux) Windows VM
|
||||
QEMU process inside inside QEMU
|
||||
+-------------+ compose +----------+ QEMU +-------------+
|
||||
| AgentClient |--port 8765--+ | |--user-mode--| agent.ps1 |
|
||||
| (Python) | 127.0.0.1 | | :8765 | hostfwd | :8765 HTTP |
|
||||
+-------------+ :8765:8765 +--| | :8765-:8765+-------------+
|
||||
+----------+
|
||||
^
|
||||
|
|
||||
USER_PORTS=8765 env var
|
||||
dockur translates this into
|
||||
QEMU's `-netdev hostfwd=tcp::8765-:8765`
|
||||
```
|
||||
|
||||
Two-stage forwarding, both legs **explicit and visible in compose.yaml**:
|
||||
|
||||
1. `environment: USER_PORTS: "8765"` — dockur's contract for adding a QEMU
|
||||
hostfwd. Forwards Windows VM's loopback 8765 to the Linux container's
|
||||
loopback 8765.
|
||||
2. `ports: - "127.0.0.1:8765:8765/tcp"` — podman/docker's standard port
|
||||
mapping. Forwards the container's loopback 8765 to the host's loopback
|
||||
8765.
|
||||
|
||||
**Both legs are required.** v0.2.2 shipped only leg 1 — the agent listened
|
||||
inside Windows, dockur forwarded it to the container, but no host port
|
||||
mapping meant `curl http://127.0.0.1:8765/health` from the host always got
|
||||
connection-refused. That single missing line is what made the entire
|
||||
v0.2.2 agent feature dead-on-arrival.
|
||||
|
||||
## Token delivery
|
||||
|
||||
**Pre-stage the token via the existing OEM bind mount.** No FreeRDP roundtrip
|
||||
required. No race with install.bat.
|
||||
|
||||
```
|
||||
~/.config/winpodx/ config/oem/ dockur lays C:\OEM\agent_token.txt
|
||||
agent_token.txt --copy--> agent_token.txt --mount-->/oem--copy--+
|
||||
(0600, host) (0600, host repo) (compose) v
|
||||
agent.ps1 reads
|
||||
Wait-Token loop
|
||||
binds 8765 with
|
||||
bearer auth
|
||||
```
|
||||
|
||||
At `winpodx setup` time:
|
||||
|
||||
1. `ensure_agent_token()` generates / loads `~/.config/winpodx/agent_token.txt`
|
||||
(mode 0600).
|
||||
2. The same content is copied to `<oem_dir>/agent_token.txt` (mode 0600).
|
||||
3. compose.yaml mounts `<oem_dir>:/oem:Z` (already does, since v0.1.6).
|
||||
4. dockur's first-boot install copies `/oem/*` to `C:\OEM\` inside Windows.
|
||||
5. `agent.ps1` (started via HKCU\Run at user logon, registered by install.bat)
|
||||
reads `C:\OEM\agent_token.txt` on startup and binds the HTTP listener.
|
||||
|
||||
`config/oem/agent_token.txt` is .gitignore'd — it carries the per-install
|
||||
secret and must never enter the repo.
|
||||
|
||||
If the OEM mount somehow fails to deliver the file (corner case), agent.ps1's
|
||||
Wait-Token loop polls every few seconds with bounded backoff, so the listener
|
||||
binds whenever the token eventually appears.
|
||||
|
||||
## Readiness signal
|
||||
|
||||
`GET /health` returns 200 with a small JSON status payload. **No auth on
|
||||
/health.** It is the single, definitive "agent is up, install.bat is done,
|
||||
rdprrap is active" signal. Everything downstream gates on it:
|
||||
|
||||
- `provisioner.ensure_ready` waits for /health to respond before firing
|
||||
any FreeRDP RemoteApp work that could race install.bat.
|
||||
- `winpodx pod wait-ready` polls /health.
|
||||
- GUI status timer probes /health to render readiness state.
|
||||
|
||||
If /health doesn't respond, **no FreeRDP RemoteApp probes are fired
|
||||
speculatively**. The previous "Phase 3 ping storm" (3s probe interval, ~100
|
||||
PS-window flashes per resume) is gone. The host simply waits or surfaces
|
||||
"still booting" to the user, and tries again on the next user-driven
|
||||
ensure_ready (next app launch).
|
||||
|
||||
## Auth
|
||||
|
||||
Every endpoint except `/health` requires `Authorization: Bearer <token>`.
|
||||
The token is the same 32-byte hex string in both:
|
||||
|
||||
- `~/.config/winpodx/agent_token.txt` (host, read by `AgentClient._token()`)
|
||||
- `C:\OEM\agent_token.txt` (guest, read by agent.ps1 at startup)
|
||||
|
||||
Constant-time compare. Any mismatch returns 401.
|
||||
|
||||
## Endpoints (phased)
|
||||
|
||||
### Phase 1 — readiness only
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|----------|------|-----------------------------------------------------------|
|
||||
| GET | /health | no | Liveness + version probe. Used as the readiness signal. |
|
||||
|
||||
This is enough to **prove the architecture works end-to-end**: host sees
|
||||
agent, gate logic stops guessing.
|
||||
|
||||
### Phase 2 — exec channel
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|----------|------|-----------------------------------------------------------|
|
||||
| POST | /exec | yes | Run a base64-encoded PowerShell script, 60s server cap. |
|
||||
|
||||
Replaces `run_in_windows` (FreeRDP RemoteApp) for non-sensitive payloads in
|
||||
`_self_heal_apply`.
|
||||
|
||||
### Phase 3 — streaming
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------------------|------|--------------------------------------------------|
|
||||
| GET | /events | yes | Long-lived SSE feed of agent log events. |
|
||||
| POST | /apply/{step} | yes | Multi-step apply with progress lines via SSE. |
|
||||
| POST | /discover | yes | App discovery with progress + final JSON. |
|
||||
|
||||
Used by GUI Logs page, Maintenance page, Refresh button.
|
||||
|
||||
## Components
|
||||
|
||||
```
|
||||
src/winpodx/core/agent.py AgentClient: health(), exec(), stream_events(),
|
||||
post_apply(), post_discover(); exception types.
|
||||
|
||||
src/winpodx/utils/agent_token.py ensure_agent_token() / token_path() — host side.
|
||||
|
||||
config/oem/agent/agent.ps1 HTTP server inside Windows. Wait-Token loop,
|
||||
HttpListener on 127.0.0.1:8765, SSE plumbing.
|
||||
|
||||
config/oem/install.bat Adds: copy agent.ps1 to C:\OEM\, register
|
||||
HKCU\Run entry. (No \\tsclient\home copy —
|
||||
token is in the OEM mount already.)
|
||||
|
||||
src/winpodx/core/compose.py USER_PORTS=8765 env + 127.0.0.1:8765:8765/tcp
|
||||
port mapping in the compose template.
|
||||
|
||||
src/winpodx/cli/setup_cmd.py Stages token to oem_dir/agent_token.txt at
|
||||
setup time (helper: _ensure_oem_token_staged).
|
||||
|
||||
src/winpodx/core/provisioner.py Uses AgentClient.health() as the readiness
|
||||
gate; defers FreeRDP work when it doesn't
|
||||
respond.
|
||||
```
|
||||
|
||||
## Phased rollout
|
||||
|
||||
Each phase is a standalone PR-sized chunk. **Each phase ends with the user
|
||||
running `--main` install on a real container** and confirming the phase
|
||||
works before the next phase starts. No phase merges to main without
|
||||
end-to-end verification.
|
||||
|
||||
### Phase 1 — minimal /health roundtrip
|
||||
- compose.py: USER_PORTS env + port mapping
|
||||
- agent_token.py: token generation
|
||||
- setup_cmd.py: stage token to oem_dir
|
||||
- agent.ps1: minimal HttpListener with /health only (no auth needed)
|
||||
- install.bat: copy agent.ps1 + HKCU\Run
|
||||
- agent.py: AgentClient.health() only
|
||||
- Tests: unit + compose port + agent.ps1 syntax
|
||||
|
||||
User test: `curl http://127.0.0.1:8765/health` returns JSON.
|
||||
|
||||
### Phase 2 — auth + exec
|
||||
- agent.ps1: bearer auth on every endpoint except /health, /exec endpoint
|
||||
- agent.py: AgentClient.exec(), exception types
|
||||
- Tests: auth pass / fail, exec rc + stdout/stderr handling
|
||||
|
||||
User test: `winpodx pod exec 'Write-Output ok'` round-trips via agent.
|
||||
|
||||
### Phase 3 — provisioner integration
|
||||
- provisioner.py: ensure_ready waits for /health; defers FreeRDP if not up
|
||||
- _self_heal_apply uses run_via_agent_or_freerdp
|
||||
- Tests: gate semantics, fallback on agent down
|
||||
|
||||
User test: app launch on first boot completes without dialog flashes.
|
||||
|
||||
### Phase 4 — streaming
|
||||
- agent.ps1: /events SSE, /apply/{step}, /discover with progress
|
||||
- agent.py: stream_events, post_apply, post_discover
|
||||
- Tests: SSE parsing, done-event handling
|
||||
|
||||
User test: Logs page in GUI live-tails agent events.
|
||||
|
||||
### Phase 5 — GUI integration
|
||||
- Replace FreeRDP-only paths in GUI with agent paths where applicable
|
||||
- Refresh button uses agent /discover
|
||||
- Logs page uses /events
|
||||
|
||||
User test: GUI feels snappier; no PS flashes on routine actions.
|
||||
|
||||
## Anti-goals (lessons from v0.2.2.x)
|
||||
|
||||
These are explicit "do NOT do" rules.
|
||||
|
||||
1. **Do not deliver the token via FreeRDP RemoteApp.** That created the
|
||||
chicken-and-egg loop: agent needed token to bind, token needed FreeRDP
|
||||
to land, FreeRDP needed rdprrap to avoid dialog conflicts, rdprrap was
|
||||
set up by install.bat which raced the token push.
|
||||
2. **Do not invent a "windows is done" heuristic from log strings,
|
||||
container age, or any signal other than /health.** Every heuristic we
|
||||
tried (dockur sentinel, time fallback, parse-Go-format-timestamps)
|
||||
either fired too early or too late. /health responding is the only
|
||||
unambiguous proof that install.bat completed AND rdprrap activated AND
|
||||
the agent could bind.
|
||||
3. **Do not fire FreeRDP RemoteApp probes speculatively.** Each one is
|
||||
a visible PS window + a chance to trip a single-session conflict
|
||||
dialog. If the agent is silent, surface "still booting" to the user
|
||||
and let them retry; do not poll FreeRDP every 3s for 5 minutes.
|
||||
4. **Do not omit the compose port mapping.** Without
|
||||
`127.0.0.1:8765:8765/tcp`, the agent's listener inside Windows is
|
||||
functionally invisible to the host. This single line is what made
|
||||
v0.2.2 dead on arrival.
|
||||
5. **Do not register the agent via `schtasks /SC ONLOGON /RU User /RL
|
||||
HIGHEST`.** The principal name doesn't always match cfg.rdp.user, and
|
||||
/RL HIGHEST races dockur's autologon UAC flow. Use HKCU\\...\\Run —
|
||||
identical to the existing WinpodxMedia entry, fires for whichever
|
||||
account autologon picks.
|
||||
6. **Do not throw on missing token.** agent.ps1 must Wait-Token-style
|
||||
poll until the file appears. Throwing kills the process and HKCU\\Run
|
||||
doesn't auto-restart.
|
||||
7. **Do not block CLI launch on a synchronous resume.** The
|
||||
`_maybe_resume_pending` path must skip for `gui` / `tray` so the GUI
|
||||
window paints before the resume worker runs in a thread.
|
||||
|
||||
## Test strategy
|
||||
|
||||
### Per-phase
|
||||
|
||||
- Unit (Python): mock HTTP for AgentClient, mock subprocess for token
|
||||
generation, mock filesystem for OEM staging.
|
||||
- Compose validation: parse generated yaml, assert both forwarding-chain
|
||||
legs are present.
|
||||
- agent.ps1 syntax: `pwsh -NoProfile -Command "& { ... parse }"` to catch
|
||||
syntax errors without running it.
|
||||
- agent.ps1 endpoint coverage: regex-scan agent.ps1 for the expected
|
||||
endpoint handlers.
|
||||
|
||||
### End-to-end (user-driven)
|
||||
|
||||
After each phase ships to `feat/agent-v2`, the user runs:
|
||||
|
||||
```
|
||||
curl ... install.sh | bash -s -- --ref feat/agent-v2
|
||||
```
|
||||
|
||||
on a clean container (or an existing one — port binding and OEM mount
|
||||
both apply on container recreate, no Windows reinstall) and confirms the
|
||||
phase's user-test behaves correctly.
|
||||
|
||||
Only after the user confirms does the phase get cherry-picked or rebased
|
||||
to main, and a new RTM tag goes out only when the user explicitly says
|
||||
"approve".
|
||||
@@ -0,0 +1,110 @@
|
||||
# Guest sync — apply host-side updates to a running guest without reinstall
|
||||
|
||||
## Problem
|
||||
|
||||
Upgrading WinPodX on the host updates the host binary, but the **guest-side**
|
||||
artifacts that were staged at first install go stale until the user wipes and
|
||||
reinstalls Windows:
|
||||
|
||||
- `C:\OEM\agent.ps1` — the in-guest agent (bind/retry logic, endpoints).
|
||||
- `install.bat`-derived runtime state — urlacl reservation (#269), RDP /
|
||||
multi-session registry, Defender exclusions, TermService recovery, NIC tweaks.
|
||||
- Guest binaries — rdprrap zip, `shim.exe` (reverse-open), `rcedit.exe`.
|
||||
|
||||
`winpodx pod apply-fixes` already re-applies *some* idempotent registry fixes,
|
||||
but it does **not** refresh `agent.ps1`, the urlacl reservation, or the guest
|
||||
binaries — and it is manual. Users on an upgraded host silently run an old
|
||||
guest until they reinstall.
|
||||
|
||||
## Key enabler
|
||||
|
||||
`/oem` is a **live bind mount** of the host's `config/oem`
|
||||
(`{oem_dir}:/oem:Z` in compose.py). So after a host upgrade the container's
|
||||
`/oem` already contains the new agent.ps1 / rdprrap / scripts — no image
|
||||
rebuild. Delivery into the guest is the same channel `winpodx pod recover-oem`
|
||||
already uses (tar `/oem` → container HTTP :8766 → guest pulls via the QEMU NAT
|
||||
gateway `10.0.2.2`), except for sync the **agent is alive**, so it runs over
|
||||
`/exec` automatically instead of noVNC paste.
|
||||
|
||||
## Version stamp
|
||||
|
||||
The guest records what provisioned it at
|
||||
`C:\winpodx\install-state\guest_version.json`:
|
||||
|
||||
```json
|
||||
{ "winpodx": "0.5.8", "oem_bundle": "25" }
|
||||
```
|
||||
|
||||
- Host current = `winpodx.__version__` + `core.info._bundled_oem_version()`.
|
||||
- `read_guest_version()` reads the file via `/exec`; missing/old/unparseable →
|
||||
treated as "needs sync".
|
||||
- Written **only after** a fully successful sync (or by the installer on a
|
||||
fresh provision).
|
||||
|
||||
`guest_sync_needed(cfg)` returns True when the guest stamp differs from the
|
||||
host pair (or is absent).
|
||||
|
||||
## Sync flow (`core/guest_sync.py :: sync_guest`)
|
||||
|
||||
All steps idempotent; ordered so a partial failure is safe to re-run.
|
||||
|
||||
1. **Deliver `/oem` to the guest.** Tar `/oem` in the container into a
|
||||
dedicated serve dir, start the :8766 HTTP server (reuse the recover-oem
|
||||
container ops), then over `/exec`:
|
||||
`Invoke-WebRequest http://10.0.2.2:8766/oem.tar.gz` → `tar -xzf` into
|
||||
`C:\OEM`. This refreshes `agent.ps1`, rdprrap, `shim.exe`, `rcedit.exe`,
|
||||
and the helper scripts in one shot. **`install.bat` is NOT run** — it
|
||||
contains one-shot first-boot logic (autologon, account setup) that must not
|
||||
re-run on a live install.
|
||||
2. **urlacl reservation** (#269) — port install.bat's netsh block to `/exec`:
|
||||
delete the overlapping 8765 reservations, then
|
||||
`netsh http add urlacl url=http://+:8765/ sddl=D:(A;;GX;;;WD)`.
|
||||
3. **Idempotent registry/runtime fixes** — call the existing
|
||||
`provisioner.apply_windows_runtime_fixes(cfg)` (max_sessions, rdp_timeouts,
|
||||
oem_runtime_fixes, vbs_launchers, multi_session). This also re-activates
|
||||
rdprrap against the refreshed binaries.
|
||||
4. **Restart the agent** — the agent is what `/exec` runs through, so it can't
|
||||
kill itself synchronously. Register a **one-shot scheduled task** that fires
|
||||
in ~5 s to stop the current agent process and relaunch `C:\OEM\agent.ps1`
|
||||
(the HKCU\Run command). The `/exec` call returns before the task fires; the
|
||||
new agent binds 8765 with the now-correct urlacl.
|
||||
5. **Write the version stamp** — only if steps 1–3 succeeded (step 4 is
|
||||
fire-and-forget; readiness is reconfirmed by the caller).
|
||||
|
||||
`sync_guest` returns a per-step result map (like `apply_windows_runtime_fixes`)
|
||||
so CLI/GUI can render rows.
|
||||
|
||||
## Triggers
|
||||
|
||||
- **Auto** (default on): after the pod is responsive (`provisioner.ensure_ready`
|
||||
/ `pod wait-ready` tail), if `guest_sync_needed` → run `sync_guest`. Cheap
|
||||
no-op when versions match. Gated to podman/docker.
|
||||
- **Manual**: `winpodx pod sync-guest [--force]` and a GUI **Tools → Sync
|
||||
Guest** action. `--force` re-syncs even when the stamp matches.
|
||||
|
||||
Config: `pod.guest_autosync` (bool, default True). `false` = only manual.
|
||||
|
||||
## Risks / mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Agent restart drops the `/exec` we're inside | one-shot scheduled task fires *after* the call returns; never `Stop-Process` the agent synchronously |
|
||||
| rdprrap re-activation disconnects a live session | auto-sync is post-readiness at pod start (no user session yet); manual sync warns |
|
||||
| Partial sync leaves mixed state | every step idempotent + re-runnable; stamp written only on success so the next start retries |
|
||||
| `/oem` tar exposes `/storage` | serve a dedicated dir with only `oem.tar.gz` (same as recover-oem) |
|
||||
| Download integrity | size + extract check; agent_token already shared, no new trust boundary |
|
||||
|
||||
## Real-Windows smoke checklist (gate before merge/release)
|
||||
|
||||
Guest-side `/exec` work — pwsh-on-Linux Pester is **not** sufficient.
|
||||
|
||||
1. Upgrade host WinPodX (bump `oem_bundle`), start an existing pod.
|
||||
→ auto-sync runs; `guest_version.json` updates to the new pair.
|
||||
2. `C:\OEM\agent.ps1` matches the new host copy (hash/length).
|
||||
3. Agent rebinds 8765 after the restart task (no manual step); `winpodx check`
|
||||
shows agent reachable.
|
||||
4. `netsh http show urlacl url=http://+:8765/` shows the WD SID reservation.
|
||||
5. rdprrap still active; multi-session still works.
|
||||
6. `winpodx pod sync-guest --force` on an up-to-date guest is a clean no-op
|
||||
(no session disruption, exit 0).
|
||||
7. Re-run sync after killing it mid-flight → converges (idempotency).
|
||||
@@ -0,0 +1,570 @@
|
||||
# WinPodX 라이프사이클 & 프로세스
|
||||
|
||||
WinPodX pod 의 설치 / 업그레이드 / 마이그레이션 / 헬스 유지 전 과정을 코드 경로 단위로 설명. 각 섹션은 한 단계씩 — 누가 발사하나, 무엇을 하나, 코드가 어디 있나, 어떤 실패 모드를 다루나.
|
||||
|
||||
> **대상.** WinPodX 의 어떤 코드 경로든 이해하거나 디버그해야 하는 메인테이너 / 고급 사용자. 일상 사용은 [README.ko.md](README.ko.md) 참고.
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [전체 단계 개요](#1-전체-단계-개요)
|
||||
2. [신규 설치](#2-신규-설치-기존-config-없음)
|
||||
3. [Sysprep 첫 부팅 (install.bat)](#3-sysprep-첫-부팅-installbat)
|
||||
4. [업그레이드 설치 (기존 config 있음)](#4-업그레이드-설치-기존-config-있음)
|
||||
5. [마이그레이션 (`winpodx migrate`)](#5-마이그레이션-winpodx-migrate)
|
||||
6. [Apply 체인 (`apply_windows_runtime_fixes`)](#6-apply-체인-apply_windows_runtime_fixes)
|
||||
7. [Multi-session 활성화](#7-multi-session-활성화)
|
||||
8. [컨테이너 이미지 pinning](#8-컨테이너-이미지-pinning)
|
||||
9. [Discovery (`winpodx app refresh`)](#9-discovery-winpodx-app-refresh)
|
||||
10. [Transport 선택](#10-transport-선택-agent-vs-freerdp)
|
||||
11. [Guest sync (`winpodx pod sync-guest`)](#11-guest-sync-winpodx-pod-sync-guest)
|
||||
12. [Disk auto-grow](#12-disk-auto-grow)
|
||||
13. [로그인 시 pod 자동 시작](#13-로그인-시-pod-자동-시작-opt-in)
|
||||
14. [복구 시나리오](#14-복구-시나리오)
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 단계 개요
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ install.sh │
|
||||
│ (호스트 측) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌─────────────────┴──────────────────┐
|
||||
│ │
|
||||
기존 config 없음 기존 config 있음
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌─────────────────────┐
|
||||
│ winpodx setup │ │ setup 스킵 │
|
||||
│ (대화형 또는 │ │ agent token staging │
|
||||
│ default) │ └──────────┬──────────┘
|
||||
│ 작성: │ │
|
||||
│ winpodx.toml │ │
|
||||
│ compose.yaml │ │
|
||||
│ agent_token │ │
|
||||
└───────┬───────┘ │
|
||||
│ │
|
||||
└──────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ winpodx pod │
|
||||
│ wait-ready │ (3 단계: 컨테이너,
|
||||
│ │ RDP 포트, FreeRDP probe)
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ winpodx migrate │ (기존 config 있을 때만)
|
||||
│ - 버전 비교 │
|
||||
│ - image pin 정렬 │
|
||||
│ - apply 체인 │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ winpodx app refresh │
|
||||
│ (3 계층 race-free │
|
||||
│ discovery) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
상단 박스 3개가 진입점, 나머지는 구현. 아래에 각각 상세 설명.
|
||||
|
||||
---
|
||||
|
||||
## 2. 신규 설치 (기존 config 없음)
|
||||
|
||||
**트리거.** `~/.config/winpodx/winpodx.toml` 부재. 보통 처음 사용자가 `curl -sSL .../install.sh | bash` 돌릴 때.
|
||||
|
||||
**흐름.**
|
||||
|
||||
1. `install.sh` 가 distro 확인, 누락 의존성 설치 (podman, podman-compose, freerdp, libnotify), Python ≥ 3.9 검증.
|
||||
2. `install.sh` 가 winpodx 소스를 `~/.local/bin/winpodx-app/` 에 추출 + `winpodx` 런처를 `~/.local/bin/winpodx` 에 작성.
|
||||
3. `install.sh` 가 `python3 -m winpodx setup --non-interactive` 호출 (`src/winpodx/cli/setup_cmd.py::handle_setup`).
|
||||
4. setup 이 `~/.config/winpodx/winpodx.toml` 작성:
|
||||
- `cfg.pod.image` default = `DOCKUR_IMAGE_PIN` (SHA-pinned `docker.io/dockurr/windows@sha256:…` digest — [§8](#8-컨테이너-이미지-pinning) 참고).
|
||||
- `cfg.rdp.password` 무작위 생성.
|
||||
- `cfg.pod.backend` 자동 감지 (podman > docker > libvirt).
|
||||
5. setup 이 `generate_compose(cfg)` 실행 → `~/.config/winpodx/compose.yaml` 작성.
|
||||
6. setup 이 `_ensure_oem_token_staged()` 실행 → `~/.config/winpodx/agent_token.txt` 작성 + OEM bind-mount source 디렉터리에 복사.
|
||||
7. `install.sh` 가 `winpodx pod wait-ready --timeout 3600 --logs` 호출. dockur 가 pinned image 받고, Windows ISO (~7.5GB) 다운로드, 우리 OEM bundle 로 Sysprep ([§3](#3-sysprep-첫-부팅-installbat)) — 보통 5-10분.
|
||||
8. migrate **스킵** (`installed_version.txt` 비교 대상 없음).
|
||||
9. `install.sh` 가 `winpodx app refresh` 호출 ([§9](#9-discovery-winpodx-app-refresh)).
|
||||
|
||||
**최종 상태.** 완전 프로비저닝된 pod, multi-session 활성, agent 가 wscript wrapper 로 동작, 앱 메뉴 채워짐.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sysprep 첫 부팅 (`install.bat`)
|
||||
|
||||
**위치.** `config/oem/install.bat`. OEM bind-mount 안에 있음 → dockur 가 첫 부팅 시 `C:\OEM\` 으로 복사 → `unattend.xml` 의 `FirstLogonCommands` 가 자동로그온 사용자의 로컬 콘솔 세션에서 1회 호출.
|
||||
|
||||
`WINPODX_OEM_VERSION` (파일 상단) 이 번들 버전. install.bat 또는 형제 리소스 변경 시 릴리스마다 bump.
|
||||
|
||||
**하는 일, 순서대로.**
|
||||
|
||||
1. **TermService 복구 동작** — `sc.exe failure TermService reset= 86400 actions= restart/5000/...`. transient TermService crash 자동 복구.
|
||||
2. **MaxInstanceCount + multi-session 레지스트리** — `HKLM:\...\Terminal Server\WinStations\RDP-Tcp\MaxInstanceCount`, `fSingleSessionPerUser = 0`. OEM 시점의 권위 있는 cap; runtime apply 체인이 `cfg.pod.max_sessions` 변경 시 동기화.
|
||||
3. **rdprrap install** (multi-session 활성화):
|
||||
- SHA256 검증된 번들을 `C:\OEM\rdprrap-*.zip` → `C:\winpodx\rdprrap\` 추출.
|
||||
- install + verify + marker 는 **`rdprrap-activate.ps1`** 에 위임 (OEM-time + runtime 활성화의 single source of truth; [§7](#7-multi-session-활성화) 참고).
|
||||
4. **NIC / RDP 타임아웃 설정** — idle / disconnect / connection 타임아웃 비활성화해서 RemoteApp 세션이 1시간 후 끊기지 않도록.
|
||||
5. **media_monitor.ps1 staging + autostart** — `media_monitor.ps1` 을 `C:\winpodx\` 에 복사하고 `HKCU\Run\WinpodxMedia` 를 **wscript+hidden-launcher.vbs wrapper** 로 등록 (OEM v19 부터 — 이전 버전은 bare `powershell.exe -WindowStyle Hidden` 이라 ~50ms conhost flash 새어나옴).
|
||||
6. **VBS 런처 staging** — `hidden-launcher.vbs`, `launch_uwp.vbs`, `launch_uwp.ps1`, `agent-respawn.ps1`, `rdprrap-activate.ps1` 을 `C:\Users\Public\winpodx\launchers\` 에 복사. Public 디렉터리는 누구나 쓰기 가능 → User-level agent 가 나중에 runtime 마이그레이션 때 덮어쓸 수 있음.
|
||||
7. **Agent autostart** — `HKCU\Run\WinpodxAgent` 를 wscript+hidden-launcher.vbs wrapper 가 `C:\OEM\agent.ps1` 가리키게 등록.
|
||||
8. **URL ACL 사전 등록** — agent 의 `http://+:8765/` 리스너용 `netsh http add urlacl`. User-level agent 가 admin 권한 없이 bind 가능.
|
||||
9. **OEM 마커** — `C:\winpodx\oem_version.txt` 작성 → 호스트가 어떤 번들로 프로비저닝됐는지 probe 가능.
|
||||
|
||||
**Idempotency.** install.bat 은 **1회** 만 실행. Sysprep 끝나면 dockur 가 절대 재실행 안 함. 이후 모든 유지보수는 호스트의 apply 체인이 처리 ([§6](#6-apply-체인-apply_windows_runtime_fixes)).
|
||||
|
||||
---
|
||||
|
||||
## 4. 업그레이드 설치 (기존 config 있음)
|
||||
|
||||
**트리거.** `~/.config/winpodx/winpodx.toml` 이미 존재. 보통 `curl -sSL .../install.sh | bash -s -- --main` 로 기존 WinPodX 업데이트.
|
||||
|
||||
**흐름.**
|
||||
|
||||
1. `install.sh` 가 소스를 `~/.local/bin/winpodx-app/` 재추출 (호스트 코드 업데이트).
|
||||
2. `winpodx setup --non-interactive` 실행 — 기존 config 감지, `Existing config found ..., skipping setup` 출력, `_ensure_oem_token_staged()` 실행, return. **여기서 compose 재생성 안 함**, 즉 동작 중인 pod 은 소스 코드 업데이트로 흔들리지 않음.
|
||||
3. `winpodx pod wait-ready` 실행 — 컨테이너가 이미 떠 있고 따뜻하니 보통 몇 초 안에 완료.
|
||||
4. `winpodx migrate` 실행 ([§5](#5-마이그레이션-winpodx-migrate)) — 기존 pod 마이그레이션의 canonical 위치.
|
||||
5. `winpodx app refresh` 실행 → 발견된 앱 메뉴 갱신.
|
||||
|
||||
게스트를 변경하는 건 migrate 만. install.sh 자체는 step 2 이후 순수 호스트 측.
|
||||
|
||||
---
|
||||
|
||||
## 5. 마이그레이션 (`winpodx migrate`)
|
||||
|
||||
**코드.** `src/winpodx/cli/migrate.py::run_migrate`.
|
||||
|
||||
**목표.** 기존 pod 을 *main fresh install 이 만들 상태*로 정렬. 3개 독립 단계가 순서대로 실행:
|
||||
|
||||
```
|
||||
installed_version vs current
|
||||
│
|
||||
┌────────────┼─────────────┐
|
||||
│ │ │
|
||||
None already cross-version
|
||||
(fresh) current (예: 0.1.7 → 0.3.1)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
버전 기록 apply 체인 whats-new 출력
|
||||
실행 + apply 체인
|
||||
+ (선택) refresh
|
||||
```
|
||||
|
||||
### 5.1 버전 감지
|
||||
|
||||
`_detect_installed_version()` 이 `~/.config/winpodx/installed_version.txt` 읽음. 부재 + config 존재 = pre-tracker (v0.1.7) baseline 가정.
|
||||
|
||||
`_version_tuple()` 이 dot-segment 별 leading digit 추출 (`0.3.0-RTM1`, `0.3.0rc1`, `0.3.0+dev` 모두 `[:3]` 비교용으로 `(0, 3, 0)` 파싱). PR #82 이전엔 첫 non-int segment 에서 멈춰서 RTM-suffix 가 `(0, 3)` 반환 → 모든 shipped `(0, 3, 0)` 보다 작게 lex-비교 → RTM 사용자 모두에게 apply 체인 빠짐.
|
||||
|
||||
### 5.2 항상 실행되는 단계
|
||||
|
||||
기존 config 있으면 버전 무관 실행:
|
||||
|
||||
1. **`_probe_password_sync`** — 사전 FreeRDP auth probe. password 가 drift 됐으면 (cfg vs Windows 계정 불일치) `winpodx pod sync-password` 안내 진단 출력.
|
||||
2. **`_ensure_canonical_image_pin`** — 일치 안 하면 `cfg.pod.image` 와 `compose.yaml` 을 `DOCKUR_IMAGE_PIN` 으로 재작성. 다음 `pod start` 시 컨테이너 1회 recreate (volume 보존 — ~30초, ISO 재다운로드 없음). Idempotent: pin 일치 → no-op.
|
||||
3. **`_apply_runtime_fixes_to_existing_guest`** — `apply_windows_runtime_fixes(cfg)` 호출 ([§6](#6-apply-체인-apply_windows_runtime_fixes)).
|
||||
|
||||
### 5.3 cross-version 업그레이드에서만 실행
|
||||
|
||||
- `_print_whats_new` — `_VERSION_NOTES` 에서 `(installed, current]` 사이 모든 버전 release notes 출력.
|
||||
- `_maybe_cleanup_legacy_bundled` — v0.1.9 경계 넘을 때만, 번들 프로필 시대의 stale `.desktop` 14개 제거 제안.
|
||||
|
||||
### 5.4 왜 "already current" 도 apply 체인 실행하나
|
||||
|
||||
Patch 버전 (0.1.9.x) 들은 `[:3]` 자르면 같은 `(0, 1, 9)` 튜플로 collapse 됨. 이 경로에서 apply 안 발사하면 `0.1.9.0 → 0.1.9.2` 업그레이드가 `0.1.9.x` 의 모든 수정을 silently 스킵. helper 들 idempotent 라 healthy pod 에 재실행해도 helper 당 marker probe + no-op return.
|
||||
|
||||
---
|
||||
|
||||
## 6. Apply 체인 (`apply_windows_runtime_fixes`)
|
||||
|
||||
**코드.** `src/winpodx/core/provisioner.py::apply_windows_runtime_fixes`. `winpodx pod apply-fixes`, GUI Tools 페이지 버튼, migrate 가 호출.
|
||||
|
||||
**순서 중요** — 각 단계는 이전 단계 실행 가정:
|
||||
|
||||
```
|
||||
1. max_sessions MaxInstanceCount 레지스트리 sync
|
||||
2. rdp_timeouts idle / disconnect / connection 타임아웃 비활성화
|
||||
3. oem_runtime_fixes NIC 절전 off, TermService 복구, …
|
||||
4. vbs_launchers VBS 파일 push + agent-respawn + WinpodxMedia 재작성
|
||||
5. multi_session marker probe + (필요 시) detached activation
|
||||
```
|
||||
|
||||
**Helper 계약.** 각 helper 가 단일 PowerShell payload 만들어서 `_apply_via_transport` 로 전송 (agent /exec 우선, FreeRDP RemoteApp 폴백) 후 return. 모든 helper idempotent — 이미 적용된 pod 에 실행하면 marker probe + no-op return.
|
||||
|
||||
**Helper 별 상세.**
|
||||
|
||||
### 6.1 `_apply_max_sessions`
|
||||
|
||||
`HKLM:\...\WinStations\RDP-Tcp` 에 `MaxInstanceCount` 작성, Terminal Server root 의 `fSingleSessionPerUser` 클리어. TermService restart **안 함** — apply 자체가 그 서비스가 제공하는 RDP 세션 *안에서* 실행 중이라, 재시작 = apply mid-flight 사망. 레지스트리 쓰기만 충분; 새 값은 다음 자연 주기에 적용.
|
||||
|
||||
### 6.2 `_apply_rdp_timeouts`
|
||||
|
||||
RDP idle / disconnect / max-session 타임아웃 비활성화 + keep-alive 활성화 레지스트리 키 작성. 없으면 Windows 가 1시간 default idle 후 RemoteApp 세션 drop, NAT/firewall idle-cleanup 이 underlying TCP 죽일 수 있음.
|
||||
|
||||
### 6.3 `_apply_oem_runtime_fixes`
|
||||
|
||||
OEM 시점에 *유지돼야* 하지만 가끔 안 그런 설정들의 catch-all:
|
||||
|
||||
- NIC 절전 off (`Set-NetAdapterPowerManagement`)
|
||||
- TermService 복구 동작 (5초 재시작, 3회 시도)
|
||||
- ApplicationFrameHost / explorer.exe 안정성 tweak
|
||||
|
||||
### 6.4 `_apply_vbs_launchers`
|
||||
|
||||
5개 파일을 단일 `/exec` round-trip 으로 push:
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `hidden-launcher.vbs` | 일반 GUI-subsystem wrapper, 자식한테 SW_HIDE 전파 |
|
||||
| `launch_uwp.vbs` | RemoteApp-friendly UWP 런처, launch_uwp.ps1 hidden 호출 |
|
||||
| `launch_uwp.ps1` | C# helper 클래스 IApplicationActivationManager activator (PS-level COM cast 이슈 없음) |
|
||||
| `agent-respawn.ps1` | Detached agent 재시작 (옛 거 죽이고 wscript wrapper 로 새거 spawn) |
|
||||
| `rdprrap-activate.ps1` | Runtime rdprrap activator ([§7](#7-multi-session-활성화) 참고) |
|
||||
|
||||
그 다음 `HKCU\Run\WinpodxAgent` 와 `HKCU\Run\WinpodxMedia` 를 wscript+hidden-launcher.vbs wrapper 사용하도록 작성. `WinpodxMedia` 재작성은 legacy 항목 존재 조건부 (install.bat 이 media_monitor staging 스킵한 pod 에 stale 항목 만들지 않도록).
|
||||
|
||||
마지막으로 `agent-respawn.ps1` detached spawn → 새 wrapper 즉시 적용 (사용자 로그아웃 불필요). `/health` 가 3-4초 blip 후 새 wrapper 로 복구.
|
||||
|
||||
### 6.5 `_apply_multi_session`
|
||||
|
||||
[§7](#7-multi-session-활성화) 참고.
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-session 활성화
|
||||
|
||||
**목표.** rdprrap 이 `termsrv.dll` 을 patch 해서 같은 사용자의 여러 RDP 세션이 공존 가능. 없으면 새 RDP 연결마다 이전 세션 대체 — 무서운 "Select a session to reconnect to" 다이얼로그.
|
||||
|
||||
**Single source of truth.** `config/oem/rdprrap-activate.ps1` 이 OEM-time (synchronous, install.bat 에서) 과 runtime (detached, `_apply_multi_session` 또는 `winpodx pod multi-session on` 에서) 둘 다에서 호출됨.
|
||||
|
||||
### 7.1 활성화 메커니즘
|
||||
|
||||
2단계:
|
||||
|
||||
1. `rdprrap-installer install --skip-restart` — `HKLM:\SYSTEM\CurrentControlSet\Services\TermService\Parameters\ServiceDll` 을 `termwrap.dll` (rdprrap 의 wrapper DLL) 가리키도록 patch.
|
||||
2. `net stop TermService /y && net start TermService` — TermService 가 fresh start 시 새 DLL 로드.
|
||||
|
||||
step 2 가 모든 활성 RDP 세션 죽임 (TermService 가 그것들 관리하니까).
|
||||
|
||||
### 7.2 OEM-time 경로 (synchronous)
|
||||
|
||||
install.bat 이 `FirstLogonCommands` 의 **로컬 콘솔 세션**에서 실행. TermService 는 **RDP 세션만** 관리하므로, step 2 의 cycle 이 cmd.exe 부모 안 죽임. install.bat 이 `-Detached` 없이 `rdprrap-activate.ps1` 호출, 스크립트 종료까지 동기 대기, rc 로 분기.
|
||||
|
||||
### 7.3 Runtime 경로 (detached)
|
||||
|
||||
agent 는 user RDP 세션 *안에서* 실행 — step 2 에서 죽는 그 세션. inline `/exec` 면 응답 반환 전에 죽음.
|
||||
|
||||
`_apply_multi_session` 과 `winpodx pod multi-session on` 이 따라서 wscript+hidden-launcher.vbs 로 `rdprrap-activate.ps1 -Detached` spawn:
|
||||
|
||||
```
|
||||
host /exec ──► agent.ps1 ──► Start-Process wscript.exe ...rdprrap-activate.ps1 -Detached
|
||||
│ │
|
||||
host 에 OK 반환 (2초 sleep — host 응답 시간)
|
||||
↓
|
||||
install + TermService cycle
|
||||
↓
|
||||
marker := 'enabled' / 'installer-failed' / 등
|
||||
↓
|
||||
(agent 세션은 ~중간에 죽음)
|
||||
```
|
||||
|
||||
사용자 재접속 → HKCU\Run 발사 → wscript wrapper 로 새 agent.
|
||||
|
||||
### 7.4 Idempotency: marker + ServiceDll 교차확인
|
||||
|
||||
`_apply_multi_session` 이 `C:\winpodx\rdprrap\.activation_status` 읽음 (`rdprrap-activate.ps1` 이 작성):
|
||||
|
||||
| Marker 값 | 동작 |
|
||||
|---|---|
|
||||
| `enabled` | No-op return. Fast path. |
|
||||
| 부재 / `not-activated` / `installer-failed` / `extract-failed` | ServiceDll 교차확인. 이미 `termwrap.dll` 면: marker 에 `enabled` 작성, no-op return (PR #85 — install.bat 이 failed 로 마킹했지만 patch 는 어쨌든 land 한 케이스 처리). 아니면: detached activator spawn. |
|
||||
|
||||
이 belt-and-suspenders 가 *이미* 동작 중인데 OEM-time 부분 실패한 pod 에서 TermService cycle 발사 방지. PR #85 이전엔 그런 pod 의 매 apply-fixes 호출이 agent 죽임.
|
||||
|
||||
### 7.5 `winpodx pod multi-session on/off/status`
|
||||
|
||||
- **`on`** — apply 체인 단계와 동일 코드 경로. Detached spawn, "OK: activation queued" 반환 + ~10초 disconnect 비용 명시.
|
||||
- **`off`** — inline `rdprrap-conf --disable`. Disable 은 레지스트리 patch clear 만; TermService 는 다음 reboot 까지 cycle 불필요, agent 세션 안전.
|
||||
- **`status`** — marker probe. apply-fixes multi_session 단계가 쓰는 동일 source, 표면 일관성.
|
||||
|
||||
---
|
||||
|
||||
## 8. 컨테이너 이미지 pinning
|
||||
|
||||
**코드.** `src/winpodx/core/config.py` 의 `DOCKUR_IMAGE_PIN` 상수.
|
||||
|
||||
**왜.** Pin 이전 (≤ v0.3.0), `cfg.pod.image` default 가 `:latest`. 매 `podman-compose up` 마다 dockur 가 푸시한 무엇이든에 대해 tag 재해상. digest 가 바뀌면 (자주 — dockur 릴리스 주기가 daily-ish), podman-compose 가 spec 다르다고 판단해서 **컨테이너 재생성** → fresh ISO 다운로드 → 분 단위 Sysprep → 게스트 상태 손실.
|
||||
|
||||
**포맷.** `docker.io/dockurr/windows@sha256:<64-char-hex>`.
|
||||
|
||||
**갱신 절차 (릴리스 시점).**
|
||||
|
||||
```
|
||||
TOKEN=$(curl -sSL "https://auth.docker.io/token?service=registry.docker.io&scope=repository:dockurr/windows:pull" | jq -r .token)
|
||||
curl -sSL -H "Authorization: Bearer $TOKEN" -I \
|
||||
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
|
||||
-H 'Accept: application/vnd.oci.image.index.v1+json' \
|
||||
"https://registry-1.docker.io/v2/dockurr/windows/manifests/latest" \
|
||||
| grep -i '^docker-content-digest:'
|
||||
```
|
||||
|
||||
digest 를 `DOCKUR_IMAGE_PIN` 에 붙여넣고, 버전 bump, ship.
|
||||
|
||||
**마이그레이션.** Migrate 의 `_ensure_canonical_image_pin` 이 기존 pod 의 `cfg.pod.image` + `compose.yaml` 재작성. 다음 `pod start` 시 컨테이너 1회 recreate (volume 보존 — ~30초, ISO 재다운로드 없음, Sysprep 없음). Idempotent.
|
||||
|
||||
**사용자 opt-in 갱신.** `winpodx setup --update-image` 가 fresh `:latest` 를 pull 하는 **유일한** 경로:
|
||||
|
||||
1. `podman pull docker.io/dockurr/windows:latest`
|
||||
2. `podman image inspect ... -f '{{json .RepoDigests}}'` → digest 해결
|
||||
3. docker.io 항목으로 필터 → `cfg.pod.image := <digest>`
|
||||
4. `compose.yaml` 재생성
|
||||
5. "다음 pod start 시 컨테이너 recreate ~30초, volume 보존" 출력
|
||||
|
||||
---
|
||||
|
||||
## 9. Discovery (`winpodx app refresh`)
|
||||
|
||||
**코드.** `src/winpodx/core/discovery/__init__.py::discover_apps` (host) + `scripts/windows/discover_apps.ps1` (guest).
|
||||
|
||||
**기본 timeout.** 180초.
|
||||
|
||||
**3 race 회피 계층.**
|
||||
|
||||
### 9.1 Layer 1: 게스트 readiness gate
|
||||
|
||||
`discover_apps.ps1` 머리:
|
||||
|
||||
```
|
||||
1초 간격 polling:
|
||||
AppXSvc.Status -eq 'Running' AND
|
||||
ProgramData Start Menu .lnk 개수 > 0
|
||||
|
||||
3 연속 안정 sample 필요
|
||||
60초 budget
|
||||
```
|
||||
|
||||
Sysprep-방금-끝난 윈도우 (AppX 가 아직 inbox 앱 설치 중, Start Menu indexer 가 mid-propagation) 잡음.
|
||||
|
||||
### 9.2 Layer 2: 호스트 transport readiness
|
||||
|
||||
`_wait_for_transport_ready(cfg, max_wait_sec=30)` 이 agent `/health` 와 RDP port polling. 둘 중 하나 응답하면 즉시 return. migrate-방금-cycle-한-TermService 윈도우 (agent mid-respawn) 잡음.
|
||||
|
||||
### 9.3 Layer 3: retry-on-empty
|
||||
|
||||
첫 pass 후, `_looks_suspiciously_empty(apps)`:
|
||||
|
||||
- 총 개수 < 5 (stock Win11 항상 15+ 개)
|
||||
- OR UWP 개수 == 0 (Calculator / Settings / Terminal 항상 있음)
|
||||
|
||||
Suspicious 면: 8초 대기, 1회 재시도. 큰 결과 선택 → retry 가 절대 regression 안 됨.
|
||||
|
||||
### 9.4 Discovery 소스
|
||||
|
||||
스크립트가 5개 소스 union, lowercase exec path 또는 UWP AUMID 로 dedupe:
|
||||
|
||||
1. **Registry App Paths** (`HKLM` + `HKCU`)
|
||||
2. **Start Menu .lnk 재귀** (ProgramData + 모든 user profile)
|
||||
3. **UWP / MSIX 패키지** via `Get-AppxPackage` + `AppxManifest.xml`
|
||||
4. **Chocolatey + Scoop shim**
|
||||
5. **Essentials allowlist** — File Explorer / Calculator / Settings 는 항상 emit (synthesized stub 으로) — `.lnk` 로 enum 안 되니까.
|
||||
|
||||
Junk 필터: uninstaller, redistributable, `LicenseManagerShellExt`, `WindowsPackageManagerServer` 등 숨김. 사용자 override (`hidden = true` in `app.toml`) 는 후속 refresh 에서도 보존.
|
||||
|
||||
### 9.5 출력
|
||||
|
||||
`~/.local/share/winpodx/discovered/` 에 persist. 각 앱이 `.toml` + `.desktop` 항목 → `~/.local/share/applications/` 에 등록 → 사용자 런처 메뉴 즉시 반영.
|
||||
|
||||
---
|
||||
|
||||
## 10. Transport 선택 (agent vs FreeRDP)
|
||||
|
||||
**코드.** `src/winpodx/core/transport/__init__.py::dispatch`.
|
||||
|
||||
**2개 transport.**
|
||||
|
||||
| Transport | 메커니즘 | 창 깜빡임 | 기본 timeout | Latency |
|
||||
|---|---|---|---|---|
|
||||
| **Agent** | HTTP `/exec` on `127.0.0.1:8765`, bearer 인증 | 없음 (CreateNoWindow=$true) | 60초 | 100-300ms |
|
||||
| **FreeRDP** | RemoteApp PS invocation via xfreerdp | 불가피한 PS 콘솔 | 30초 | 3-5초 |
|
||||
|
||||
**선택 규칙.** `dispatch(cfg)` 가 1-2초 timeout 으로 agent `/health` 호출. agent 응답하면 `AgentTransport` 반환. 아니면 `FreerdpTransport`.
|
||||
|
||||
**사용처.** `core.updates`, `core.daemon.sync_windows_time`, `cli.pod.multi-session`, `cli.main.debloat`, GUI Tools 페이지 debloat 핸들러 — 모두 `windows_exec.run_via_transport` 통과.
|
||||
|
||||
**의도적 미사용.** Password rotation 과 `winpodx pod sync-password` rescue 경로 — 둘 다 직접 credential 인증 필요, FreeRDP 강제.
|
||||
|
||||
### 10.1 어느 transport 가 활성인지 확인
|
||||
|
||||
```
|
||||
PYTHONPATH=src python3 -c "
|
||||
from winpodx.core.config import Config
|
||||
from winpodx.core.transport import dispatch
|
||||
print(type(dispatch(Config.load())).__name__)"
|
||||
```
|
||||
|
||||
`AgentTransport` → /exec 경로. `FreerdpTransport` → 폴백.
|
||||
|
||||
### 10.2 Agent 프로세스 트리
|
||||
|
||||
```
|
||||
HKCU\Run\WinpodxAgent 가 user logon 시 트리거:
|
||||
wscript.exe hidden-launcher.vbs (GUI subsystem, 콘솔 없음)
|
||||
└─ powershell.exe -File C:\OEM\agent.ps1 (SW_HIDE 상속)
|
||||
└─ /exec 호출마다 child PS
|
||||
(ProcessStartInfo + CreateNoWindow=$true)
|
||||
```
|
||||
|
||||
Agent listener: `http://+:8765/` + `netsh http add urlacl` 사전 등록 (User-level, admin 불필요). Token: `C:\OEM\agent_token.txt` (호스트에서 bind-mount).
|
||||
|
||||
---
|
||||
|
||||
## 11. Guest sync (`winpodx pod sync-guest`)
|
||||
|
||||
**코드.** `src/winpodx/core/guest_sync.py::sync_guest`. 전체 설계 노트: [docs/design/GUEST_SYNC_DESIGN.md](design/GUEST_SYNC_DESIGN.md).
|
||||
|
||||
**목표.** 호스트의 WinPodX 를 업그레이드하면 호스트 바이너리는 갱신되지만, 첫 설치 때 staging 된 게스트 측 아티팩트는 사용자가 Windows 를 밀고 재설치하기 전까지 stale 상태로 남음: `C:\OEM\agent.ps1`, urlacl 예약, rdprrap / `shim.exe` / `rcedit.exe`, 헬퍼 스크립트. apply 체인 ([§6](#6-apply-체인-apply_windows_runtime_fixes)) 은 *일부* idempotent 레지스트리 fix 만 재적용할 뿐 `agent.ps1` / urlacl 예약 / 게스트 바이너리는 **갱신 안 함**. Guest sync 가 재설치 없이 이 간극을 메움.
|
||||
|
||||
**핵심 enabler.** `/oem` 은 호스트 `config/oem` 의 **live bind mount** (`compose.py` 의 `{oem_dir}:/oem:Z`) — 즉 호스트 업그레이드 후 동작 중인 컨테이너의 `/oem` 에 *이미* 새 파일이 들어 있음 (이미지 재빌드 없음). 게스트 전달은 `winpodx pod recover-oem` 과 동일 채널 (컨테이너에서 `/oem` tar → `127.0.0.1:8766` 일회성 HTTP 서버 → 게스트가 QEMU NAT 게이트웨이 `10.0.2.2` 로 pull), 단 sync 는 agent 가 살아 있으므로 bearer-auth `/exec` 엔드포인트로 동작.
|
||||
|
||||
**언제 실행되나.** **pod start 당 1회**, pod 이 responsive 해진 뒤 (`provisioner.ensure_ready()` / `pod wait-ready` 꼬리), `cfg.pod.guest_autosync` (default `True`) 면서 게스트 stamp 가 stale 일 때. podman/docker 로 gate.
|
||||
|
||||
**Staleness gate.** 호스트 current = `winpodx.__version__` + `core.info._bundled_oem_version()`. `read_guest_version()` 이 `C:\winpodx\install-state\guest_version.json` (`{winpodx, oem_bundle}`) 을 `/exec` 로 읽음. `guest_sync_needed(cfg)` 는 stamp 가 **존재하고 호스트 쌍보다 오래된** 경우에만 True 반환. stamp **부재** 는 기록만, sync *안 함* — 아직 진행 중인 첫 부팅 install (install.bat 이 아직 자기 state 미작성) 을 방해 안 하려는 것. stamp 가 존재하고 동일하면 cheap no-op.
|
||||
|
||||
**Sync 흐름** (모든 단계 idempotent; 부분 실패 후 재실행 안전하도록 순서 잡힘):
|
||||
|
||||
1. **`/oem` 게스트 전달** — 게스트 `Invoke-WebRequest http://10.0.2.2:8766/oem.tar.gz` → `tar -xzf` → `C:\OEM`. `agent.ps1`, rdprrap, `shim.exe`, `rcedit.exe`, 헬퍼 스크립트를 한 번에 refresh. **`install.bat` 은 재실행 안 함** — autologon / 계정 설정 같은 one-shot 첫 부팅 로직을 담고 있어 live install 에서 재발사하면 안 됨.
|
||||
2. **urlacl 예약** ([#269](https://github.com/winpodx/winpodx/issues/269)) — install.bat 의 netsh 블록을 `/exec` 로 포팅: 겹치는 `:8765` 예약 삭제 후 `WD` SID SDDL 로 `http://+:8765/` 재등록.
|
||||
3. **Idempotent 레지스트리 / runtime fix** — `apply_windows_runtime_fixes(cfg)` 호출 ([§6](#6-apply-체인-apply_windows_runtime_fixes) 과 동일 체인). 갱신된 바이너리에 대해 rdprrap 도 재활성화.
|
||||
4. **agent 재시작** — agent 는 자신이 실행되는 `/exec` 를 제공하므로 동기적으로 스스로를 `Stop-Process` 할 수 없음. **one-shot scheduled task** 가 ~5초 뒤 발사해 현재 agent 프로세스를 멈추고 `HKCU\Run` 명령 (`C:\OEM\agent.ps1`) 을 재기동. `/exec` 호출이 task 발사 *전에* 반환; 새 agent 가 교정된 urlacl 로 `:8765` 재바인드.
|
||||
5. **버전 stamp 작성** — `guest_version.json` 은 1–3 단계 성공 **후에만** 작성 (step 4 는 fire-and-forget; readiness 는 호출자가 재확인). 부분 실패 시 stamp 가 stale 로 남아 다음 pod start 가 재시도.
|
||||
|
||||
`sync_guest` 는 CLI 와 GUI Tools → Sync Guest 액션이 행을 렌더링할 수 있도록 단계별 결과 맵 반환 (`apply_windows_runtime_fixes` 처럼).
|
||||
|
||||
**수동.** `winpodx pod sync-guest [--force]`. `--force` 는 stamp 가 호스트 쌍과 일치해도 재sync (up-to-date 게스트에서 clean no-op — 동일 아티팩트 재전달, 세션 중단 없음).
|
||||
|
||||
---
|
||||
|
||||
## 12. Disk auto-grow
|
||||
|
||||
**코드.** `src/winpodx/core/disk.py` (sizing + 게스트 extend), `src/winpodx/core/daemon.py` (idle 경로) 및 pod start 에서 호출.
|
||||
|
||||
**목표.** dockur 는 `cfg.pod.disk_size` 가 증가하고 컨테이너가 재생성될 때만 가상 디스크 *이미지*를 키울 뿐, 게스트의 C: 파티션은 절대 확장 안 하고 **online resize 도 없음**. auto-grow 가 양쪽을 처리해서 차오르는 게스트가 공간 고갈 안 되게 함.
|
||||
|
||||
**트리거.** pod start / idle 시, C: used% 가 `cfg.pod.disk_autogrow_threshold_pct` (default 80) 를 넘고 **동시에** pod 이 idle 일 때.
|
||||
|
||||
**왜 idle 전용.** dockur 에 online resize 가 없으므로 매 grow 가 **컨테이너를 재생성** (빠른 게스트 reboot). idle 전용 스케줄링이 live RemoteApp 세션을 절대 중단 안 함을 보장 — daemon 이 idle 경로에서만 발사.
|
||||
|
||||
**Sizing** (`compute_grow_target`). 플랫 step 이 아니라, `used%` 가 `cfg.pod.disk_autogrow_target_free_pct` 여유 (default 30%) 로 돌아갈 만큼만 키우되 `cfg.pod.disk_autogrow_increment` 단위 (default `32G`) 로 올림. ceiling 은 다음 중 작은 쪽:
|
||||
|
||||
- 선택적 `cfg.pod.disk_max_size` (빈 값 = 고정 cap 없음), 그리고
|
||||
- *호스트가 실제로 back 가능한 양* — `current + (host_free − reserve)` (reserve 가 auto-grow 의 호스트 디스크 고갈 방지).
|
||||
|
||||
어느 headroom 도 없으면 (cap 도달 / 호스트 여유 없음) 로그 한 줄과 함께 grow 스킵, pod 은 현재 크기로 계속.
|
||||
|
||||
**게스트 extend.** 이미지가 커지고 컨테이너가 재생성되면 새 공간은 디스크 **끝**에 붙지만 C: 는 원래 위치에서 끝남. extend 는 `/exec` 로 동작: `Resize-Partition -DriveLetter C`. dockur 의 Windows 레이아웃은 C: **바로 뒤**에 작은 WinRE Recovery 파티션을 두어 extend 를 막으므로 — 단계가:
|
||||
|
||||
1. `reagentc /disable` (WinRE detach),
|
||||
2. 막는 recovery 파티션 삭제 (C: 끝의 그것),
|
||||
3. `Resize-Partition -DriveLetter C` 로 새 공간 채움,
|
||||
4. `reagentc /enable` (WinRE 재활성화; 전용 파티션 없으면 `C:\Windows` 로 폴백).
|
||||
|
||||
---
|
||||
|
||||
## 13. 로그인 시 pod 자동 시작 (opt-in)
|
||||
|
||||
**설정.** `cfg.pod.auto_start` (bool, **default off**), 그리고 tray 의 autostart `.desktop` 항목.
|
||||
|
||||
**목표.** 매 로그인 후 사용자가 `winpodx pod start` 를 수동 실행하지 않아도 Windows pod 이 준비되게 함.
|
||||
|
||||
**흐름.** `cfg.pod.auto_start` 가 켜지면 tray 의 autostart `.desktop` 항목이 등록되어 로그인 시 tray 가 뜸; 시작 시 tray 가 pod 을 올림 — 멈춰 있으면 **start**, suspend 됐으면 **resume** ([auto resume](#1-전체-단계-개요)). 작업은 **best-effort + 백그라운드**: pod 도달 실패가 로그인을 막거나 blocking 에러를 띄우지 않고, 다음 on-demand 실행이 올리도록 pod 을 그대로 둠.
|
||||
|
||||
이게 opt-in 인 이유는 cold pod start 가 (이미 있는) 이미지를 pull 하고 Windows 를 부팅하는데, 가끔만 Windows 앱을 띄우는 사용자에겐 낭비된 작업이기 때문.
|
||||
|
||||
---
|
||||
|
||||
## 14. 복구 시나리오
|
||||
|
||||
### 14.1 Agent 가 죽고 안 살아남
|
||||
|
||||
**증상.** `curl http://127.0.0.1:8765/health` exit 56 / 무응답.
|
||||
|
||||
**원인.** TermService 가 cycle 됨 (multi-session 활성화, 수동 재시작 등). agent 의 RDP 세션이 같이 죽음. HKCU\Run 은 user logon 시점만 발사, service 재시작 시점 아님.
|
||||
|
||||
**해결.** Windows 앱 아무거나 띄움 — 새 RDP 세션이 HKCU\Run 트리거 → wscript wrapper 로 새 agent. 앱 안 띄우고 pod idle 이면 agent dead 유지.
|
||||
|
||||
**예방.** PR #85 (활성화 전 ServiceDll 교차확인) 이 가장 흔한 redundant 활성화 케이스 차단. install.bat OEM v15+ 가 marker 정확히 작성 → 후속 apply 가 fast path 로.
|
||||
|
||||
### 14.2 매 앱 launch 마다 PS 콘솔 깜빡
|
||||
|
||||
**증상.** 검정 콘솔 ~50ms 깜빡 (UWP 든 Win32 든 무관).
|
||||
|
||||
**진단.** agent /exec 로 `HKCU\Run` 값 probe. `wscript.exe ... hidden-launcher.vbs` 로 안 감싼 항목 찾기. 흔한 culprit:
|
||||
|
||||
- `WinpodxMedia` — PR #84 에서 수정 (옛날엔 bare `powershell.exe -WindowStyle Hidden`).
|
||||
- `WinpodxAgent` — PR #58 에서 수정.
|
||||
|
||||
**해결.** `winpodx pod apply-fixes` 가 둘 다 재작성 (`vbs_launchers` 단계가 legacy 항목 존재 조건부라 재실행 안전).
|
||||
|
||||
### 14.3 "Select a session to reconnect to" 다이얼로그
|
||||
|
||||
**증상.** Multi-session 활성 안 됨. 매 새 앱 launch 가 이전 세션 대체.
|
||||
|
||||
**진단.** `HKLM:\SYSTEM\CurrentControlSet\Services\TermService\Parameters\ServiceDll` probe:
|
||||
|
||||
- `C:\Program Files\RDP Wrapper\termwrap.dll` → rdprrap registry-patched. Multi-session 이 동작해야 함; 그래도 다이얼로그 뜨면 TermService 가 옛 `termsrv.dll` 로드한 채 cycle 안 함. `winpodx pod multi-session on` 으로 cycle (~10초 disconnect 후 OK).
|
||||
- `C:\Windows\System32\termsrv.dll` → patch 안 됨. `winpodx pod multi-session on` 으로 install + 활성화.
|
||||
|
||||
### 14.4 컨테이너 예상 외 재생성
|
||||
|
||||
**증상.** Pod 예기치 않게 재시작. dockur log 에 fresh Windows install / ISO 재다운로드.
|
||||
|
||||
**원인 (PR #83 이전).** `image: :latest` 가 podman-compose 에 의해 재해상. dockur 가 마지막 `up` 이후 새 `:latest` push. 새 digest → spec mismatch → recreate.
|
||||
|
||||
**해결.** Migrate (PR #83 의 `_ensure_canonical_image_pin`) 가 compose 를 pinned digest 로 재작성. 미래 `:latest` push 가 사용자 안 흔듬.
|
||||
|
||||
### 14.5 Discovery 가 빈/부분 결과 반환
|
||||
|
||||
**증상.** `winpodx app refresh` 끝나는데 메뉴에 몇 개만 / UWP 항목 없음.
|
||||
|
||||
**원인 (PR #86 이전).** 첫 부팅 race — AppXSvc 아직 deploying / Start Menu indexer 아직 propagating / agent mid-respawn.
|
||||
|
||||
**해결.** PR #86 의 계층화된 race 회피. 스크립트 stderr (apply-fixes 로그에 보임) 에서 `[discover] stable (...) — proceeding` (좋음) vs `[discover] stability budget exceeded` (pod 이 진짜 60초+ 걸렸음) 확인. budget 적중 의심되면 `winpodx app refresh` 재실행.
|
||||
|
||||
### 14.6 Agent token 불일치
|
||||
|
||||
**증상.** 모든 `/exec` 호출 401 실패.
|
||||
|
||||
**원인.** `~/.config/winpodx/agent_token.txt` (호스트) 와 `C:\OEM\agent_token.txt` (게스트) 불일치. 보통 수동 편집 또는 부분 복원 후.
|
||||
|
||||
**해결.** `winpodx setup --non-interactive` 재실행 — `_ensure_oem_token_staged()` 가 fresh token 재생성 + staging. agent 가 받아들이도록 pod 재시작.
|
||||
|
||||
### 14.7 Pod 시작 안 됨
|
||||
|
||||
**증상.** `winpodx pod start` 가 컨테이너 up 보고하지만 `wait-ready` 가 phase 1/2 통과 못함.
|
||||
|
||||
**진단.** `podman logs winpodx-windows --tail 50`. 찾을 것:
|
||||
|
||||
- `proc.sh: line 137: -1: substring expression < 0` → dockur 의 `:latest` push 내부 버그. PR #83 이후엔 pin 이 보호하니 발생 불가.
|
||||
- `mknod: /dev/net/tun: File exists` → 무해 경고, root cause 아님.
|
||||
- BdsDxe boot loop 인데 `Windows started successfully` 없음 → 게스트 mid-Sysprep. 그냥 대기.
|
||||
|
||||
컨테이너 자체가 시작 안 함 (podman 즉시 exit) 이면, `podman ps -a --filter name=winpodx` 와 `podman inspect winpodx-windows -f '{{.State.Error}}'` 확인.
|
||||
|
||||
---
|
||||
|
||||
## 참고
|
||||
|
||||
- **[CHANGELOG.ko.md](CHANGELOG.ko.md)** — 릴리스 히스토리.
|
||||
- **[AGENT_V2_DESIGN.md](AGENT_V2_DESIGN.md)** — agent 프로토콜 설계 노트.
|
||||
- **[TRANSPORT_ABC.md](TRANSPORT_ABC.md)** — transport 추상화 내부.
|
||||
- **[LIFECYCLE.md](LIFECYCLE.md)** — English version.
|
||||
@@ -0,0 +1,638 @@
|
||||
# WinPodX Lifecycle & Processes
|
||||
|
||||
End-to-end reference for how a WinPodX pod is installed, upgraded, migrated, and kept healthy. Each section describes one phase: who fires it, what it does, where the code lives, and what failure modes it handles.
|
||||
|
||||
> **Audience.** Maintainers and advanced users who need to understand or debug any WinPodX code path. For day-to-day usage see [README.md](../README.md).
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [Phases overview](#1-phases-overview)
|
||||
2. [Fresh install](#2-fresh-install-no-existing-config)
|
||||
3. [Sysprep first boot (install.bat)](#3-sysprep-first-boot-installbat)
|
||||
4. [Upgrade install (existing config)](#4-upgrade-install-existing-config)
|
||||
5. [Migrate (`winpodx migrate`)](#5-migrate-winpodx-migrate)
|
||||
6. [Apply chain (`apply_windows_runtime_fixes`)](#6-apply-chain-apply_windows_runtime_fixes)
|
||||
7. [Multi-session activation](#7-multi-session-activation)
|
||||
8. [Container image pinning](#8-container-image-pinning)
|
||||
9. [Discovery (`winpodx app refresh`)](#9-discovery-winpodx-app-refresh)
|
||||
10. [Transport selection](#10-transport-selection-agent-vs-freerdp)
|
||||
11. [Guest sync (`winpodx pod sync-guest`)](#11-guest-sync-winpodx-pod-sync-guest)
|
||||
12. [Disk auto-grow](#12-disk-auto-grow)
|
||||
13. [Pod auto-start on login](#13-pod-auto-start-on-login-opt-in)
|
||||
14. [Recovery scenarios](#14-recovery-scenarios)
|
||||
|
||||
---
|
||||
|
||||
## 1. Phases overview
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ install.sh │
|
||||
│ (host side) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌─────────────────┴──────────────────┐
|
||||
│ │
|
||||
no existing config existing config
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌─────────────────────┐
|
||||
│ winpodx setup │ │ skip setup │
|
||||
│ (interactive │ │ stage agent token │
|
||||
│ or default) │ └──────────┬──────────┘
|
||||
│ writes: │ │
|
||||
│ winpodx.toml │ │
|
||||
│ compose.yaml │ │
|
||||
│ agent_token │ │
|
||||
└───────┬───────┘ │
|
||||
│ │
|
||||
└──────────────┬───────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ winpodx pod │
|
||||
│ wait-ready │ (3 phases: container,
|
||||
│ │ RDP port, FreeRDP probe)
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ winpodx migrate │ (only if existing config)
|
||||
│ - version compare │
|
||||
│ - image pin align │
|
||||
│ - apply chain │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ winpodx app refresh │
|
||||
│ (3-layer race-free │
|
||||
│ discovery) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
The shaded boxes are entry points; everything else is implementation. Each is described in detail below.
|
||||
|
||||
---
|
||||
|
||||
## 2. Fresh install (no existing config)
|
||||
|
||||
**Trigger.** `~/.config/winpodx/winpodx.toml` does not exist. Typically a brand-new user running `curl -sSL .../install.sh | bash`.
|
||||
|
||||
**Flow.**
|
||||
|
||||
1. `install.sh` checks distro, installs missing dependencies (podman, podman-compose, freerdp, libnotify), verifies Python ≥ 3.9.
|
||||
2. `install.sh` extracts winpodx source to `~/.local/bin/winpodx-app/` and writes the `winpodx` launcher to `~/.local/bin/winpodx`.
|
||||
3. `install.sh` runs `python3 -m winpodx setup --non-interactive` (`src/winpodx/cli/setup_cmd.py::handle_setup`).
|
||||
4. Setup writes `~/.config/winpodx/winpodx.toml`:
|
||||
- `cfg.pod.image` defaults to `DOCKUR_IMAGE_PIN` (a SHA-pinned `docker.io/dockurr/windows@sha256:…` digest — see [§8](#8-container-image-pinning)).
|
||||
- `cfg.rdp.password` randomized.
|
||||
- `cfg.pod.backend` autodetected (podman > docker > libvirt).
|
||||
5. Setup runs `generate_compose(cfg)` which writes `~/.config/winpodx/compose.yaml`.
|
||||
6. Setup runs `_ensure_oem_token_staged()` which writes `~/.config/winpodx/agent_token.txt` and copies it to the OEM bind-mount source dir.
|
||||
7. `install.sh` calls `winpodx pod wait-ready --timeout 3600 --logs`. dockur pulls the pinned image, downloads the Windows ISO (~7.5 GB), runs Sysprep with our OEM bundle ([§3](#3-sysprep-first-boot-installbat)) — typically 5-10 min on first install.
|
||||
8. Migrate is **skipped** (no `installed_version.txt` to compare against).
|
||||
9. `install.sh` calls `winpodx app refresh` ([§9](#9-discovery-winpodx-app-refresh)).
|
||||
|
||||
**End state.** Fully provisioned pod, multi-session active, agent running under wscript wrapper, app menu populated.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sysprep first boot (`install.bat`)
|
||||
|
||||
**Where.** `config/oem/install.bat`. Lives in the OEM bind-mount → copied by dockur into `C:\OEM\` at first boot → invoked once via `unattend.xml`'s `FirstLogonCommands` as the local console session of the autologon user.
|
||||
|
||||
`WINPODX_OEM_VERSION` (top of file) is the bundle version. Bumped per release whenever install.bat or its sibling resources change.
|
||||
|
||||
**What it does, in order.**
|
||||
|
||||
1. **TermService recovery actions** — `sc.exe failure TermService reset= 86400 actions= restart/5000/...`. Survives transient TermService crashes without manual intervention.
|
||||
2. **MaxInstanceCount + multi-session registry** — `HKLM:\...\Terminal Server\WinStations\RDP-Tcp\MaxInstanceCount`, `fSingleSessionPerUser = 0`. Authoritative cap at OEM time; runtime apply chain syncs it later if `cfg.pod.max_sessions` changes.
|
||||
3. **rdprrap install** (multi-session enabler):
|
||||
- SHA256-verified bundle extraction from `C:\OEM\rdprrap-*.zip` to `C:\winpodx\rdprrap\`.
|
||||
- Delegates the install + verify + marker to **`rdprrap-activate.ps1`** (single source of truth for both OEM-time and runtime activation; see [§7](#7-multi-session-activation)).
|
||||
4. **NIC / RDP timeout settings** — disables idle/disconnect/connection timeouts so RemoteApp sessions don't drop after 1 h.
|
||||
5. **media_monitor.ps1 staging + autostart** — copies `media_monitor.ps1` to `C:\winpodx\` and registers `HKCU\Run\WinpodxMedia` with the **wscript+hidden-launcher.vbs wrapper** so the autostart doesn't flash a PS console (since OEM v19 — earlier versions used bare `powershell.exe -WindowStyle Hidden` which leaked a ~50 ms conhost flash).
|
||||
6. **VBS launcher staging** — copies `hidden-launcher.vbs`, `launch_uwp.vbs`, `launch_uwp.ps1`, `agent-respawn.ps1`, `rdprrap-activate.ps1` to `C:\Users\Public\winpodx\launchers\`. Public dir is universally writable so the agent (User-level) can later overwrite these during runtime migrations.
|
||||
7. **Agent autostart** — registers `HKCU\Run\WinpodxAgent` with the wscript+hidden-launcher.vbs wrapper pointing at `C:\OEM\agent.ps1`.
|
||||
8. **URL ACL pre-registration** — `netsh http add urlacl` for the agent's `http://+:8765/` listener, so the User-level agent can bind without admin elevation at runtime.
|
||||
9. **OEM marker** — writes `C:\winpodx\oem_version.txt` so the host can probe what bundle this guest was provisioned with.
|
||||
|
||||
**Idempotency.** install.bat runs **once**. After Sysprep finishes, dockur never re-runs it. All ongoing maintenance is handled by the host-side apply chain ([§6](#6-apply-chain-apply_windows_runtime_fixes)).
|
||||
|
||||
---
|
||||
|
||||
## 4. Upgrade install (existing config)
|
||||
|
||||
**Trigger.** `~/.config/winpodx/winpodx.toml` already exists. Typically `curl -sSL .../install.sh | bash -s -- --main` to update an existing WinPodX installation.
|
||||
|
||||
**Flow.**
|
||||
|
||||
1. `install.sh` re-extracts source to `~/.local/bin/winpodx-app/` (host code update).
|
||||
2. Runs `winpodx setup --non-interactive` — detects existing config, prints `Existing config found ..., skipping setup`, runs `_ensure_oem_token_staged()`, returns. **No compose regeneration here**, so the running pod is undisturbed by the source code update.
|
||||
3. Runs `winpodx pod wait-ready` — typically completes in seconds since the container is already up and warm.
|
||||
4. Runs `winpodx migrate` ([§5](#5-migrate-winpodx-migrate)) — the canonical place where existing-pod migration happens.
|
||||
5. Runs `winpodx app refresh` to refresh the discovered app menu.
|
||||
|
||||
The only path that mutates the guest is migrate. install.sh itself is purely host-side after step 2.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrate (`winpodx migrate`)
|
||||
|
||||
**Code.** `src/winpodx/cli/migrate.py::run_migrate`.
|
||||
|
||||
**Goal.** Leave the existing pod in the state a *fresh main install would produce*. Three independent migration steps run in order:
|
||||
|
||||
```
|
||||
installed_version vs current
|
||||
│
|
||||
┌────────────┼─────────────┐
|
||||
│ │ │
|
||||
None already cross-version
|
||||
(fresh) current (e.g. 0.1.7 → 0.3.1)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
record run apply print whats-new
|
||||
version chain + run apply chain
|
||||
+ (optional) refresh
|
||||
```
|
||||
|
||||
### 5.1 Version detection
|
||||
|
||||
`_detect_installed_version()` reads `~/.config/winpodx/installed_version.txt`. If absent and a config exists, assumes pre-tracker (v0.1.7) baseline.
|
||||
|
||||
`_version_tuple()` extracts leading digits per dot-segment (so `0.3.0-RTM1`, `0.3.0rc1`, `0.3.0+dev` all parse to `(0, 3, 0)` for `[:3]` comparison purposes). Pre-PR #82 the parser stopped at the first non-int segment, returning `(0, 3)` for RTM-suffixed strings — that 2-tuple lex-compared less than every shipped `(0, 3, 0)`, dropping the apply chain on the floor for every RTM user.
|
||||
|
||||
### 5.2 Steps that always run
|
||||
|
||||
Regardless of version (when an existing config is present):
|
||||
|
||||
1. **`_probe_password_sync`** — pre-flight FreeRDP auth probe. If the password drifted (cfg vs Windows account out of sync), prints a diagnostic pointing at `winpodx pod sync-password`.
|
||||
2. **`_ensure_canonical_image_pin`** — rewrites `cfg.pod.image` and `compose.yaml` to `DOCKUR_IMAGE_PIN` if they don't match. One container recreate cost on next `pod start` (volume preserved — ~30 s, no ISO redownload). Idempotent: matching pins → no-op.
|
||||
3. **`_apply_runtime_fixes_to_existing_guest`** — calls `apply_windows_runtime_fixes(cfg)` ([§6](#6-apply-chain-apply_windows_runtime_fixes)).
|
||||
|
||||
### 5.3 Steps that run on cross-version upgrade only
|
||||
|
||||
- `_print_whats_new` — pulls release notes from `_VERSION_NOTES` for every version in `(installed, current]` and prints them.
|
||||
- `_maybe_cleanup_legacy_bundled` — only when crossing the v0.1.9 boundary, offers to remove the 14 stale `.desktop` entries left over from the bundled-profiles era.
|
||||
|
||||
### 5.4 Why "always current" still runs the apply chain
|
||||
|
||||
Patch versions (0.1.9.x) collapse to the same `(0, 1, 9)` tuple under `[:3]` truncation. Without firing apply on this path, an upgrade `0.1.9.0 → 0.1.9.2` would silently skip every fix shipped in `0.1.9.x`. Helpers are idempotent, so re-running on a healthy pod is a marker probe + no-op return per helper.
|
||||
|
||||
---
|
||||
|
||||
## 6. Apply chain (`apply_windows_runtime_fixes`)
|
||||
|
||||
**Code.** `src/winpodx/core/provisioner.py::apply_windows_runtime_fixes`. Called by `winpodx pod apply-fixes`, the GUI Tools-page button, and migrate.
|
||||
|
||||
**Order matters** — each step assumes earlier steps have run:
|
||||
|
||||
```
|
||||
1. max_sessions MaxInstanceCount registry sync
|
||||
2. rdp_timeouts disable idle / disconnect / connection timeouts
|
||||
3. oem_runtime_fixes NIC power-save off, TermService recovery, …
|
||||
4. vbs_launchers push VBS files + agent-respawn + WinpodxMedia rewrite
|
||||
5. multi_session marker probe + (if needed) detached activation
|
||||
```
|
||||
|
||||
**Per-helper contract.** Each helper builds a single PowerShell payload, sends it via `_apply_via_transport` (agent /exec preferred, FreeRDP RemoteApp fallback), and returns. All helpers are idempotent — running on a pod that's already at-or-past the relevant fix produces a marker probe + no-op return.
|
||||
|
||||
**Per-helper detail.**
|
||||
|
||||
### 6.1 `_apply_max_sessions`
|
||||
|
||||
Writes `MaxInstanceCount` to `HKLM:\...\WinStations\RDP-Tcp` and clears `fSingleSessionPerUser` at the Terminal Server root. Does **not** restart TermService — the apply runs *inside* an RDP session served by that very service; restart would kill the apply mid-flight. Registry write alone is enough; new value picked up on next natural cycle.
|
||||
|
||||
### 6.2 `_apply_rdp_timeouts`
|
||||
|
||||
Writes the registry keys that disable RDP's idle / disconnect / max-session timeouts and enables keep-alive. Without this Windows drops active RemoteApp sessions after the 1 h default idle, and NAT/firewall idle-cleanup can kill the underlying TCP.
|
||||
|
||||
### 6.3 `_apply_oem_runtime_fixes`
|
||||
|
||||
Catch-all for OEM-time settings that *should* persist but sometimes don't:
|
||||
|
||||
- NIC power-management off (`Set-NetAdapterPowerManagement`)
|
||||
- TermService recovery actions (5 s restart, 3 attempts)
|
||||
- ApplicationFrameHost / explorer.exe stability tweaks
|
||||
|
||||
### 6.4 `_apply_vbs_launchers`
|
||||
|
||||
Five files pushed via a single `/exec` round-trip:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `hidden-launcher.vbs` | Generic GUI-subsystem wrapper, propagates SW_HIDE to any spawned child |
|
||||
| `launch_uwp.vbs` | RemoteApp-friendly UWP launcher, calls launch_uwp.ps1 hidden |
|
||||
| `launch_uwp.ps1` | C#-helper-class IApplicationActivationManager activator (no PS-level COM cast issues) |
|
||||
| `agent-respawn.ps1` | Detached agent restart (kills old, spawns new under wscript wrapper) |
|
||||
| `rdprrap-activate.ps1` | Runtime rdprrap activator (see [§7](#7-multi-session-activation)) |
|
||||
|
||||
Then writes `HKCU\Run\WinpodxAgent` and `HKCU\Run\WinpodxMedia` to use the wscript+hidden-launcher.vbs wrapper. `WinpodxMedia` rewrite is conditional on the legacy entry existing (avoids creating a stale entry on pods where install.bat skipped media_monitor staging).
|
||||
|
||||
Finally spawns `agent-respawn.ps1` detached so the new wrapper takes effect immediately without requiring a user logout — `/health` blips for 3-4 s and recovers under the new wrapper.
|
||||
|
||||
### 6.5 `_apply_multi_session`
|
||||
|
||||
See [§7](#7-multi-session-activation).
|
||||
|
||||
---
|
||||
|
||||
## 7. Multi-session activation
|
||||
|
||||
**Goal.** rdprrap patches `termsrv.dll` so multiple RDP sessions of the same user can coexist. Without it, each new RDP connection replaces the previous session — the dreaded "Select a session to reconnect to" dialog.
|
||||
|
||||
**Single source of truth.** `config/oem/rdprrap-activate.ps1` is invoked from both OEM-time (synchronous, from install.bat) and runtime (detached, from `_apply_multi_session` or `winpodx pod multi-session on`).
|
||||
|
||||
### 7.1 Activation mechanism
|
||||
|
||||
Two-step:
|
||||
|
||||
1. `rdprrap-installer install --skip-restart` — patches `HKLM:\SYSTEM\CurrentControlSet\Services\TermService\Parameters\ServiceDll` to point at `termwrap.dll` (rdprrap's wrapper DLL).
|
||||
2. `net stop TermService /y && net start TermService` — TermService loads the new DLL on fresh start.
|
||||
|
||||
Step 2 kills every active RDP session (because TermService manages them).
|
||||
|
||||
### 7.2 OEM-time path (synchronous)
|
||||
|
||||
install.bat runs from `FirstLogonCommands` in the **local console session**. TermService manages **RDP sessions only**, so the cycle in step 2 doesn't tear down the cmd.exe parent. install.bat invokes `rdprrap-activate.ps1` without `-Detached`, waits synchronously for the script to exit, and branches on the rc.
|
||||
|
||||
### 7.3 Runtime path (detached)
|
||||
|
||||
The agent runs *inside* a user RDP session — the session that gets killed in step 2. An inline `/exec` would die mid-flight before the response could return.
|
||||
|
||||
`_apply_multi_session` and `winpodx pod multi-session on` therefore spawn `rdprrap-activate.ps1 -Detached` via wscript+hidden-launcher.vbs:
|
||||
|
||||
```
|
||||
host /exec ──► agent.ps1 ──► Start-Process wscript.exe ...rdprrap-activate.ps1 -Detached
|
||||
│ │
|
||||
returns OK to host (sleeps 2 s — host response time)
|
||||
↓
|
||||
install + TermService cycle
|
||||
↓
|
||||
marker := 'enabled' / 'installer-failed' / etc.
|
||||
↓
|
||||
(agent's session died ~mid-flow)
|
||||
```
|
||||
|
||||
User reconnects → HKCU\Run fires → fresh agent comes up under wscript wrapper.
|
||||
|
||||
### 7.4 Idempotency: marker + ServiceDll cross-check
|
||||
|
||||
`_apply_multi_session` reads `C:\winpodx\rdprrap\.activation_status` (written by `rdprrap-activate.ps1`):
|
||||
|
||||
| Marker value | Action |
|
||||
|---|---|
|
||||
| `enabled` | No-op return. Fast path. |
|
||||
| missing / `not-activated` / `installer-failed` / `extract-failed` | Cross-check ServiceDll. If `termwrap.dll` already there: write `enabled` to marker, no-op return (PR #85 — handles the case where install.bat marked it failed but the patch landed anyway). Otherwise: spawn detached activator. |
|
||||
|
||||
This belt-and-suspenders avoids cycling TermService on pods that are *already* working but had an OEM-time partial failure. Pre-PR #85, every apply-fixes call on such pods killed the agent.
|
||||
|
||||
### 7.5 `winpodx pod multi-session on/off/status`
|
||||
|
||||
- **`on`** — same code path as the apply-chain step. Detached spawn, returns "OK: activation queued" with a clear note about the ~10 s disconnect cost.
|
||||
- **`off`** — inline `rdprrap-conf --disable`. Disable just clears the registry patch; TermService doesn't need cycling until next reboot, so the agent's session is safe.
|
||||
- **`status`** — marker probe. Same source the apply-fixes multi_session step uses, so output is consistent across surfaces.
|
||||
|
||||
---
|
||||
|
||||
## 8. Container image pinning
|
||||
|
||||
**Code.** `DOCKUR_IMAGE_PIN` constant in `src/winpodx/core/config.py`.
|
||||
|
||||
**Why.** Pre-pin (≤ v0.3.0), `cfg.pod.image` defaulted to `:latest`. Every `podman-compose up` re-resolved the tag against whatever dockur had pushed. When the digest changed (frequent — dockur's release cadence is daily-ish), podman-compose treated the spec as different and **recreated the container** → fresh ISO download → multi-minute Sysprep → loss of guest state.
|
||||
|
||||
**Format.** `docker.io/dockurr/windows@sha256:<64-char-hex>`.
|
||||
|
||||
**Update procedure (release-time).**
|
||||
|
||||
```
|
||||
TOKEN=$(curl -sSL "https://auth.docker.io/token?service=registry.docker.io&scope=repository:dockurr/windows:pull" | jq -r .token)
|
||||
curl -sSL -H "Authorization: Bearer $TOKEN" -I \
|
||||
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
|
||||
-H 'Accept: application/vnd.oci.image.index.v1+json' \
|
||||
"https://registry-1.docker.io/v2/dockurr/windows/manifests/latest" \
|
||||
| grep -i '^docker-content-digest:'
|
||||
```
|
||||
|
||||
Paste the digest into `DOCKUR_IMAGE_PIN`, bump version, ship.
|
||||
|
||||
**Migration.** Migrate's `_ensure_canonical_image_pin` rewrites `cfg.pod.image` + `compose.yaml` for existing pods. One container recreate on next `pod start` (volume preserved — ~30 s, no ISO redownload, no Sysprep). Idempotent.
|
||||
|
||||
**User opt-in update.** `winpodx setup --update-image` is the **only** path that ever pulls a fresh `:latest`:
|
||||
|
||||
1. `podman pull docker.io/dockurr/windows:latest`
|
||||
2. `podman image inspect ... -f '{{json .RepoDigests}}'` → resolve to digest
|
||||
3. Filter to docker.io entry → `cfg.pod.image := <digest>`
|
||||
4. Regenerate `compose.yaml`
|
||||
5. Print "next pod start will recreate container ~30 s, volume preserved"
|
||||
|
||||
---
|
||||
|
||||
## 9. Discovery (`winpodx app refresh`)
|
||||
|
||||
**Code.** `src/winpodx/core/discovery/__init__.py::discover_apps` (host) + `scripts/windows/discover_apps.ps1` (guest).
|
||||
|
||||
**Default timeout.** 180 s.
|
||||
|
||||
**Three race avoidance layers.**
|
||||
|
||||
### 9.1 Layer 1: guest readiness gate
|
||||
|
||||
`discover_apps.ps1` head:
|
||||
|
||||
```
|
||||
poll every 1 s for:
|
||||
AppXSvc.Status -eq 'Running' AND
|
||||
ProgramData Start Menu .lnk count > 0
|
||||
|
||||
require 3 consecutive stable samples
|
||||
bounded at 60 s
|
||||
```
|
||||
|
||||
Catches the Sysprep-just-finished window where AppX is still installing inbox apps and Start Menu indexer is mid-propagation.
|
||||
|
||||
### 9.2 Layer 2: host transport readiness
|
||||
|
||||
`_wait_for_transport_ready(cfg, max_wait_sec=30)` polls agent `/health` and RDP port. Returns as soon as either responds. Catches the migrate-just-cycled-TermService window where the agent is mid-respawn.
|
||||
|
||||
### 9.3 Layer 3: retry-on-empty
|
||||
|
||||
After the first pass, `_looks_suspiciously_empty(apps)`:
|
||||
|
||||
- Total count < 5 (stock Win11 always has 15+)
|
||||
- OR UWP count == 0 (Calculator / Settings / Terminal always present)
|
||||
|
||||
If suspicious: wait 8 s, retry once. Picks the larger result so retry never regresses.
|
||||
|
||||
### 9.4 Discovery sources
|
||||
|
||||
The script unions five sources, deduping by lowercase executable path or UWP AUMID:
|
||||
|
||||
1. **Registry App Paths** (`HKLM` + `HKCU`)
|
||||
2. **Start Menu .lnk recursion** (ProgramData + every user profile)
|
||||
3. **UWP / MSIX packages** via `Get-AppxPackage` + `AppxManifest.xml`
|
||||
4. **Chocolatey + Scoop shims**
|
||||
5. **Essentials allowlist** — File Explorer / Calculator / Settings always emitted with synthesized stubs, since they aren't enumerated as `.lnk` files.
|
||||
|
||||
Junk filter: hides uninstallers, redistributables, `LicenseManagerShellExt`, `WindowsPackageManagerServer`, etc. User overrides via `hidden = true` in `app.toml` survive subsequent refreshes.
|
||||
|
||||
### 9.5 Output
|
||||
|
||||
Persisted under `~/.local/share/winpodx/discovered/`. Each app gets a `.toml` plus a `.desktop` entry registered under `~/.local/share/applications/` so the user's launcher menu populates immediately.
|
||||
|
||||
---
|
||||
|
||||
## 10. Transport selection (agent vs FreeRDP)
|
||||
|
||||
**Code.** `src/winpodx/core/transport/__init__.py::dispatch`.
|
||||
|
||||
**Two transports.**
|
||||
|
||||
| Transport | Mechanism | Window flash | Default timeout | Latency |
|
||||
|---|---|---|---|---|
|
||||
| **Agent** | HTTP `/exec` on `127.0.0.1:8765`, bearer-authed | None (CreateNoWindow=$true) | 60 s | 100-300 ms |
|
||||
| **FreeRDP** | RemoteApp PS invocation via xfreerdp | Unavoidable PS console | 30 s | 3-5 s |
|
||||
|
||||
**Selection rule.** `dispatch(cfg)` calls agent `/health` with a 1-2 s timeout. If the agent answers, returns `AgentTransport`. Otherwise returns `FreerdpTransport`.
|
||||
|
||||
**Used by.** `core.updates`, `core.daemon.sync_windows_time`, `cli.pod.multi-session`, `cli.main.debloat`, GUI Tools-page debloat handler — all go through `windows_exec.run_via_transport`.
|
||||
|
||||
**Not used by (intentionally).** Password rotation and `winpodx pod sync-password` rescue path — both need direct credential auth, must use FreeRDP.
|
||||
|
||||
### 10.1 Verifying which transport is active
|
||||
|
||||
```
|
||||
PYTHONPATH=src python3 -c "
|
||||
from winpodx.core.config import Config
|
||||
from winpodx.core.transport import dispatch
|
||||
print(type(dispatch(Config.load())).__name__)"
|
||||
```
|
||||
|
||||
`AgentTransport` → /exec path. `FreerdpTransport` → fallback.
|
||||
|
||||
### 10.2 Agent process tree
|
||||
|
||||
```
|
||||
HKCU\Run\WinpodxAgent triggers at user logon:
|
||||
wscript.exe hidden-launcher.vbs (GUI subsystem, no console)
|
||||
└─ powershell.exe -File C:\OEM\agent.ps1 (SW_HIDE inherited)
|
||||
└─ child PS for each /exec call
|
||||
(ProcessStartInfo with CreateNoWindow=$true)
|
||||
```
|
||||
|
||||
Agent listener: `http://+:8765/` with `netsh http add urlacl` pre-registered (User-level, no admin needed). Token: `C:\OEM\agent_token.txt` (bind-mounted from host).
|
||||
|
||||
---
|
||||
|
||||
## 11. Guest sync (`winpodx pod sync-guest`)
|
||||
|
||||
**Code.** `src/winpodx/core/guest_sync.py::sync_guest`. Full design notes: [docs/design/GUEST_SYNC_DESIGN.md](design/GUEST_SYNC_DESIGN.md).
|
||||
|
||||
**Goal.** Upgrading WinPodX on the host updates the host binary, but the
|
||||
guest-side artifacts staged at first install go stale until the user wipes and
|
||||
reinstalls Windows: `C:\OEM\agent.ps1`, the urlacl reservation, rdprrap /
|
||||
`shim.exe` / `rcedit.exe`, and the helper scripts. The apply chain ([§6](#6-apply-chain-apply_windows_runtime_fixes))
|
||||
re-applies *some* idempotent registry fixes but does **not** refresh `agent.ps1`,
|
||||
the urlacl reservation, or the guest binaries. Guest sync closes that gap without
|
||||
a reinstall.
|
||||
|
||||
**Key enabler.** `/oem` is a **live bind mount** of the host's `config/oem`
|
||||
(`{oem_dir}:/oem:Z` in `compose.py`), so after a host upgrade the running
|
||||
container's `/oem` *already* holds the new files — no image rebuild. Delivery
|
||||
into the guest is the same channel `winpodx pod recover-oem` uses (tar `/oem`
|
||||
in the container → one-shot HTTP server on `127.0.0.1:8766` → guest pulls via
|
||||
the QEMU NAT gateway `10.0.2.2`), except sync runs over the bearer-authed
|
||||
`/exec` endpoint because the agent is alive.
|
||||
|
||||
**When it runs.** **Once per pod start**, after the pod is responsive
|
||||
(`provisioner.ensure_ready()` / `pod wait-ready` tail), when
|
||||
`cfg.pod.guest_autosync` (default `True`) is set and the guest stamp is stale.
|
||||
Gated to podman/docker.
|
||||
|
||||
**Staleness gate.** Host current = `winpodx.__version__` +
|
||||
`core.info._bundled_oem_version()`. `read_guest_version()` reads
|
||||
`C:\winpodx\install-state\guest_version.json` (`{winpodx, oem_bundle}`) via
|
||||
`/exec`. `guest_sync_needed(cfg)` returns True only when the stamp is **present
|
||||
and older** than the host pair. A **missing** stamp is recorded only, *not*
|
||||
synced — this avoids disrupting a first-boot install that is still mid-flight
|
||||
(install.bat has not yet written its own state). A present-and-equal stamp is a
|
||||
cheap no-op.
|
||||
|
||||
**Sync flow** (every step idempotent; ordered so a partial failure is safe to
|
||||
re-run):
|
||||
|
||||
1. **Deliver `/oem` to the guest** — guest `Invoke-WebRequest http://10.0.2.2:8766/oem.tar.gz`
|
||||
→ `tar -xzf` into `C:\OEM`. Refreshes `agent.ps1`, rdprrap, `shim.exe`,
|
||||
`rcedit.exe`, and helper scripts in one shot. **`install.bat` is NOT re-run**
|
||||
— it carries one-shot first-boot logic (autologon, account setup) that must
|
||||
not fire on a live install.
|
||||
2. **urlacl reservation** ([#269](https://github.com/winpodx/winpodx/issues/269)) — ports install.bat's netsh block to `/exec`:
|
||||
delete the overlapping `:8765` reservations, then re-add `http://+:8765/`
|
||||
with the `WD` SID SDDL.
|
||||
3. **Idempotent registry / runtime fixes** — calls
|
||||
`apply_windows_runtime_fixes(cfg)` (the same chain as [§6](#6-apply-chain-apply_windows_runtime_fixes)).
|
||||
This also re-activates rdprrap against the refreshed binaries.
|
||||
4. **Restart the agent** — the agent serves the `/exec` it runs through, so it
|
||||
can't `Stop-Process` itself synchronously. A **one-shot scheduled task**
|
||||
fires ~5 s later to stop the current agent process and relaunch the
|
||||
`HKCU\Run` command (`C:\OEM\agent.ps1`). The `/exec` call returns *before*
|
||||
the task fires; the new agent rebinds `:8765` under the now-correct urlacl.
|
||||
5. **Write the version stamp** — `guest_version.json` is written **only after**
|
||||
steps 1–3 succeed (step 4 is fire-and-forget; readiness is reconfirmed by the
|
||||
caller). On a partial failure the stamp stays stale, so the next pod start
|
||||
retries.
|
||||
|
||||
`sync_guest` returns a per-step result map (like `apply_windows_runtime_fixes`)
|
||||
so the CLI and GUI Tools → Sync Guest action can render rows.
|
||||
|
||||
**Manual.** `winpodx pod sync-guest [--force]`. `--force` re-syncs even when the
|
||||
stamp matches the host pair (a clean no-op on an up-to-date guest — same
|
||||
artifacts re-delivered, no session disruption).
|
||||
|
||||
---
|
||||
|
||||
## 12. Disk auto-grow
|
||||
|
||||
**Code.** `src/winpodx/core/disk.py` (sizing + guest extend), invoked from
|
||||
`src/winpodx/core/daemon.py` (idle path) and at pod start.
|
||||
|
||||
**Goal.** dockur only grows the virtual disk *image* when `cfg.pod.disk_size`
|
||||
increases and the container is recreated — it never extends the guest's C:
|
||||
partition, and it has **no online resize**. Auto-grow handles both ends so a
|
||||
filling-up guest doesn't run out of space.
|
||||
|
||||
**Trigger.** On pod start / idle, if C: used% exceeds
|
||||
`cfg.pod.disk_autogrow_threshold_pct` (default 80) **and** the pod is idle.
|
||||
|
||||
**Why idle-only.** Since dockur has no online resize, every grow **recreates
|
||||
the container** (a quick guest reboot). Scheduling it idle-only guarantees it
|
||||
never interrupts a live RemoteApp session — the daemon only fires it on the
|
||||
idle path.
|
||||
|
||||
**Sizing** (`compute_grow_target`). Rather than a flat step, grow just enough
|
||||
that `used%` drops back to `cfg.pod.disk_autogrow_target_free_pct` free
|
||||
(default 30%), rounded up to whole `cfg.pod.disk_autogrow_increment` steps
|
||||
(default `32G`). The ceiling is the smaller of:
|
||||
|
||||
- the optional `cfg.pod.disk_max_size` (empty = no fixed cap), and
|
||||
- *what the host can actually back* — `current + (host_free − reserve)`, where
|
||||
the reserve keeps auto-grow from consuming the last of the host disk.
|
||||
|
||||
If neither headroom is available (at cap / no host room), the grow is skipped
|
||||
with a log line and the pod runs on at its current size.
|
||||
|
||||
**Guest extend.** After the image grows and the container is recreated, the new
|
||||
space lands at the **end** of the disk but C: still ends where it did. The
|
||||
extend runs over `/exec`: `Resize-Partition -DriveLetter C`. dockur's Windows
|
||||
layout puts a small WinRE Recovery partition **right after** C:, blocking the
|
||||
extend — so the step:
|
||||
|
||||
1. `reagentc /disable` (detach WinRE),
|
||||
2. delete the blocking recovery partition (the one at C:'s end),
|
||||
3. `Resize-Partition -DriveLetter C` to fill the new space,
|
||||
4. `reagentc /enable` (re-enable WinRE; falls back to `C:\Windows` when no
|
||||
dedicated partition is present).
|
||||
|
||||
---
|
||||
|
||||
## 13. Pod auto-start on login (opt-in)
|
||||
|
||||
**Config.** `cfg.pod.auto_start` (bool, **off by default**), plus the tray's
|
||||
autostart `.desktop` entry.
|
||||
|
||||
**Goal.** Have the Windows pod ready without the user manually running
|
||||
`winpodx pod start` after each login.
|
||||
|
||||
**Flow.** When `cfg.pod.auto_start` is enabled, the tray's autostart `.desktop`
|
||||
entry is registered so the tray launches at login; on startup the tray brings
|
||||
the pod up — **starting** it if stopped, or **resuming** it if suspended
|
||||
([auto resume](#1-phases-overview)). The work is **best-effort and runs in the
|
||||
background**: a failure to reach the pod doesn't block login or surface a
|
||||
blocking error, it just leaves the pod for the next on-demand launch to bring
|
||||
up.
|
||||
|
||||
This is opt-in because a cold pod start pulls the (already-present) image and
|
||||
boots Windows, which is wasted work for users who only occasionally launch a
|
||||
Windows app.
|
||||
|
||||
---
|
||||
|
||||
## 14. Recovery scenarios
|
||||
|
||||
### 14.1 Agent dies and stays dead
|
||||
|
||||
**Symptom.** `curl http://127.0.0.1:8765/health` exits 56 / no response.
|
||||
|
||||
**Cause.** TermService was cycled (multi-session activation, manual restart, etc.). Agent's RDP session died with it. HKCU\Run only fires at user logon, not on service restart.
|
||||
|
||||
**Fix.** Open any Windows app — the new RDP session triggers HKCU\Run → fresh agent comes up under wscript wrapper. If no app launches and the pod stays idle, the agent stays dead.
|
||||
|
||||
**Prevention.** PR #85 (ServiceDll cross-check before activation) avoids the most common case where activation was redundant. install.bat OEM v15+ writes the marker correctly so subsequent applies hit the fast path.
|
||||
|
||||
### 14.2 PS console flashes on every app launch
|
||||
|
||||
**Symptom.** Brief black console appears for ~50 ms after each launch (UWP or Win32, doesn't matter).
|
||||
|
||||
**Diagnosis.** Probe `HKCU\Run` values via agent /exec. Look for any entry not wrapped in `wscript.exe ... hidden-launcher.vbs`. Common culprits:
|
||||
|
||||
- `WinpodxMedia` — fixed in PR #84 (was bare `powershell.exe -WindowStyle Hidden`).
|
||||
- `WinpodxAgent` — fixed in PR #58.
|
||||
|
||||
**Fix.** `winpodx pod apply-fixes` rewrites both entries (the `vbs_launchers` step is conditional on legacy entries existing, so it's safe to re-run).
|
||||
|
||||
### 14.3 "Select a session to reconnect to" dialog
|
||||
|
||||
**Symptom.** Multi-session not active. Each new app launch replaces the previous session.
|
||||
|
||||
**Diagnosis.** Probe `HKLM:\SYSTEM\CurrentControlSet\Services\TermService\Parameters\ServiceDll`:
|
||||
|
||||
- `C:\Program Files\RDP Wrapper\termwrap.dll` → rdprrap registry-patched. Multi-session should work; if dialog still appears, TermService loaded the old `termsrv.dll` and never cycled. Run `winpodx pod multi-session on` to cycle it (~10 s disconnect, then OK).
|
||||
- `C:\Windows\System32\termsrv.dll` → not patched. Run `winpodx pod multi-session on` to install + activate.
|
||||
|
||||
### 14.4 Container recreated unexpectedly
|
||||
|
||||
**Symptom.** Pod restarts unexpectedly. dockur logs show fresh Windows install or ISO redownload.
|
||||
|
||||
**Cause (pre-PR #83).** `image: :latest` re-resolved by podman-compose. dockur pushed a new `:latest` since last `up`. New digest → spec mismatch → recreate.
|
||||
|
||||
**Fix.** Migrate (PR #83's `_ensure_canonical_image_pin`) rewrites compose to a pinned digest. Future `:latest` pushes don't disturb the user.
|
||||
|
||||
### 14.5 Discovery returns empty / partial
|
||||
|
||||
**Symptom.** `winpodx app refresh` finishes but the menu only shows a handful of apps, or no UWP entries.
|
||||
|
||||
**Cause (pre-PR #86).** First-boot race — AppXSvc still deploying / Start Menu indexer still propagating / agent mid-respawn.
|
||||
|
||||
**Fix.** PR #86 layered race avoidance. Look at the script's stderr output (visible in `apply-fixes` logs) for `[discover] stable (...) — proceeding` (good) vs `[discover] stability budget exceeded` (the pod really took >60 s to settle). Re-run `winpodx app refresh` if you suspect the budget was hit.
|
||||
|
||||
### 14.6 Agent token mismatch
|
||||
|
||||
**Symptom.** All `/exec` calls fail with 401.
|
||||
|
||||
**Cause.** `~/.config/winpodx/agent_token.txt` (host) doesn't match `C:\OEM\agent_token.txt` (guest). Usually after a manual edit or a partial restore.
|
||||
|
||||
**Fix.** Re-run `winpodx setup --non-interactive` — `_ensure_oem_token_staged()` regenerates and stages a fresh token. Restart the pod for the agent to pick it up.
|
||||
|
||||
### 14.7 Pod won't start
|
||||
|
||||
**Symptom.** `winpodx pod start` reports the container is up but `wait-ready` never gets past phase 1 or 2.
|
||||
|
||||
**Diagnosis.** `podman logs winpodx-windows --tail 50`. Look for:
|
||||
|
||||
- `proc.sh: line 137: -1: substring expression < 0` → dockur internal bug from a `:latest` push. Should be impossible post-PR #83 since the pin protects against this.
|
||||
- `mknod: /dev/net/tun: File exists` → harmless warning, not the root cause.
|
||||
- BdsDxe boot loop without `Windows started successfully` → guest is mid-Sysprep. Just wait.
|
||||
|
||||
If the container itself isn't starting (podman exits immediately), check `podman ps -a --filter name=winpodx` and `podman inspect winpodx-windows -f '{{.State.Error}}'`.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- **[CHANGELOG.md](../CHANGELOG.md)** — release history.
|
||||
- **[AGENT_V2_DESIGN.md](AGENT_V2_DESIGN.md)** — agent protocol design notes.
|
||||
- **[TRANSPORT_ABC.md](TRANSPORT_ABC.md)** — transport abstraction internals.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Provision-unify fidelity audit (0.6.0 item B follow-up)
|
||||
|
||||
The first cut of item B (PR #375) consolidated the four post-create
|
||||
provisioning paths (`install.sh`, `setup_cmd._run_full_provision`,
|
||||
`migrate`, `pending.resume`) into `core/provisioner.finish_provisioning`.
|
||||
It homogenised them by conforming everything to the silent
|
||||
`wait_for_windows_responsive` + a single `require_agent` gate — and in doing
|
||||
so it **dropped behaviours that each path had accumulated for specific
|
||||
issues**. This document records the audit (behaviour → source → issue) and
|
||||
the faithful restoration.
|
||||
|
||||
Merging spread-out logic is not "pick one and unify": you analyse each path's
|
||||
current behaviour, trace it to the issue / PR that added it, and decide
|
||||
per-behaviour what to keep vs. discard. The first cut skipped that step. This
|
||||
is the redo.
|
||||
|
||||
## Regressions found (each traced to its issue)
|
||||
|
||||
| # | Lost behaviour | Original source | Issue | Symptom |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Dynamic wait** — live self-erasing progress line + wget-ETA deadline auto-extension | `cli/pod._wait_ready` (`pod wait-ready --logs`) | **#126** (xiyeming, 86-min ISO download) | Fresh install showed one `[wait_ready] up to 3600s` line then silence for the whole Windows boot — looked frozen; slow links could time out |
|
||||
| 2 | **`WINPODX_REQUIRE_AGENT=1` propagation to discovery/apply** | pre-B `install.sh` `export WINPODX_REQUIRE_AGENT=1` | **#271 / agent-first** | `finish_provisioning(require_agent=True)` gated only the one-shot Stage-2 settle re-probe; discovery still fell back to FreeRDP (3× `FreeRDP-fallback` in a real smoke) → FreeRDP can kick install.bat's autologon session during first boot |
|
||||
| 3 | **Upgrade → `winpodx migrate`** (guest_sync + image-pin + release notes) | pre-B `install.sh` `"$SYMLINK" migrate --non-interactive` on existing-config | guest-sync-on-upgrade feature | New `install.sh` ran `provision` for BOTH fresh + upgrade; `provision` doesn't guest_sync, so upgraded guests kept STALE `agent.ps1` / OEM scripts |
|
||||
| 4 | **`pending.resume` "migrate" step → guest_sync** | pre-B `pending.resume` ran `winpodx migrate` | same | B mapped the "migrate" pending step to `finish_provisioning`'s apply-fixes (no guest_sync), so a deferred upgrade resumed via pending also kept stale guest scripts |
|
||||
|
||||
Behaviours B preserved correctly (verified, not regressed): Ctrl+C / SIGTERM
|
||||
(rc 130/143) clean bail, `no such container` → mark-pending, `tee` of the
|
||||
provision output, the per-step `.pending_setup` markers.
|
||||
|
||||
## Faithful restoration
|
||||
|
||||
- **`finish_provisioning` gains `wait_fn`** (injection, keeps `core` cli-free).
|
||||
CLI `provision`, interactive `setup`, and `migrate` inject the rich
|
||||
`cli/pod._wait_ready` (the #126 dynamic deadline + live line). `None`
|
||||
(pending.resume, GUI) keeps the silent wait. [#1]
|
||||
- **`finish_provisioning` exports `WINPODX_REQUIRE_AGENT=1`** for the duration
|
||||
of the apply + discovery stages when `require_agent=True`, restoring it
|
||||
after. `_run_discovery_with_retry` takes `require_agent` and, on a
|
||||
persistent `agent_unavailable`, raises `ProvisionAgentUnavailable` so the
|
||||
caller defers (exit 5 → pending) rather than recording a generic failure.
|
||||
`_cmd_provision` already maps `ProvisionAgentUnavailable` → exit 5. [#2]
|
||||
- **`install.sh` branches fresh vs upgrade** on the pre-setup
|
||||
`IS_FRESH_INSTALL` snapshot: fresh → `winpodx provision --require-agent`;
|
||||
upgrade → `winpodx migrate --non-interactive`. [#3]
|
||||
- **`migrate` runs reverse-open** (`with_reverse_open=cfg.reverse_open.enabled`)
|
||||
since the upgrade path is now `migrate`-only and no longer has install.sh's
|
||||
separate host-open step. [#3]
|
||||
- **`pending.resume` "migrate" step calls `maybe_autosync`** (idempotent
|
||||
guest_sync) so a deferred upgrade resumed later still refreshes guest
|
||||
scripts. [#4]
|
||||
|
||||
## Tests
|
||||
|
||||
- `test_finish_provisioning.py`: `wait_fn` override used / silent fallback /
|
||||
timeout-skips-downstream; `require_agent` exports + restores
|
||||
`WINPODX_REQUIRE_AGENT`; `require_agent=False` leaves env untouched;
|
||||
discovery `agent_unavailable` → `ProvisionAgentUnavailable`;
|
||||
`_run_discovery_with_retry` escalation vs. re-raise.
|
||||
- `test_install_sh_provision.py`: fresh → `provision --require-agent`,
|
||||
upgrade → `migrate`, the `IS_FRESH_INSTALL` branch, old inline chain steps
|
||||
still absent.
|
||||
- `test_pending.py`: "migrate" step runs `maybe_autosync`; left pending when
|
||||
guest_sync fails.
|
||||
|
||||
Real-Windows smoke is still the gate (fresh install: live progress + no
|
||||
FreeRDP-fallback during discovery; upgrade: guest scripts refreshed).
|
||||
@@ -0,0 +1,42 @@
|
||||
# docs/design/
|
||||
|
||||
Engineering-internal documentation: design specs, reference docs, roadmaps, security reviews, and machine-readable schemas. Intended audience is contributors and maintainers, not end users — user-facing docs live one level up in [`../`](../) (see [`../README.md`](../README.md)).
|
||||
|
||||
A document belongs here when any of these are true:
|
||||
- It describes how the code is structured or sequenced (rather than how to use the product).
|
||||
- It refers to specific files, functions, or commit-level decisions.
|
||||
- It records a decision (review, audit, ADR) made at a point in time.
|
||||
- It is consumed by tooling, not humans (schemas, machine-readable specs).
|
||||
|
||||
## Index
|
||||
|
||||
### Design specs (`*_DESIGN.md`)
|
||||
Proposals and rationale for major subsystems. Each documents the problem, the chosen design, and the trade-offs considered.
|
||||
- [AGENT_V2_DESIGN.md](AGENT_V2_DESIGN.md) — host-side HTTP client + guest `agent.ps1` listener (the `/health`, `/exec`, `/events`, `/apply`, `/discover` channel).
|
||||
- [AGENT_FIRST_INSTALL_DESIGN.md](AGENT_FIRST_INSTALL_DESIGN.md) — install-time agent bring-up, OEM bind mount, first-boot ordering.
|
||||
- [GUEST_SYNC_DESIGN.md](GUEST_SYNC_DESIGN.md) — version-stamp comparison + windowless agent transport for upgrading the guest payload.
|
||||
- [REVERSE_OPEN_DESIGN.md](REVERSE_OPEN_DESIGN.md) — Linux "Open with" → Windows handler reverse path (#48).
|
||||
|
||||
### Reference (process / interface)
|
||||
Long-form references that describe code paths or interface contracts.
|
||||
- [LIFECYCLE.md](LIFECYCLE.md) ([한국어](LIFECYCLE.ko.md)) — end-to-end pod lifecycle: install, sysprep, migrate, apply chain, multi-session, discovery, transport selection. "Who fires it, what it does, where the code lives."
|
||||
- [TRANSPORT_ABC.md](TRANSPORT_ABC.md) — host→guest `Transport` abstract base class, the `FreerdpTransport` / `AgentTransport` contracts, dispatch policy.
|
||||
|
||||
### Roadmaps
|
||||
Per-release cut-lists with execution order and deferred-item tracking.
|
||||
- [ROADMAP-0.6.0.md](ROADMAP-0.6.0.md) — Consolidation & UX release: unify provisioning, thin AppImage, command-taxonomy reorg.
|
||||
|
||||
### Reviews / audits
|
||||
Point-in-time decisions and their rationale.
|
||||
- [ROTATION_SECURITY_REVIEW_2026-05-07.md](ROTATION_SECURITY_REVIEW_2026-05-07.md) — security review of the password-rotation flow.
|
||||
|
||||
### Schemas
|
||||
Machine-readable specs consumed by tooling.
|
||||
- [install_failure.schema.json](install_failure.schema.json) — JSON Schema for the install-failure telemetry blob.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Naming:** design proposals carry the `_DESIGN.md` suffix. Reference / interface / roadmap / review / schema files use a descriptive name without the suffix (their content type is documented by the section they live under here).
|
||||
- **Dating:** reviews and audits include the date in the filename (`*_YYYY-MM-DD.md`). Roadmaps include the target version (`ROADMAP-X.Y.Z.md`).
|
||||
- **No `.ko.md` mirrors required.** Korean mirrors are for user-facing docs only; engineering docs are English-only unless a specific doc is dual-authored (LIFECYCLE is currently the only exception).
|
||||
- **No behaviour-change content.** A design doc describing a change shouldn't ship in the same PR as the change itself land; either the design comes first (the PR that lands the change refers back to it) or the doc is co-authored with the change but marked clearly as describing what just landed.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
# WinPodX 0.6.0 — Consolidation & UX Release
|
||||
|
||||
## Goal
|
||||
|
||||
Single-pass cleanup of accumulated structural debt: unify duplicated implementations, sharpen the command surface, and rethink the AppImage so it stops being fragile. After 0.6.0, the shells (`install.sh`, `uninstall.sh`, `postinst`, `postrm`) are thin wrappers and the Python CLI is the single source of truth.
|
||||
|
||||
This roadmap is the cut-list. Items move from "in scope" to "done" only when the corresponding PR has merged to `main`. Deferred items are recorded so we don't lose them; "won't do" items are decided no.
|
||||
|
||||
## In scope (must land in 0.6.0)
|
||||
|
||||
### Provisioning + AppImage
|
||||
|
||||
- **A. Thin AppImage.** Drop bundled podman / podman-compose / conmon / crun / netavark / pasta. Keep FreeRDP + Python + Qt + WinPodX. Require host podman/docker/libvirt (same model as `install.sh`). Closes #357 and #363 by *removing the root cause* (bundled podman shadowing or poisoning the host stack) instead of patching around it. AppImage shrinks ~150 MB → ~50 MB. `_hostenv` helper collapses to an `LD_LIBRARY_PATH` strip (no host-first `PATH` games needed once nothing is shadowing). **Effort M / Risk M** (recipe rebuild + reporter smoke).
|
||||
- **B. P1 — `winpodx provision` single source of truth.** New `core/provisioner.finish_provisioning(cfg, *, wait_timeout, require_agent, with_reverse_open)` consolidates the `wait-ready → apply-fixes → discovery → reverse-open` chain currently duplicated in `install.sh`, `setup_cmd._run_full_provision`, `migrate`, and `pending.resume`. `install.sh` collapses ~140 lines → ~5. `setup --create-only` is dropped. **Effort L / Risk M** (load-bearing; needs real-Windows smoke). **Absorbs I.**
|
||||
- **I. Qt GUI bring-up → `winpodx provision`.** `gui/_main_window_bringup.py` is the 5th copy of the chain. Folded into B. **Effort S (inside B).**
|
||||
|
||||
### Hardcoding / single source
|
||||
|
||||
- **C. P2 — port 8765 constant.** `core/agent.AGENT_PORT` referenced by all host-side callers (compose generation, checks, guest_sync, `install.sh` health-poll). The guest-side `agent.ps1` keeps its own literal (can't import Python); the pairing is documented. 8 literals → 1 host SoT. **Effort S / Risk Low.**
|
||||
- **D. P3 — dependency / health single detector.** Extend `utils/deps.py:check_all` (+ kvm, + podman-major gate). `deps_quickcheck`, `doctor`, `setup_cmd` all consume it. Drop the re-hardcoded freerdp lists. `install.sh` keeps a minimal pre-venv bash probe (genuinely shell-unique). 5 places → 1 Python SoT + 1 minimal shell probe. **Effort M / Risk Low-M.**
|
||||
- **E. P4 — backend selection helper.** `core/backend/select.py:choose_backend(prefer, present)` implementing `podman → docker → libvirt` + the podman major-version gate. `install.sh` and `setup_cmd` both consume it. 3 places → 1. **Effort S-M / Risk Low.**
|
||||
- **F. P5 — version + edition single source.** `__init__.__version__` reads from package metadata. Windows-edition help/locale strings generate from `_KNOWN_WIN_VERSIONS`. Fix stale `packaging/rpm/winpodx.spec:8` literal. **Effort S / Risk Low.**
|
||||
|
||||
### Command surface
|
||||
|
||||
- **G. Command taxonomy reorg.**
|
||||
- Diagnostics: `info` + `check` + `doctor` → single `winpodx doctor` (`--json` machine-readable, `--quick` fast subset, `--fix` see K).
|
||||
- `pod` keeps lifecycle only: `start | stop | restart | recreate | status | wait-ready`.
|
||||
- New `winpodx guest`: `apply-fixes`, `sync`, `sync-password`, `multi-session`, `recover-oem`.
|
||||
- New `winpodx install`: `status`, `resume`, `grow-disk`, `disk-usage`.
|
||||
- Deprecation aliases: old `pod apply-fixes` etc. keep working through 0.6.x with a deprecation note; removed in 0.7.0.
|
||||
- `install.sh` / `uninstall.sh` / `postinst` / `postrm` call only public `winpodx <command>` (no internal Python imports).
|
||||
- **Effort M-L / Risk Low** with aliases.
|
||||
- **K. `winpodx doctor --fix` auto-remediation.** ✅ Implemented (PR `feat/doctor-fix`); guest-touching fixers smoke-gated before merge. Common recoverable failures: dead agent → keepalive kick, stale lock files → purge, missing desktop entries → re-register, oem-version drift → trigger guest-sync. Each fix idempotent. **Effort S-M / Risk Low.**
|
||||
|
||||
### Docs + ops
|
||||
|
||||
- **H. Documentation refresh (mandatory for G).** README, FEATURES, COMPARISON, ARCHITECTURE, LIFECYCLE, USAGE + their `docs/*.ko.md` mirrors. New-command table + old→new alias map. Install / upgrade flows reflect Thin AppImage + `winpodx provision`. **Effort M / Risk Low.**
|
||||
- **J. Config schema versioning.** `cfg.schema_version: int = 1` field + load-time migration hook. 0.6.0 doesn't change config structure, but the marker lets 0.7.0+ migrate cleanly. **Effort S / Risk Low.**
|
||||
- **L. Logging hygiene pass.** Consistent levels across modules (some INFO too noisy, some WARNING missing). One-pass review. **Effort S / Risk Low.**
|
||||
- **M. `install.sh`: drop discovery 6× retry.** Agent keepalive (#359) proven; 6× is overkill. 1-2× max. Faster first run. **Effort S / Risk Low.**
|
||||
- **N. Packaging single-source enforcement.** `flake.nix` already reads `pyproject` (good); fix `winpodx.spec:8` stale literal + a lint that catches future drift. Subset of F. **Effort S / Risk Low.**
|
||||
|
||||
## Execution order
|
||||
|
||||
1. **C** — port constant (warm-up, fastest, low risk).
|
||||
2. **J** — `schema_version` (cheap future-proofing before big changes).
|
||||
3. **F + N** — version / edition / packaging single-source (quick).
|
||||
4. **D** — dep check (prerequisite for cleaner E and `install.sh`).
|
||||
5. **E** — backend select (prerequisite for cleaner A and `install.sh`).
|
||||
6. **A** — Thin AppImage (root-cause kill for #357 / #363).
|
||||
7. **B + I** — provision unify + GUI bring-up (biggest piece; real-Windows smoke gate).
|
||||
8. **G** — command taxonomy (depends on B; deprecation aliases for back-compat).
|
||||
9. **K** — `doctor --fix` (pairs with G's doctor).
|
||||
10. **L** — logging hygiene (pass once everything else has landed).
|
||||
11. **M** — `install.sh` retry trim (final tightening).
|
||||
12. **H** — docs refresh (last; reflects final shapes; ko mirrors after EN approved).
|
||||
13. Cut `v0.6.0` + `REL-v0.6.0`.
|
||||
|
||||
Real-Windows smoke gate applies to: **A** (rebuilt AppImage), **B / I** (provision chain), **G** (command renames touching guest paths), **K** (--fix actions). Other items don't need it.
|
||||
|
||||
## Deferred to 0.7.0+ (tracked, not started)
|
||||
|
||||
- **Agent authentication.** The listener is localhost-only and only reachable from the host via dockur's port map → low risk today. Future: HMAC or shared-token on `/exec` to harden against host-local malice.
|
||||
- **Real-Windows smoke CI lane.** A cloud Windows VM in CI to gate guest-side install changes. Expensive (~$50/mo + setup); manual user smoke continues for now.
|
||||
- **libvirt backend feature-completeness.** podman is primary; libvirt has rougher edges (storage growth, multi-session). Separate effort.
|
||||
- **Manual mode (no backend) testing.** Niche path; document constraints rather than build a test surface.
|
||||
- **Performance pass.** Discovery cache, FreeRDP launch latency, full-rescan vs. incremental. Current is adequate.
|
||||
- **Reverse-open edge cases.** Special chars in paths, multi-monitor positioning, MIME priority conflicts. File as encountered.
|
||||
- **i18n drift detection.** A lint that catches keys present in only some `locale/*.json`. Small but needs CI infra.
|
||||
- **Content-addressed `oem_bundle`.** Currently a counter (#359 bumped to 26). A hash would be drift-proof. Backwards-compat work to migrate existing stamps.
|
||||
- **OBS publish race.** Timing race on the publish workflow (intermittent re-runs needed). Workflow refactor.
|
||||
- **Lock-file lifecycle.** Stale lock cleanup in `~/.local/share/winpodx/run/` is hit-or-miss. Owned-pid check + boot-time sweep.
|
||||
- **PowerShell window flash regression test.** A test that asserts no host→guest op ends up on `run_in_windows` (the FreeRDP path that flashes) outside of explicitly-flash-okay callers. Locks in the #355 win.
|
||||
|
||||
## Won't do
|
||||
|
||||
- **External telemetry.** Decided. Local `FreeRDP-fallback` log markers (already shipped) substitute for the only signal we needed.
|
||||
- **New opt-in flags for behaviour.** Decided. Features ship default-on; behaviour-changing flags are debt.
|
||||
- **Bundled-podman "actually works".** Option A acknowledges the fat AppImage's "self-contained" promise was always limited by the host-systemd dependency. Thin makes that limit explicit instead of pretending.
|
||||
@@ -0,0 +1,288 @@
|
||||
# Rotation Memory-Exposure Review — Agent-first vs FreeRDP
|
||||
|
||||
**Status**: complete, sign-off
|
||||
**Date**: 2026-05-07
|
||||
**Reviewer**: security-reviewer (rotation-agent-first team)
|
||||
**Scope**: `_change_windows_password` migration from FreeRDP-only (Rule #6)
|
||||
to agent-first with FreeRDP fallback. Verifies the team's "neither is
|
||||
strictly worse" claim used to retire Rule #6 in `docs/TRANSPORT_ABC.md`.
|
||||
**Sources**:
|
||||
- `src/winpodx/core/rotation/__init__.py` (`_change_windows_password`, line 72)
|
||||
- `src/winpodx/core/windows_exec.py` (`run_in_windows`, line 120; `run_via_transport`, line 77)
|
||||
- `src/winpodx/core/transport/agent.py` (`AgentTransport.exec`, line 84)
|
||||
- `src/winpodx/core/agent.py` (`AgentClient.exec`, line 195)
|
||||
- `config/oem/agent/agent.ps1` (`Invoke-ExecScript`, line 146)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
The team's claim that **"agent path is not strictly worse than FreeRDP
|
||||
path for new-password exposure"** is correct and approved.
|
||||
|
||||
- Both paths terminate at the same dominant exposure: `net.exe <user>
|
||||
<newpw>` argv inside the guest, visible to Task Manager, WMI
|
||||
`Win32_Process.CommandLine`, ETW process-create events
|
||||
(`Microsoft-Windows-Kernel-Process` GUID
|
||||
`{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}`), Sysmon Event ID 1, and
|
||||
Defender ASR / AMSI telemetry.
|
||||
- The on-disk `.ps1` artifact moves from the **host** filesystem
|
||||
(FreeRDP path, `~/.local/share/winpodx/windows-exec/`) to the
|
||||
**guest** filesystem (agent path, `C:\OEM\agent-runs\<guid>.ps1`).
|
||||
Both are deleted in a `finally` immediately after the call. Lifetime
|
||||
is comparable (~1-2s). The host-side artifact under a single-user
|
||||
`$HOME` and the guest-side artifact under `C:\OEM` (default perms:
|
||||
Administrators / SYSTEM full, authenticated Users read+execute) have
|
||||
similar realistic blast radius — neither materially weaker than the
|
||||
other for an attacker who already has code execution at that level.
|
||||
- Agent path adds **one** in-memory copy not present in the FreeRDP
|
||||
path: the HTTP request body inside the guest agent's PowerShell
|
||||
process (`$body`, `$parsed.script`, decoded `$bytes`), held until GC.
|
||||
This is the new surface introduced by the migration.
|
||||
- FreeRDP path contains **one** offsetting exposure not present in the
|
||||
agent path: `xfreerdp /p:<password>` on host argv — but that is the
|
||||
*current* RDP password, not the new password being set, so it does
|
||||
not change the new-password surface. (It does mean FreeRDP path
|
||||
exposes the OLD password on host argv, which is a separate
|
||||
long-standing concern unrelated to this migration.)
|
||||
|
||||
The "agent process memory" objection in the original Rule #6 rationale
|
||||
overstated the marginal risk: the dominant exposure (`net.exe` argv)
|
||||
is identical between the two paths, and an attacker capable of reading
|
||||
agent process memory inside the guest is already inside the trust
|
||||
boundary that owns the local SAM database.
|
||||
|
||||
**Recommendation**: sign off the migration. Optional hardenings below
|
||||
are cheap and worth landing, but **none are blocking**.
|
||||
|
||||
---
|
||||
|
||||
## Path-by-path enumeration
|
||||
|
||||
### FreeRDP path
|
||||
|
||||
Code: `_change_windows_password` → `run_in_windows`
|
||||
(`src/winpodx/core/windows_exec.py:120`).
|
||||
|
||||
| # | Artifact | Where | Form | Persistence | Lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Python local `pw` | host process heap | string `<newpw>` | in-memory | until function returns + GC |
|
||||
| 2 | `payload` string | host process heap | `& net user '<u>' '<newpw>' | Out-Null\n…` | in-memory | until function returns + GC |
|
||||
| 3 | `wrapper` string | host process heap | full `.ps1` source incl. payload | in-memory | until function returns + GC |
|
||||
| 4 | `script_path` on host disk | `~/.local/share/winpodx/windows-exec/rotate-password.ps1` | full `.ps1` source | **on-disk** | written line 213, **deleted in `finally` line 313**. Lives ~5-15s (RDP handshake + script + disconnect). Default umask → typically 0644 on single-user `$HOME` |
|
||||
| 5 | xfreerdp argv | host argv | `/p:<RDP_old_pw>` plus `/v: /u: …` | in-memory + ps argv | until xfreerdp exits. **Does not contain the new password.** |
|
||||
| 6 | RDP wire | TCP to guest | TLS-wrapped (`/sec:tls`, `/cert:ignore`) | in-transit | duration of session |
|
||||
| 7 | `\\tsclient\home\…\rotate-password.ps1` (guest view of #4) | RDP drive redirect | full `.ps1` source | virtual; backed by host file | identical to #4 |
|
||||
| 8 | guest PS argv | guest process argv | `powershell.exe -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass -File "\\tsclient\home\…\rotate-password.ps1"` | in-memory + ps argv | until PS exits. **Does not contain the new password.** |
|
||||
| 9 | guest PS heap | guest process heap | `$payload`-equivalent string from script source | in-memory | until PS exits |
|
||||
| 10 | **`net.exe` argv** | guest process argv | `net user <user> <newpw>` | in-memory + ps argv | until `net.exe` exits (sub-second). Visible via Task Manager → Details → Command line column, WMI `Win32_Process.CommandLine`, ETW Kernel-Process / Sysmon EID 1, Defender process telemetry. **Dominant exposure.** |
|
||||
| 11 | `result.json` on host | `~/.local/share/winpodx/windows-exec/rotate-password-result.json` | `{rc, stdout: "password set", stderr: ""}` | on-disk | written by guest, read+deleted by host (line 337). Does **not** contain the password. |
|
||||
| 12 | RDP video framebuffer | RDP wire / FreeRDP buffers | rendered console of PS — but `-WindowStyle Hidden` + RemoteApp means no console paint, plus payload uses `Out-Null` | likely-empty | RDP session lifetime |
|
||||
|
||||
### Agent path
|
||||
|
||||
Code: `_change_windows_password` → `run_via_transport` →
|
||||
`AgentTransport.exec` → `AgentClient.exec` → guest `Invoke-ExecScript`
|
||||
(`config/oem/agent/agent.ps1:146`).
|
||||
|
||||
| # | Artifact | Where | Form | Persistence | Lifetime |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Python local `pw` | host process heap | string `<newpw>` | in-memory | until function returns + GC |
|
||||
| 2 | `payload` string | host process heap | `& net user '<u>' '<newpw>' | Out-Null\n…` | in-memory | until function returns + GC |
|
||||
| 3 | `encoded` (base64) | host process heap | b64 of script bytes | in-memory | until function returns + GC |
|
||||
| 4 | `body` (JSON) | host process heap | `{"script":"<b64>","timeout_sec":45}` bytes | in-memory | until function returns + GC |
|
||||
| 5 | HTTP wire | `127.0.0.1:8765` → QEMU slirp → guest `+:8765` | plaintext HTTP body containing #4 | in-transit | request duration. Host loopback + container-to-VM slirp; never on a physical wire. The compose mapping is `127.0.0.1:8765:8765/tcp` — loopback-only on the host |
|
||||
| 6 | guest agent `$body` | guest agent PS heap | full request body string | in-memory | until next GC after `Read-Body` returns. Held simultaneously with #7-#9 |
|
||||
| 7 | guest agent `$parsed.script` | guest agent PS heap | b64 of script | in-memory | until next GC |
|
||||
| 8 | guest agent `$bytes` (decoded) | guest agent PS heap | raw script bytes incl. password | in-memory | written to `$tempFile` then conceptually superseded; PS GC keeps until next collection. **This is the marginal new exposure vs. FreeRDP path.** |
|
||||
| 9 | guest agent `$hash` / log entry | `C:\OEM\agent.log` | SHA256 hex of script bytes (NOT script content) | on-disk | persistent. **Does not contain the password.** Hash is one-way; not reversible without dictionary attack on the (small) password keyspace. Generated passwords (`generate_password`) are high-entropy → not a practical recovery vector |
|
||||
| 10 | `$tempFile` on guest disk | `C:\OEM\agent-runs\<guid>.ps1` | full script bytes | **on-disk** | written line 157, **deleted in `finally` line 218**. Default `C:\OEM` ACL: BUILTIN\Administrators / NT AUTHORITY\SYSTEM full, authenticated Users read+execute |
|
||||
| 11 | guest child PS argv | guest process argv | `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "<tempFile>"` | in-memory + ps argv | until PS exits. **Does not contain the new password.** |
|
||||
| 12 | guest child PS heap | guest process heap | full script source from `-File` read | in-memory | until PS exits |
|
||||
| 13 | **`net.exe` argv** | guest process argv | `net user <user> <newpw>` | in-memory + ps argv | until `net.exe` exits. **Identical to FreeRDP row #10.** |
|
||||
| 14 | guest `$result.stdout/stderr` | guest agent PS heap then HTTP response | drained from child PS via `ReadToEndAsync` | in-memory | until response written. Does **not** contain the password (payload uses `Out-Null`) |
|
||||
| 15 | host `payload` JSON response | host process heap | `{rc, stdout, stderr}` | in-memory | until function returns + GC. Does **not** contain the password |
|
||||
|
||||
---
|
||||
|
||||
## Side-by-side comparison
|
||||
|
||||
| Dimension | FreeRDP | Agent | Worse? |
|
||||
|---|---|---|---|
|
||||
| Host disk artifact (script .ps1) | yes — `$HOME/.local/share/winpodx/windows-exec/` ~5-15s | **no** | agent better |
|
||||
| Guest disk artifact (script .ps1) | no | yes — `C:\OEM\agent-runs\<guid>.ps1` ~1-3s | freerdp better |
|
||||
| Wire (script body) | RDP-TLS (`/sec:tls /cert:ignore`) | plaintext HTTP on loopback + slirp | tie (both effectively local) |
|
||||
| Host argv exposure of NEW password | no | no | tie |
|
||||
| Host argv exposure of OLD/RDP password | yes (`/p:`) | no | agent better (out-of-scope of new-pw question, but a free win) |
|
||||
| Guest argv exposure of NEW password (PS launch) | no | no | tie |
|
||||
| **Guest argv exposure of NEW password (`net.exe`)** | **yes — Task Manager / WMI / ETW / Defender** | **yes — Task Manager / WMI / ETW / Defender** | **tie. Dominant exposure.** |
|
||||
| Guest agent process memory (script body) | n/a | yes — `$body`, `$parsed.script`, `$bytes` until GC | freerdp better (marginal) |
|
||||
| Persisted log of password content | no | no (only SHA256 hash in agent.log) | tie |
|
||||
| Persisted result file containing password | no | no | tie |
|
||||
| RDP framebuffer / console paint | mitigated by `-WindowStyle Hidden` + RemoteApp + `Out-Null` | n/a (no console) | agent better |
|
||||
| Lifetime — script body on disk | ~5-15s host | ~1-3s guest | agent shorter |
|
||||
| Lifetime — script body in memory after dispatch | host PS exits → freed | guest agent PS keeps `$body`/`$bytes` until GC (long-lived process) | freerdp shorter |
|
||||
|
||||
### Where they differ, in plain English
|
||||
|
||||
- **Agent gives up**: a marginally longer-lived in-memory copy of the
|
||||
script (and therefore the new password) inside the guest agent's
|
||||
long-running PowerShell process, until GC reclaims `$body`,
|
||||
`$parsed.script`, and `$bytes`.
|
||||
- **Agent gains**: zero host-disk script artifact, zero RDP/xfreerdp
|
||||
invocation (so no host argv exposure of the OLD RDP password
|
||||
either), no PowerShell window flash, ~5x faster.
|
||||
|
||||
Both paths have **identical** `net.exe` argv exposure — the dominant
|
||||
attack surface for "anyone watching processes inside the guest" — so
|
||||
neither is meaningfully worse for the realistic threat model.
|
||||
|
||||
---
|
||||
|
||||
## Threat model check
|
||||
|
||||
The attacker classes that matter for new-password disclosure:
|
||||
|
||||
1. **Other unprivileged user on the host** — neither path lets them
|
||||
read the password. FreeRDP `.ps1` is in the user's own `$HOME`
|
||||
under default 0644 (no other user has it; this is single-user
|
||||
linux). Agent path has no host-disk artifact.
|
||||
2. **Other unprivileged user inside the guest Windows VM** — both
|
||||
paths expose `net.exe` argv, readable via `Win32_Process.CommandLine`.
|
||||
Default Windows: any authenticated user can read any process's
|
||||
command line via WMI. Both paths fall the same way.
|
||||
3. **Defender / EDR / Sysmon telemetry** — both paths trigger Sysmon
|
||||
EID 1 with the `net user <user> <pw>` command line. Both fall the
|
||||
same way.
|
||||
4. **Memory-read inside guest agent process (e.g., crash dump leaked)**
|
||||
— agent path adds this surface; FreeRDP doesn't have it. But: an
|
||||
attacker able to read a privileged guest process's memory has
|
||||
already won (same boundary owns SAM via `lsass.exe`).
|
||||
5. **Wire sniffing** — neither path crosses a physical wire. Loopback
|
||||
+ slirp + RDP/TLS are all local to the host. Tie.
|
||||
6. **Bad actor with disk forensics on host** — FreeRDP path leaves a
|
||||
delete-then-overwrite-eligible inode on the host briefly; agent
|
||||
path leaves nothing. Agent better.
|
||||
7. **Bad actor with disk forensics on guest** — agent leaves a
|
||||
delete-then-overwrite-eligible inode on guest briefly; FreeRDP
|
||||
leaves nothing. FreeRDP better.
|
||||
|
||||
Symmetric overall.
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**Sign-off granted.** The "neither is strictly worse" claim is
|
||||
accurate. Rule #6 should be retired (task #2). The migration unblocks
|
||||
the cachyos xfreerdp3 drive-redirect timeout bug without introducing
|
||||
a meaningfully larger attack surface for the new password.
|
||||
|
||||
The original Rule #6 rationale — "expose the new password to the
|
||||
agent process and to anyone who could read the agent's process
|
||||
memory" — was technically true but rendered moot by `net.exe` being
|
||||
the unavoidable terminus for both paths. Anyone capable of reading
|
||||
the agent's process memory is already inside the guest's high-
|
||||
privilege boundary, where reading `net.exe`'s argv (or, for that
|
||||
matter, dumping `lsass.exe`) is strictly easier than diffing a
|
||||
PowerShell heap looking for ungarbage-collected strings.
|
||||
|
||||
---
|
||||
|
||||
## Bonus hardening (optional, non-blocking)
|
||||
|
||||
These are cheap, principled, and don't break the agent's existing
|
||||
simplicity. None are required for sign-off, but they tighten the
|
||||
agent path beyond parity with the FreeRDP path.
|
||||
|
||||
### H1 — replace argv `net.exe` with `NetUserSetInfo` (BIGGEST WIN)
|
||||
|
||||
**Where**: the rotation payload itself, in
|
||||
`_change_windows_password`. Both transport paths inherit this.
|
||||
|
||||
**Change**: instead of `& net user '<u>' '<pw>' | Out-Null`, call the
|
||||
Win32 `NetUserSetInfo` API directly with `USER_INFO_1003` and a
|
||||
`SecureString`-derived buffer. PowerShell can do this via
|
||||
`[DirectoryServices.AccountManagement.UserPrincipal]::FindByIdentity(...).SetPassword($pw)`
|
||||
or Add-Type'd P/Invoke to `netapi32!NetUserSetInfo`.
|
||||
|
||||
**Why this is the biggest win**: the new password no longer appears
|
||||
in any process's argv at all. Eliminates Sysmon EID 1, WMI
|
||||
`CommandLine`, Defender process-telemetry, Task Manager Details
|
||||
column exposure in one shot. **This is the single change with the
|
||||
highest leverage for both transports**, and is independent of the
|
||||
agent-first migration.
|
||||
|
||||
**Cost**: ~10 lines of PowerShell. The host doesn't need to change.
|
||||
|
||||
### H2 — pass payload via stdin instead of `-File <temp.ps1>`
|
||||
|
||||
**Where**: `Invoke-ExecScript` in `agent.ps1` (line 146).
|
||||
|
||||
**Change**: spawn `powershell.exe -NoProfile -ExecutionPolicy Bypass
|
||||
-Command -` with `RedirectStandardInput=$true` and write the script
|
||||
bytes to `$proc.StandardInput`. Skip the temp file entirely.
|
||||
|
||||
**Why**: removes the on-disk artifact (#10 in the agent table). Even
|
||||
though it's deleted in `finally`, "no file written" beats "file
|
||||
written then unlinked" for forensic recovery.
|
||||
|
||||
**Cost**: stdin redirect adds ~5 lines; needs care that the existing
|
||||
async stdout/stderr drain still works (it does — they're independent
|
||||
streams). Tested pattern. Worth it.
|
||||
|
||||
### H3 — zero `$body` / `$bytes` after dispatch
|
||||
|
||||
**Where**: `Invoke-ExecScript` in `agent.ps1`, end of the function.
|
||||
|
||||
**Change**: after `WriteAllBytes` (or stdin write if H2 lands), do
|
||||
`[Array]::Clear($bytes, 0, $bytes.Length)` and explicitly null out
|
||||
`$body`, `$parsed.script`, `$scriptB64`, `$bytes`. Optionally call
|
||||
`[GC]::Collect()` to make the freed strings less likely to linger
|
||||
across the next request handler.
|
||||
|
||||
**Why**: shrinks the in-memory window where the password lives in the
|
||||
agent's PS heap from "until next GC" to "until the next allocator
|
||||
event". `[Array]::Clear` on the byte[] is the only one of these that
|
||||
reliably zeroes — strings in .NET are interned/immutable and can't be
|
||||
overwritten, but nulling local refs lets them be collected sooner.
|
||||
|
||||
**Cost**: ~4 lines. Doesn't fully close the gap (PS strings are
|
||||
immutable so the b64 and decoded-string copies still exist until GC),
|
||||
but it cuts the dominant lifetime.
|
||||
|
||||
### H4 — explicit SHA256 hash blocklist for the rotation payload
|
||||
|
||||
**Where**: agent.log already records `hash=<sha256>` for every /exec.
|
||||
|
||||
**Change**: nothing in code. Operationally, the rotation payload's
|
||||
hash is constant per-`(user, newpw)` pair; if a future audit wants to
|
||||
prove "this payload was the rotation call, not something else", the
|
||||
hash is the link. Document this in the rotation module so an incident
|
||||
response can correlate without exposing the password.
|
||||
|
||||
**Cost**: a docstring line. Free.
|
||||
|
||||
### Recommended package
|
||||
|
||||
If you ship one thing, ship **H1** (`NetUserSetInfo`). It eliminates
|
||||
the dominant exposure for **both** transports and is independent of
|
||||
the migration. **H2 + H3** are cheap follow-ups specific to the agent
|
||||
path. H4 is a docs note.
|
||||
|
||||
None of these block the agent-first migration. Land that on its own,
|
||||
then file H1 as a separate hardening PR.
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
- [x] FreeRDP path enumerated
|
||||
- [x] Agent path enumerated
|
||||
- [x] Side-by-side comparison
|
||||
- [x] Threat-model check
|
||||
- [x] Verdict: agent path is not strictly worse — sign-off granted
|
||||
- [x] Hardening recommendations provided
|
||||
|
||||
Reviewer: security-reviewer
|
||||
Date: 2026-05-07
|
||||
@@ -0,0 +1,331 @@
|
||||
# WinPodX Transport ABC — host→guest channel contract
|
||||
|
||||
**Status**: contract spec, Sprint 0 of `feat/redesign`. Both Track A (host
|
||||
modular refactor) and Track B (agent-v2 feature) implement against this
|
||||
spec so they converge cleanly.
|
||||
|
||||
**Branch**: `feat/redesign`. Don't change this contract on a different
|
||||
branch without bumping the version line below.
|
||||
|
||||
**Spec version**: 1.
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The host has multiple ways to run a PowerShell script inside the Windows
|
||||
guest:
|
||||
|
||||
- **FreeRDP RemoteApp** — slow (~5-10s per call), shows a brief PS
|
||||
window flash, but always available once RDP works.
|
||||
- **HTTP agent** — fast (~50ms localhost roundtrip), invisible, only
|
||||
available once `agent.ps1` is bound.
|
||||
- (future) **Other channels** — if we ever add WSMan/WinRM, etc.
|
||||
|
||||
These differ in performance + UI artifacts but expose the same operation:
|
||||
"run this script, return rc/stdout/stderr". Callers shouldn't care which
|
||||
channel is used; they should declare what they need and let the dispatcher
|
||||
pick.
|
||||
|
||||
This document defines the abstract `Transport` interface every concrete
|
||||
channel must implement, plus the `dispatch` helper that picks the best
|
||||
available transport.
|
||||
|
||||
## Module layout
|
||||
|
||||
```
|
||||
src/winpodx/core/transport/
|
||||
__init__.py # public re-exports: Transport, TransportError, dispatch, ExecResult, etc.
|
||||
base.py # the ABC + result types
|
||||
freerdp.py # FreerdpTransport — wraps windows_exec.run_in_windows
|
||||
agent.py # AgentTransport — wraps AgentClient
|
||||
dispatch.py # dispatch(): pick agent if /health responds, else freerdp
|
||||
```
|
||||
|
||||
Tests live under `tests/test_transport/`.
|
||||
|
||||
## Public API (Python)
|
||||
|
||||
```python
|
||||
# src/winpodx/core/transport/base.py
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecResult:
|
||||
"""Result of a one-shot script execution.
|
||||
|
||||
rc/stdout/stderr come from the script itself, not the transport.
|
||||
Transport-level failures (channel down, auth, timeout) raise
|
||||
TransportError subclasses instead of returning a bad ExecResult.
|
||||
"""
|
||||
|
||||
rc: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.rc == 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthStatus:
|
||||
"""Result of a transport-level health probe.
|
||||
|
||||
`available` is the only field every transport must populate; the
|
||||
rest is best-effort optional metadata."""
|
||||
|
||||
available: bool
|
||||
version: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
"""Base for any transport-level failure (channel down, auth, malformed
|
||||
response). Distinct from a script-level non-zero rc, which lives
|
||||
inside ExecResult."""
|
||||
|
||||
|
||||
class TransportUnavailable(TransportError):
|
||||
"""Transport is not reachable (connection refused, no FreeRDP binary,
|
||||
no agent /health response, etc). Caller may fall back to another
|
||||
transport via dispatch()."""
|
||||
|
||||
|
||||
class TransportAuthError(TransportError):
|
||||
"""Transport reachable but rejected our auth (agent 401/403, FreeRDP
|
||||
bad password). Do NOT silently fall back to another transport — auth
|
||||
failures usually mean config drift, not channel state."""
|
||||
|
||||
|
||||
class TransportTimeoutError(TransportError):
|
||||
"""Server accepted the request but didn't finish in time."""
|
||||
|
||||
|
||||
class Transport(ABC):
|
||||
"""Abstract host->guest command channel.
|
||||
|
||||
Implementations: FreerdpTransport (slow, always-on after RDP works),
|
||||
AgentTransport (fast, available after install.bat finishes).
|
||||
|
||||
Callers should declare intent in business terms (apply fix X, run
|
||||
discovery, etc.) via higher-level modules; this layer just runs
|
||||
PowerShell.
|
||||
"""
|
||||
|
||||
name: str # human-readable name, e.g. "freerdp" or "agent"
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> HealthStatus:
|
||||
"""Cheap probe (~50ms-2s budget) used by dispatch() to pick a
|
||||
transport. MUST NOT raise on transient failures — return
|
||||
``HealthStatus(available=False, detail=str(e))`` instead. May
|
||||
raise on configuration errors (missing FreeRDP binary etc) so
|
||||
the caller surfaces the problem rather than silently falling
|
||||
back."""
|
||||
|
||||
@abstractmethod
|
||||
def exec(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
timeout: int = 60,
|
||||
description: str = "winpodx-exec",
|
||||
) -> ExecResult:
|
||||
"""Run ``script`` as PowerShell on the guest.
|
||||
|
||||
``description`` is a short human-readable label for log lines and
|
||||
for FreeRDP RemoteApp's task name. Caller is trusted; do not
|
||||
inject characters that break the underlying channel.
|
||||
|
||||
Raises:
|
||||
TransportUnavailable: channel down (caller may fall back).
|
||||
TransportAuthError: auth rejected (do NOT fall back).
|
||||
TransportTimeoutError: server-side timeout.
|
||||
TransportError: any other channel-level failure.
|
||||
|
||||
Returns ExecResult even when script's rc != 0 — that's a
|
||||
script-level result, not a transport-level error.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def stream(
|
||||
self,
|
||||
script: str,
|
||||
on_progress: Callable[[str], None],
|
||||
*,
|
||||
timeout: int = 600,
|
||||
description: str = "winpodx-stream",
|
||||
) -> ExecResult:
|
||||
"""Run ``script`` and call ``on_progress(line)`` for each progress
|
||||
line the script emits via the agreed channel:
|
||||
|
||||
* FreerdpTransport: tail the progress file written via
|
||||
``Write-WinpodxProgress`` (existing protocol in windows_exec.py).
|
||||
* AgentTransport: SSE feed from /apply/{step}, /discover, etc.
|
||||
|
||||
Returns the final ExecResult after the stream closes. Same error
|
||||
contract as exec().
|
||||
"""
|
||||
```
|
||||
|
||||
## Dispatcher
|
||||
|
||||
```python
|
||||
# src/winpodx/core/transport/dispatch.py
|
||||
from winpodx.core.transport.base import Transport, TransportUnavailable
|
||||
from winpodx.core.transport.freerdp import FreerdpTransport
|
||||
from winpodx.core.transport.agent import AgentTransport
|
||||
|
||||
|
||||
def dispatch(cfg, *, prefer: str | None = None) -> Transport:
|
||||
"""Pick the best available transport for cfg.
|
||||
|
||||
Default policy:
|
||||
1. If AgentTransport.health().available, use it.
|
||||
2. Else fall back to FreerdpTransport.
|
||||
|
||||
``prefer="freerdp"`` forces FreerdpTransport (used for password
|
||||
rotation — see anti-goal below). ``prefer="agent"`` raises
|
||||
TransportUnavailable if agent isn't up rather than falling back.
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
## Behavioral rules (binding on every implementation)
|
||||
|
||||
### 1. health() never raises on transient state
|
||||
|
||||
A connection refused, timeout, or 5xx becomes
|
||||
`HealthStatus(available=False, detail=...)`. Only raise for
|
||||
configuration errors that prevent the transport from ever working
|
||||
(FreeRDP binary missing on PATH, no token file when one is required,
|
||||
invalid cfg).
|
||||
|
||||
This is what makes `dispatch()` safe — it can call `health()` on every
|
||||
transport without try/except chains.
|
||||
|
||||
### 2. exec() is the only retry boundary
|
||||
|
||||
Implementations do their own internal retry (e.g. agent retries
|
||||
once on a transient HTTP 503; FreeRDP doesn't retry — too expensive).
|
||||
Callers MUST NOT wrap exec() in their own retry loop without checking
|
||||
the exception type first; specifically, never retry on
|
||||
`TransportAuthError`.
|
||||
|
||||
### 3. Outputs are bytes-clean strings
|
||||
|
||||
`stdout` / `stderr` in `ExecResult` are decoded UTF-8 strings with
|
||||
errors replaced. Callers that need bytes must use a different transport
|
||||
(future work; not in v1).
|
||||
|
||||
### 4. PowerShell scripts must be self-contained
|
||||
|
||||
Callers pass complete PowerShell source. The transport may wrap it
|
||||
(e.g. FreerdpTransport adds the result-file harness) but does not
|
||||
parse, modify, or interpret the user's script.
|
||||
|
||||
### 5. Timeouts are enforced server-side when possible
|
||||
|
||||
AgentTransport: `/exec` endpoint enforces `timeout` via job kill.
|
||||
FreerdpTransport: `subprocess.run(timeout=...)` on the FreeRDP process.
|
||||
Either way, a hung script doesn't leak past the timeout.
|
||||
|
||||
### 6. (superseded 2026-05-07) Password rotation may use any Transport
|
||||
|
||||
Historical: this rule prohibited using Transport for password rotation,
|
||||
arguing (a) the host needs to authenticate FreeRDP with the OLD password
|
||||
and (b) routing via AgentTransport would expose the new password to the
|
||||
agent process memory.
|
||||
|
||||
Both arguments no longer hold:
|
||||
|
||||
- AgentTransport authenticates with a bearer token, not the user
|
||||
password. Bootstrapping is independent of which password is in cfg.
|
||||
- Both transports expose the new password equally — via PowerShell
|
||||
argv and `net user` argv on the guest, visible to any other guest
|
||||
process via Task Manager / WMI. The agent path adds one in-memory
|
||||
HTTP request buffer; the FreeRDP path adds one on-disk script file
|
||||
under `~/.local/share/winpodx/windows-exec/`. Neither is strictly
|
||||
worse from a memory-exposure standpoint.
|
||||
|
||||
In practice the prohibition caused a real bug: cachyos's xfreerdp3
|
||||
build has a broken bidirectional drive redirect, so the FreeRDP-only
|
||||
rotation path timed out at 45s with no recourse. `core/rotation/` now
|
||||
prefers AgentTransport with FreeRDP fallback.
|
||||
|
||||
The pre-rotation `_ROTATION_PENDING_MARKER` is still required and
|
||||
unchanged — it's the recovery contract for ANY transport that
|
||||
disconnects mid-rotation.
|
||||
|
||||
## Anti-goals (do NOT do these)
|
||||
|
||||
- **Don't add a generic key-value store** to ExecResult. If a script
|
||||
needs structured output, parse stdout in the caller.
|
||||
- **Don't make Transport awaitable.** v1 is sync. Async wrappers can
|
||||
come later if/when the GUI needs them.
|
||||
- **Don't add cancellation tokens to exec().** A canceled script leaves
|
||||
the guest in an undefined state. If you need cancellation, use
|
||||
stream() with a stop event in `on_progress`.
|
||||
- **Don't make the dispatcher cache transport instances**. Each
|
||||
caller gets a fresh Transport. State (token, base URL) lives on
|
||||
cfg + the transport's __init__ args, not the instance.
|
||||
|
||||
## Transport-specific notes
|
||||
|
||||
### FreerdpTransport
|
||||
|
||||
- Wraps `windows_exec.run_in_windows`.
|
||||
- `health()` checks for `xfreerdp` / `xfreerdp3` etc on PATH and probes
|
||||
the RDP TCP port. Returns available=True if RDP port answers, even
|
||||
if the actual RemoteApp call would fail — that's a per-call concern.
|
||||
- `exec()` is unchanged from current behaviour: 5-10s, brief PS flash.
|
||||
- `stream()` uses the existing `Write-WinpodxProgress` file-tail
|
||||
protocol (already in `windows_exec.py`).
|
||||
|
||||
### AgentTransport
|
||||
|
||||
- Wraps `AgentClient` (already on `feat/agent-v2`).
|
||||
- `health()` calls `AgentClient.health()` with a 2s timeout. Connection
|
||||
refused / timeout / 5xx → `available=False`. JSON parse error → also
|
||||
`available=False` with a clear `detail`.
|
||||
- `exec()` POSTs to `/exec` with bearer auth. Maps 401/403 to
|
||||
`TransportAuthError`.
|
||||
- `stream()` consumes `/exec`'s SSE variant (Phase 4 of agent-v2; not
|
||||
in Sprint 1).
|
||||
|
||||
## Versioning
|
||||
|
||||
This spec is v1. Breaking changes require a new spec doc + version
|
||||
bump. Both Track A and Track B implementations declare
|
||||
`SPEC_VERSION = 1` so a mismatch fails fast at import time.
|
||||
|
||||
## What changes WHERE in the codebase
|
||||
|
||||
### New (Sprint 1-2 of feat/redesign)
|
||||
|
||||
- `src/winpodx/core/transport/{__init__,base,freerdp,agent,dispatch}.py`
|
||||
- `tests/test_transport/{test_base,test_freerdp,test_agent,test_dispatch}.py`
|
||||
|
||||
### Modified callers (Sprint 2-3)
|
||||
|
||||
Today's `run_in_windows` callers move to `dispatch(cfg).exec(...)`:
|
||||
|
||||
- `core/migrate/` (new module — uses Transport from day one)
|
||||
- `core/discovery/` (after Step 3 extraction)
|
||||
- `cli/pod.py` `apply-fixes` handler
|
||||
|
||||
### Untouched (intentional)
|
||||
|
||||
- `core/rdp/launch.py` — RemoteApp launches for actual user apps stay
|
||||
on FreeRDP directly. Transport is for command channels, not for the
|
||||
user-facing app windows.
|
||||
|
||||
### Migrated 2026-05-07
|
||||
|
||||
- `core/rotation/` — now agent-first via `dispatch(cfg, prefer="agent")`
|
||||
with `run_in_windows` as explicit fallback. See rule #6 (superseded).
|
||||
@@ -0,0 +1,197 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
# USB passthrough investigation (#286)
|
||||
|
||||
Why a YubiKey passes through to the Windows guest but an external USB3
|
||||
drive does not, and what it would take to fix it.
|
||||
|
||||
Branch: `explore/usb-container-hotplug`. Builds on the `security_opt:
|
||||
label=disable` fix already on `main` (PR #411).
|
||||
|
||||
## TL;DR
|
||||
|
||||
Live USB hot-plug via **QEMU's own `usb-host`** is not achievable here:
|
||||
dockur runs QEMU inside a **rootless Podman container**, and QEMU's libusb
|
||||
device list is **frozen at container/QEMU start** — the container's separate
|
||||
network namespace (plus no `udevd` / `/run/udev`) means libusb's udev
|
||||
netlink hotplug monitor never receives kernel uevents. So `device_add
|
||||
usb-host` can only attach a device present **and at the same bus address**
|
||||
as at QEMU start. VirtualBox / virt-manager succeed because they are **host
|
||||
processes** (host netns, live libusb hotplug) — the containerization is the
|
||||
difference.
|
||||
|
||||
**But live IS achievable by bypassing the container:** redirect USB from a
|
||||
**host** process (`usbredirect`) into a QEMU `usb-redir` socket channel —
|
||||
the host process has live libusb hotplug, so attach/detach is truly live.
|
||||
Write access needs no persistent udev rule — run just `usbredirect` under
|
||||
`pkexec`/`sudo` at attach time (transient root, nothing persistent touched).
|
||||
See "Live fix that bypasses the container" below. Feasible at every layer
|
||||
tested; end-to-end (device-in-Windows) not yet smoke-verified.
|
||||
|
||||
## The layered walls (each independently necessary)
|
||||
|
||||
| # | Wall | Status |
|
||||
|---|------|--------|
|
||||
| 1 | **SELinux** — `container_t` can't open `usb_device_t` nodes | **Fixed** (#411, `security_opt: label=disable`, scoped to this container) |
|
||||
| 2 | **Node write permission** — QEMU usb-host needs `O_RDWR`; udev tags only some classes `uaccess` | Needs a per-device udev rule (sudo, once) |
|
||||
| 3 | **Frozen libusb list** — QEMU's device list never updates after start | The real blocker for "live"; requires recreate to refresh |
|
||||
|
||||
### Wall 2 — write permission
|
||||
|
||||
`udev` grants the active-seat user an `uaccess` ACL only to certain device
|
||||
classes (security keys, input, sound, cameras). USB **mass storage**,
|
||||
**Bluetooth**, etc. get **no** `uaccess` tag, so their `/dev/bus/usb/BBB/DDD`
|
||||
node stays `root:root rw-rw-r--`. The rootless container's user (mapped
|
||||
from container-root via the userns) is neither owner nor group, so it gets
|
||||
only `other::r--` = **read-only**. QEMU usb-host needs to open the node
|
||||
`O_RDWR` to claim interfaces, so it can't.
|
||||
|
||||
- YubiKey works because udev tags it `TAGS=...uaccess...` → `user:<you>:rw`.
|
||||
- Fix: a per-device udev rule, installed once with sudo, persistent across
|
||||
replug/reboot:
|
||||
```
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="2109", ATTR{idProduct}=="0715", MODE="0660", TAG+="uaccess"
|
||||
```
|
||||
(`setfacl -m u:<you>:rw <node>` works too but is wiped on every replug —
|
||||
and USB3 devices re-enumerate their address frequently.)
|
||||
|
||||
### Wall 3 — the frozen libusb device list (the deep one)
|
||||
|
||||
QEMU's `usb-host` backend uses libusb. libusb on Linux keeps its device
|
||||
list live via a udev/netlink hotplug monitor thread
|
||||
(`udev_monitor_new_from_netlink`). Inside the rootless container that
|
||||
monitor receives **no events** — the container is in a separate network
|
||||
namespace (kernel uevents are netns-scoped) and there is no `udevd` /
|
||||
`/run/udev`. So `libusb_get_device_list` returns the **boot-time
|
||||
snapshot**, forever.
|
||||
|
||||
**Evidence (independent of any timing/replug coordination):**
|
||||
|
||||
HMP probe against the running guest, `device_add usb-host,hostbus=2,hostaddr=N`:
|
||||
|
||||
| Device | Host state | QEMU response |
|
||||
|--------|-----------|---------------|
|
||||
| WD `1058:2626` @ addr 20 (stable since QEMU boot) | present | `failed to **open** 2:20` — **found**, perm-only |
|
||||
| SSD `2109:0715` @ addr 30 (re-enumerated after boot) | present (sysfs + fresh `lsusb` both confirm) | `failed to **find** 2:30` — **not in QEMU's list** |
|
||||
|
||||
Corroboration: 6+ long-lived libusb monitors (a `ctypes` poller of
|
||||
`libusb_get_device_list`) — in the default netns, in `--network=host`, and
|
||||
in `--network=host` **with `/run/udev` bind-mounted** — every one stayed
|
||||
**frozen within its lifetime**, while the SSD's address churned
|
||||
`30→33→34→36→39` *across* runs (proving replugs happened, just never
|
||||
visible to a single long-lived context). A **fresh** context (each new
|
||||
`lsusb` / monitor) always sees the current state. So it is specifically
|
||||
the long-lived (QEMU) context that goes stale.
|
||||
|
||||
Notes:
|
||||
- USB3 is not special per se — `qemu-xhci` has `p3=7` SuperSpeed ports, and
|
||||
the (stable) USB3 WD **is** in QEMU's list. The killer is post-boot
|
||||
address churn + the frozen list. USB3 just churns more (link re-training).
|
||||
- `--network=host` + `/run/udev` mount did **not** restore hotplug in
|
||||
testing. (It may be theoretically possible with a running udevd in the
|
||||
right netns, but it would break WinPodX's `127.0.0.1:port` mapping model
|
||||
and was not made to work.)
|
||||
|
||||
## Consequence
|
||||
|
||||
`device_add usb-host` for a device not in QEMU's frozen list produces an
|
||||
empty **1.5 Mb/s "USB Host Device"** stub — it never opens the real device.
|
||||
The YubiKey "works live" only because it was present + stable at QEMU start.
|
||||
|
||||
So the current `usb_live` live-attach design (monitor `device_add` at
|
||||
runtime) cannot work for the general case.
|
||||
|
||||
## Proposed fix — boot-time passthrough (recreate-to-attach)
|
||||
|
||||
Treat USB like PCI: pass assigned USB devices on QEMU's **start-up** args so
|
||||
the fresh boot enumeration grabs them.
|
||||
|
||||
- `compose._qemu_arguments_for_host` already has the builder
|
||||
(`devices.qemu_device_args` emits `-device usb-host,vendorid=0x..,productid=0x..`)
|
||||
but only calls it for PCI. Extend it to USB.
|
||||
- Match by **vendorid/productid** (not hostbus/hostaddr) so an absent device
|
||||
yields a pending device rather than aborting boot.
|
||||
- Target dockur's controller: `bus=xhci.0` — **verify ordering**: our
|
||||
`ARGUMENTS` must be appended after dockur emits `-device qemu-xhci,id=xhci`,
|
||||
or `bus=xhci.0` won't resolve.
|
||||
- Requires the node writable at boot → the Wall-2 udev rule.
|
||||
- UX change: USB attach becomes **recreate-on-attach** (like PCI), not live.
|
||||
Update `cli/device.py` + the GUI Devices tab messaging; revisit the
|
||||
`usb_live` flag semantics.
|
||||
|
||||
### Open questions to settle on real hardware (before merge)
|
||||
|
||||
1. Does `-device usb-host,vendorid=,productid=` abort QEMU boot when the
|
||||
device is absent, or create a pending device? (Determines whether we can
|
||||
always emit it or must gate on presence.)
|
||||
2. Does `bus=xhci.0` resolve from `ARGUMENTS`, or do we need our own
|
||||
controller?
|
||||
3. Does a boot-added device actually appear in Windows end-to-end (not just
|
||||
a stub)?
|
||||
4. Address-churning devices (some USB3 bridges/docks re-enumerate
|
||||
repeatedly) may still miss the boot enumeration window — acceptable?
|
||||
|
||||
## Live fix that bypasses the container — usbredir + transient root
|
||||
|
||||
The frozen-libusb wall only applies to QEMU **inside** the container. A
|
||||
**host** process has live libusb hotplug (that's why VirtualBox /
|
||||
virt-manager work). So redirect USB from the host:
|
||||
|
||||
```
|
||||
[host] usbredirect --device VID:PID <--socket--> [qemu] usb-redir chardev --> Windows (native driver)
|
||||
^ host process => live libusb hotplug => true live attach/detach
|
||||
```
|
||||
|
||||
- QEMU side: `-chardev socket,id=urN,... -device usb-redir,chardev=urN`.
|
||||
Confirmed: dockur's QEMU has the `usb-redir` device; the channel can even
|
||||
be added to a running guest via HMP `chardev-add` + `device_add usb-redir`
|
||||
(no recreate). USB-over-socket is the same mechanism SPICE uses.
|
||||
- Host side: `usbredirect` (openSUSE `usbredir` package) grabs the device
|
||||
with the **host's** libusb and pipes it to QEMU. Live: spawn = attach,
|
||||
kill = detach.
|
||||
- **Write permission, without a persistent system change** (the user does
|
||||
not want to touch udev rules): run **just `usbredirect`** under
|
||||
`pkexec`/`sudo` at attach time. Root opens the root-owned device node
|
||||
directly — no udev rule, no group, nothing persistent. The privileged
|
||||
process is one small short-lived USB forwarder, not WinPodX. (Contrast:
|
||||
VirtualBox ships a broad install-time udev rule + `vboxusers` group; we
|
||||
can avoid that entirely with transient root.)
|
||||
- Windows guest sees the **real device with its native driver** (URB-level
|
||||
redirection), not a translated/class share.
|
||||
|
||||
### Transport snag (the one open item)
|
||||
|
||||
The usbredir socket must be reachable host<->container. On this host the
|
||||
podman bridge firewall blocks **both** directions for arbitrary ports
|
||||
(host->10.89.0.2:port refused; container->10.89.0.1:port refused). So the
|
||||
socket has to ride WinPodX's existing **compose `ports:` mapping**
|
||||
(`127.0.0.1:<port>:<port>`, same as RDP/VNC/agent) — which means a
|
||||
**one-time recreate** to add the `usb-redir` channel + port-map. After
|
||||
that, attach/detach is fully live via `usbredirect`. (The HMP live-add of
|
||||
the channel works but is moot until the socket is reachable.)
|
||||
|
||||
### Status
|
||||
|
||||
Feasible at every layer tested (QEMU `usb-redir` ✓, HMP channel-add ✓, host
|
||||
`usbredirect` ✓, `pkexec` ✓, transient-root model ✓). **End-to-end not yet
|
||||
verified** — device-appears-in-Windows needs the port-mapped socket wired
|
||||
(one recreate) + a real smoke. No blind compose change before that smoke.
|
||||
|
||||
### Proposed WinPodX integration
|
||||
|
||||
1. compose: add a `usb-redir` chardev (socket server) + `127.0.0.1:<port>:<port>`
|
||||
map per concurrent USB slot (e.g. 4 slots). One-time recreate when the
|
||||
feature is enabled.
|
||||
2. `device attach <usb>`: spawn `pkexec usbredirect --device <vid:pid> --to
|
||||
tcp:127.0.0.1:<port>` (transient root, no persistent change). `detach`:
|
||||
kill that process. Replaces the broken qemu-libusb `live_attach`.
|
||||
3. GUI Devices tab drives the same; surface the pkexec prompt / failures.
|
||||
4. Security keys etc. (uaccess) can still use the existing path without root.
|
||||
|
||||
## What works today (shipped on main)
|
||||
|
||||
- `uaccess` USB2 devices (security keys) attach **live**.
|
||||
- The `security_opt: label=disable` SELinux lift (#411).
|
||||
- For files on an external drive, FreeRDP drive redirection
|
||||
(`\\tsclient\media`, already wired) is the right tool and works for any
|
||||
filesystem — raw passthrough of a LUKS/ext4 disk is useless to Windows
|
||||
anyway.
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://github.com/kernalix7/winpodx/blob/main/docs/design/install_failure.schema.json",
|
||||
"title": "WinPodX agent-first install failure record",
|
||||
"description": "Pinned JSON Schema for install_failure.json, the sanitized failure record written by the in-guest install agent after retry exhaustion. The host reads this file on next 'winpodx app run' / 'pod install-status' / 'pod install-resume'. See docs/design/AGENT_FIRST_INSTALL_DESIGN.md (§Schemas → install_failure.json) for the full design context, including the redactor pipeline that runs before any payload reaches this schema.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"session_id",
|
||||
"failed_step",
|
||||
"phase",
|
||||
"attempt",
|
||||
"max_attempts",
|
||||
"exit_code",
|
||||
"error_class",
|
||||
"error_summary",
|
||||
"timestamp_utc",
|
||||
"environment",
|
||||
"last_log_lines"
|
||||
],
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
|
||||
"description": "UUID identifying the install session that produced this failure record."
|
||||
},
|
||||
"failed_step": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z_]+$",
|
||||
"minLength": 1,
|
||||
"maxLength": 80,
|
||||
"description": "Slug identifying the step that exhausted its retry budget (e.g. 'multi_session_activate'). Restricted to lowercase ASCII and underscore so the value is safe to interpolate into log lines and CLI output without escaping."
|
||||
},
|
||||
"phase": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9,
|
||||
"description": "Install phase number (0..9). Range leaves room for future phases beyond the four documented in the design doc."
|
||||
},
|
||||
"attempt": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "1-indexed attempt count when the failure was recorded. Always equal to max_attempts in a retry-exhausted record."
|
||||
},
|
||||
"max_attempts": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Configured retry budget for failed_step."
|
||||
},
|
||||
"exit_code": {
|
||||
"type": "integer",
|
||||
"description": "Process exit code from the failed step. Any integer is allowed, including negative values that some platforms emit for signal-terminated processes."
|
||||
},
|
||||
"error_class": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z_]+$",
|
||||
"minLength": 1,
|
||||
"maxLength": 80,
|
||||
"description": "Stable, machine-readable error category (e.g. 'rdprrap_activate_failed'). Restricted to lowercase ASCII and underscore so host-side switch/case logic can match safely."
|
||||
},
|
||||
"error_summary": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"description": "Short human-readable summary, post-redactor. The redactor (see design doc §Sanitization rules) runs before this value is written; the schema's only job is to bound the length."
|
||||
},
|
||||
"timestamp_utc": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "RFC 3339 / ISO 8601 timestamp in UTC (ending in 'Z') for when the failure was recorded."
|
||||
},
|
||||
"environment": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"windows_build",
|
||||
"disk_fs",
|
||||
"free_bytes",
|
||||
"ram_total_mb"
|
||||
],
|
||||
"properties": {
|
||||
"windows_build": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Windows build string from the guest (e.g. '10.0.26100.0')."
|
||||
},
|
||||
"disk_fs": {
|
||||
"type": "string",
|
||||
"maxLength": 32,
|
||||
"description": "Host filesystem hosting the pod's storage (e.g. 'ntfs', 'ext4', 'btrfs')."
|
||||
},
|
||||
"free_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Free bytes on the guest's system drive at the moment of failure."
|
||||
},
|
||||
"ram_total_mb": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Total RAM available to the guest, in MiB."
|
||||
}
|
||||
}
|
||||
},
|
||||
"last_log_lines": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
},
|
||||
"description": "Tail of the install.log JSON-per-line stream, post-redactor. Bounded at 50 lines / 1000 chars per line to keep the failure record cheap to ship and parse."
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="210mm"
|
||||
height="297mm"
|
||||
viewBox="0 0 210 297"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.4 (unknown)"
|
||||
sodipodi:docname="CI_ICON.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="0.82047559"
|
||||
inkscape:cx="397.33053"
|
||||
inkscape:cy="200.49347"
|
||||
inkscape:window-width="1969"
|
||||
inkscape:window-height="1159"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g1" /><defs
|
||||
id="defs1" /><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"><g
|
||||
id="g3"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,-65.529516,34.333496)"><g
|
||||
id="g1"
|
||||
style="fill:#2596be;fill-opacity:1"
|
||||
transform="translate(-718.32936,-313.76438)"><path
|
||||
style="fill:#2596be;fill-opacity:1"
|
||||
d="m 1326.735,973.6718 c -8.7902,-0.6541 -33.5095,-4.3462 -42.8878,-6.4058 -23.9215,-5.2534 -49.3875,-15.0786 -76.0377,-29.3365 l -14.4254,-7.7176 -12.5746,-8.6049 c -16.2761,-11.1378 -19.9778,-14.0745 -35.4417,-28.1166 l -12.8672,-11.6842 -7.7269,-9 c -30.911,-36.0037 -52.1953,-75.06431 -65.9334,-121 l -3.8879,-13 -1.7416,-12.98373 -1.7416,-12.98373 0.5791,-13.01627 0.579,-13.01627 2.0653,-9.5 c 2.7898,-12.83301 5.428,-20.62887 10.9982,-32.5 l 4.6923,-10 6.426,-8.98263 c 21.9823,-30.72841 54.4706,-52.46565 95.7084,-64.03639 l 6.272,-1.75982 0.7227,0.72207 0.7228,0.72208 v 25.65897 25.65898 l -10,3.5657 c -12.6962,4.52708 -16.2862,6.42189 -26.0603,13.75472 -9.7818,7.33858 -18.8002,16.58354 -24.7147,25.33545 l -4.4866,6.63913 -4.0152,10.36087 c -4.5564,11.7576 -5.8848,17.32261 -6.8063,28.51288 l -0.6463,7.84799 1.1911,8 c 6.4383,43.2416 33.2205,66.27231 90.3605,77.70338 l 11.1778,2.23617 19.5,-0.024 19.5,-0.024 9,-1.69196 c 26.9808,-5.07224 52.5422,-14.82578 76.7316,-29.27873 6.1976,-3.70299 20.5453,-12.91324 31.8838,-20.46723 57.3171,-38.18611 79.1683,-47.86613 146.8846,-65.06956 44.9665,-11.42381 68.8656,-20.48231 92,-34.87076 8.5238,-5.30142 13.0454,-8.98563 21.2429,-17.30878 l 5.7428,-5.83094 4.001,-6.73468 4.001,-6.73469 1.6323,-5.45216 c 0.8978,-2.99869 1.966,-8.15216 2.3737,-11.45216 l 0.7413,-6 -0.8719,-6.5 -0.8718,-6.5 -3.5397,-7.22854 -3.5396,-7.22853 -4.7282,-4.85426 -4.7282,-4.85426 -7.4778,-3.65217 c -8.9762,-4.38402 -16.8945,-6.53879 -26.9778,-7.34138 l -7.5,-0.59697 -9,1.1747 c -4.95,0.64609 -12.7799,2.06129 -17.3998,3.14489 -4.6199,1.0836 -8.6037,1.76632 -8.8529,1.51715 -0.5865,-0.58647 -0.4467,-51.71456 0.143,-52.30424 0.2458,-0.24579 5.771,-1.3532 12.2783,-2.46091 l 11.8314,-2.01403 17,0.52308 17,0.52308 8.2461,2.06014 c 4.5354,1.13308 12.3565,3.64756 17.3802,5.58773 l 9.1341,3.52759 8.1198,4.94453 8.1198,4.94454 7.6639,7.0463 7.6639,7.0463 4.4513,6.24763 c 5.5868,7.84139 10.006,16.56739 13.1443,25.9543 l 2.4385,7.29333 1.6416,11 1.6416,11 -0.6325,6.5 c -0.3478,3.575 -1.2967,10.45098 -2.1085,15.27996 l -1.476,8.77997 -3.3793,9.72003 c -1.8585,5.34602 -7.0658,19.62004 -11.5716,31.72004 -4.5058,12.1 -11.2577,30.55 -15.0042,41 -6.1488,17.15055 -10.1126,27.79579 -25.5062,68.5 -10.4093,27.52435 -19.8166,47.2065 -31.3066,65.5 -26.2499,41.7933 -50.5189,66.9429 -89.2174,92.4545 l -14.5572,9.5967 -14.0787,7.221 -14.0787,7.221 -15.3641,6.1165 c -24.2126,9.639 -39.6944,13.7699 -67.3641,17.9746 l -13,1.9754 -21.5,0.3576 c -11.825,0.1966 -23.975,0.1734 -27,-0.052 z m 11.8845,-65.916 c 20.7134,-2.8693 34.2071,-6.7917 54.1155,-15.7305 28.9991,-13.0206 51.1713,-28.7481 74.507,-52.8505 l 11.007,-11.3686 12.3655,-16.5 c 6.8011,-9.075 12.8179,-16.95 13.3708,-17.5 l 1.0051,-1 -0.6177,5.59012 -0.6176,5.59008 -3.4854,11.4972 c -4.3981,14.5081 -8.3402,23.801 -15.1962,35.8226 -9.8512,17.2735 -23.9149,34.619 -38.3011,47.239 -3.5544,3.1181 -6.2124,5.9193 -5.9067,6.225 1.4515,1.4515 36.0286,-17.361 46.8693,-25.5004 1.925,-1.4453 6.9467,-5.2095 11.1593,-8.3648 23.4889,-17.5939 48.0348,-46.2367 65.214,-76.0988 7.7672,-13.50163 10.646,-19.55839 17.8235,-37.5 4.4267,-11.06533 18.0752,-47.63925 19.4941,-52.23836 l 0.5363,-1.73836 -8.3636,3.90566 c -4.6,2.14811 -15.8258,6.39132 -24.9463,9.42936 l -16.5827,5.52371 -20.9173,5.12554 c -25.9959,6.36999 -34.9917,8.89808 -53.7531,15.10617 l -14.8358,4.90912 -13.0905,5.98489 -13.0905,5.98488 -13.5737,8.15187 c -7.4655,4.48352 -18.9737,11.84143 -25.5737,16.35089 -35.6667,24.36943 -48.871,32.22863 -71.5896,42.61033 l -14.7207,6.7269 -12.3449,3.9951 c -13.114,4.244 -25.457,7.1374 -41.4959,9.7274 l -9.8489,1.5905 -18,-0.031 -18,-0.031 -11.5069,-1.5246 c -6.3288,-0.8384 -16.4538,-2.6168 -22.5,-3.9519 -6.0462,-1.335 -11.0901,-2.3334 -11.2087,-2.2186 -0.1186,0.1148 3.1813,3.9004 7.3332,8.4126 14.48,15.7363 36.7785,32.5077 55.8824,42.0309 l 10,4.985 12,4.0267 12,4.0267 10.5,2.1068 c 5.775,1.1587 15,2.5033 20.5,2.9878 l 10,0.881 12,-0.4793 c 6.6,-0.2636 16.673,-1.1267 22.3845,-1.9178 z"
|
||||
id="path1"
|
||||
sodipodi:nodetypes="csscsscsscccccsscsscccccssscsscsscccsssssscccsscccccccsscssssscccsscccccsscccssccssssscccsscscccsscsscccssssscssscsscsscccssscssccccsscscccsscscc" /><path
|
||||
style="fill:#58a6ff;fill-opacity:1"
|
||||
d="m 1294.6201,750.97754 c -4.789,-1.77888 -8.6454,-4.60281 -13.5454,-9.91869 -5.3953,-5.8532 -8.6212,-11.6313 -11.8652,-21.25265 -3.3064,-9.80633 -7.1785,-16.7826 -12.121,-21.83781 l -3.7522,-3.83782 -6.5506,-3.29293 c -3.6029,-1.81111 -8.7016,-4.0208 -11.3305,-4.91043 -2.6289,-0.88963 -7.0912,-2.78881 -9.9164,-4.22041 l -5.1365,-2.6029 -5.1451,-5.14885 -5.1451,-5.14885 -2.5994,-5.5 -2.5994,-5.5 -1.1817,-6.5 -1.1817,-6.5 0.6179,-5 c 0.3397,-2.75 1.6189,-7.90586 2.8425,-11.45747 l 2.2247,-6.45747 5.3553,-5.78997 5.3554,-5.78996 7.1446,-3.48304 7.1447,-3.48303 6.5,-0.91653 6.5,-0.91653 44.5,0.147 44.5,0.147 4.7267,2.32791 4.7266,2.32791 2.678,3.17209 c 1.4729,1.74465 3.4985,4.97209 4.5014,7.17209 l 1.8234,4 -0.04,43 -0.04,43 -0.741,7.40368 -0.741,7.40368 -3.3724,2.81541 c -3.9135,3.26718 -22.8537,15.45109 -34.6829,22.31103 l -8.1609,4.73262 -3.8391,0.42385 -3.8391,0.42386 z m 80.7222,-46.92134 c 0.7878,-4.59384 6.0463,-15.13949 15.1004,-30.28309 4.2858,-7.1682 13.6423,-23.07716 20.7923,-35.35324 l 13,-22.32014 4.2721,-4.81824 4.272,-4.81825 4.2937,-2.07852 4.2936,-2.07852 h 5.005 5.005 l 5.429,2.75 5.429,2.75 3.5003,4.68952 c 3.4376,4.60547 28.0003,45.05695 28.0003,46.11264 0,0.29641 -1.4625,0.8537 -3.25,1.23843 -19.8175,4.26532 -57.8565,17.51233 -75.25,26.20564 -7.8497,3.9233 -32.9332,17.794 -35.9725,19.8921 -1.0849,0.74892 -2.5361,1.36167 -3.225,1.36167 h -1.2525 z M 1220.4941,580.8997 c -5.6241,-2.03197 -8.4763,-4.20899 -11.4996,-8.77759 l -2.7368,-4.13551 -0.3087,-7.0902 -0.3086,-7.0902 3.9054,-13.5 c 2.148,-7.425 7.3746,-25.2 11.6147,-39.5 l 7.7093,-26 2.9632,-4.63518 2.9631,-4.63518 3.8879,-2.6943 3.8879,-2.69429 6.0624,-1.88684 c 3.3344,-1.03777 11.8086,-3.21445 18.8316,-4.83706 7.023,-1.62261 23.3101,-5.91277 36.1935,-9.53368 l 23.4244,-6.58347 h 4.3902 4.3902 l 4.8632,2.09209 4.8632,2.0921 2.5259,3.6579 c 1.3893,2.01185 3.2218,5.33096 4.0722,7.37579 l 1.5463,3.71788 v 53.46702 53.46703 l -1.0559,2.8151 c -0.5807,1.54831 -2.2366,4.53105 -3.6798,6.62833 l -2.6239,3.81322 -4.5702,2.68678 -4.5702,2.68677 -56.5,0.22254 -56.5,0.22254 z m 212.2409,-0.0209 c -17.0084,-3.78034 -28.3363,-10.49377 -40.969,-24.2801 l -6.2066,-6.77341 -3.8464,-8.00956 c -2.1155,-4.40525 -4.6469,-10.93455 -5.6253,-14.50955 l -1.7789,-6.5 0.031,-11.5 0.031,-11.5 1.7708,-6.5 c 2.4195,-8.88159 7.4585,-19.40008 12.4555,-26 l 4.1642,-5.5 6.7366,-5.73907 6.7366,-5.73907 6.5,-3.41907 c 3.575,-1.88048 9.875,-4.46816 14,-5.75039 l 7.5,-2.33132 13,0.03 13,0.03 8,2.76808 8,2.76808 6.9782,4.19135 6.9782,4.19135 6.2728,5.8656 6.2728,5.86561 4.6375,7.44048 4.6374,7.44048 2.9493,8.55655 2.9494,8.55654 0.5293,11.13737 0.5293,11.13737 -2.1572,8.99762 c -5.6367,23.51114 -18.8283,39.45683 -41.3859,50.02635 l -7.1911,3.36943 -7.7078,1.5533 -7.7079,1.5533 -7.2921,-0.10247 -7.2922,-0.10247 z"
|
||||
id="path1-3" /></g><g
|
||||
id="g2"
|
||||
style="fill:#2596be;fill-opacity:1"
|
||||
transform="translate(-111.66527,32.56245)" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
description = "WinPodX — Windows app integration for Linux desktop";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
|
||||
# Single source of truth for the package version. Reading from
|
||||
# pyproject.toml at evaluation time keeps the Nix store path
|
||||
# (winpodx-<version>) in sync with the Python distribution version
|
||||
# automatically — no need to bump two files at release time.
|
||||
pyprojectVersion = (builtins.fromTOML (builtins.readFile ./pyproject.toml)).project.version;
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
pkgs:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
python = pkgs.python3;
|
||||
|
||||
# Runtime CLI tools winpodx shells out to. Podman is the default
|
||||
# backend so it ships in the wrapper PATH; docker/libvirt stay
|
||||
# opt-in via the host to keep the closure bounded.
|
||||
runtimeBins = [
|
||||
pkgs.freerdp
|
||||
pkgs.iproute2
|
||||
pkgs.libnotify
|
||||
pkgs.podman
|
||||
pkgs.podman-compose
|
||||
];
|
||||
in
|
||||
{
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.winpodx;
|
||||
|
||||
winpodx = python.pkgs.buildPythonApplication {
|
||||
pname = "winpodx";
|
||||
version = pyprojectVersion;
|
||||
pyproject = true;
|
||||
|
||||
src = lib.cleanSource ./.;
|
||||
|
||||
build-system = [ python.pkgs.hatchling ];
|
||||
|
||||
dependencies = with python.pkgs; [
|
||||
pyside6
|
||||
docker
|
||||
libvirt
|
||||
];
|
||||
|
||||
nativeCheckInputs = with python.pkgs; [
|
||||
pytestCheckHook
|
||||
];
|
||||
|
||||
preCheck = ''
|
||||
export WINPODX_BUNDLE_DIR=$PWD
|
||||
'';
|
||||
|
||||
# scripts/, config/ and data/ live at the repo root rather than
|
||||
# inside the Python package; ship them under share/ and point the
|
||||
# runtime at it so bundle_dir() resolves correctly for the wheel.
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/winpodx
|
||||
cp -r scripts config data $out/share/winpodx/
|
||||
'';
|
||||
|
||||
makeWrapperArgs = [
|
||||
"--prefix"
|
||||
"PATH"
|
||||
":"
|
||||
(lib.makeBinPath runtimeBins)
|
||||
"--set"
|
||||
"WINPODX_BUNDLE_DIR"
|
||||
"${placeholder "out"}/share/winpodx"
|
||||
];
|
||||
|
||||
pythonImportsCheck = [ "winpodx" ];
|
||||
|
||||
meta = {
|
||||
description = "Windows app integration for the Linux desktop (FreeRDP RemoteApp + dockur/windows)";
|
||||
homepage = "https://github.com/kernalix7/winpodx";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "winpodx";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (pkgs: {
|
||||
winpodx = self.packages.${pkgs.stdenv.hostPlatform.system}.winpodx;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.winpodx ];
|
||||
# Runtime tools the wrapper would normally inject; expose them in
|
||||
# the dev shell so `python -m winpodx` works against the source tree.
|
||||
packages = [
|
||||
pkgs.freerdp
|
||||
pkgs.iproute2
|
||||
pkgs.libnotify
|
||||
pkgs.podman
|
||||
pkgs.podman-compose
|
||||
pkgs.ruff
|
||||
pkgs.mypy
|
||||
];
|
||||
shellHook = ''
|
||||
export PYTHONPATH="$PWD/src''${PYTHONPATH:+:$PYTHONPATH}"
|
||||
'';
|
||||
};
|
||||
});
|
||||
|
||||
formatter = forAllSystems (pkgs: pkgs.nixfmt);
|
||||
};
|
||||
}
|
||||
Executable
+1752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
# WinPodX AppImage build
|
||||
|
||||
Builds a distro-agnostic **Thin** AppImage of WinPodX (0.6.0 item A).
|
||||
|
||||
There are **two build paths**, both producing a Thin AppImage:
|
||||
|
||||
| | What is bundled | How |
|
||||
|---|---|---|
|
||||
| **CI (shipped release artifact)** | Python + winpodx + Qt + **FreeRDP 3** | `.github/workflows/appimage-publish.yml` |
|
||||
| **`build.sh` (local dev)** | Python + winpodx + Qt | python-appimage, host-resolved FreeRDP |
|
||||
|
||||
The release asset attached to a tag is the CI build (the only difference vs
|
||||
`build.sh` is the bundled FreeRDP overlay). Neither build bundles the
|
||||
container runtime.
|
||||
|
||||
## Prerequisites the user provides on the host
|
||||
|
||||
Same model as `install.sh`:
|
||||
|
||||
- **`podman` (recommended)** or `docker` — installed via the
|
||||
host distro package manager. Rootless podman fundamentally needs host
|
||||
systemd / subuid integration that an AppImage can't carry, so WinPodX
|
||||
cannot ship one that works.
|
||||
- KVM kernel module + `/dev/kvm` access + `kvm` group membership.
|
||||
- `/etc/subuid` + `/etc/subgid` for rootless Podman.
|
||||
|
||||
`winpodx setup-host` runs a one-shot `pkexec` wizard for the kvm-group /
|
||||
subuid / kvm-module bits; `winpodx setup` / `winpodx doctor` surface
|
||||
anything else.
|
||||
|
||||
The dockur/windows container image (~500MB–1GB) is pulled at first pod
|
||||
start via the host podman/docker.
|
||||
|
||||
## Why Thin (was Fat before 0.6.0)
|
||||
|
||||
The pre-0.6.0 Fat AppImage bundled the entire podman stack
|
||||
(podman / podman-compose / conmon / crun / netavark / aardvark-dns / pasta /
|
||||
slirp4netns) into `${APPDIR}/usr/bin` and prepended that directory to PATH
|
||||
+ `${APPDIR}/usr/lib` to LD_LIBRARY_PATH. That broke every host that
|
||||
already had a working podman:
|
||||
|
||||
- **#357 (Ubuntu 26.04)** — bundled `podman-compose` resolved first,
|
||||
probed for a podman it couldn't drive standalone, died with
|
||||
`it seems that you do not have podman installed`.
|
||||
- **#363 (Fedora Bluefin)** — host `systemd-run` rootless aardvark-dns
|
||||
spawn loaded the bundled `libcrypto.so.3` from the inherited
|
||||
`LD_LIBRARY_PATH`, died with `OPENSSL_3.4.0 not found`.
|
||||
|
||||
PR #365 patched around it with a host-first `_hostenv` helper. 0.6.0
|
||||
item A removes the root cause: drop the entire container stack, require
|
||||
host podman/docker (same model as `install.sh`) and stops fighting
|
||||
the host. That alone only reached ~274 MB (from ~296 MB fat), so a
|
||||
companion Qt6 slim (`slim-pyside6.sh`) strips the unused Qt6 modules
|
||||
PySide6 bundles — winpodx links only QtCore/QtGui/QtWidgets/QtSvg/
|
||||
QtDBus — bringing the AppImage to ~110 MB. `_hostenv`
|
||||
collapses to an `LD_LIBRARY_PATH` strip (still needed: bundled FreeRDP /
|
||||
Python / Qt keep the AppImage's `LD_LIBRARY_PATH`, and host helpers
|
||||
spawned by the host runtime must not inherit bundled libcrypto / libssl).
|
||||
|
||||
## CI release artifact (Thin)
|
||||
|
||||
`.github/workflows/appimage-publish.yml` runs on every `v*.*.*` tag
|
||||
push, builds the Thin AppImage, and uploads it as a release asset
|
||||
alongside the `.deb` / `.rpm` / wheel artefacts.
|
||||
|
||||
**Bundled:**
|
||||
|
||||
- Python 3.11 runtime (astral-sh python-build-standalone, pinned tag +
|
||||
SHA256-verified against its `.sha256` sidecar)
|
||||
- `winpodx` wheel + `gui` (PySide6/Qt6) + `reverse-open`
|
||||
(Pillow / cairosvg / pyxdg) extras
|
||||
- FreeRDP 3 client: `xfreerdp3`, `wlfreerdp3`, `sdl-freerdp3` (from
|
||||
Fedora 41) — leaf binary, doesn't spawn host helpers
|
||||
- Transitive `.so` deps for the above (via `ldd`), minus the
|
||||
host-critical exclude list (glibc / libX11 / libGL / libwayland /
|
||||
libxkbcommon stay on the host)
|
||||
|
||||
**NOT bundled** (Thin acknowledges these have to come from the host):
|
||||
|
||||
- Container runtime: `podman` / `podman-compose` / `conmon` / `crun` /
|
||||
`netavark` / `aardvark-dns` / `pasta` / `passt` / `slirp4netns`
|
||||
- KVM kernel module + `/dev/kvm` access + `kvm` group membership
|
||||
- `/etc/subuid` + `/etc/subgid` for rootless Podman
|
||||
- dockur/windows container image (pulled at first pod start)
|
||||
|
||||
## Local lean build (`build.sh`)
|
||||
|
||||
```bash
|
||||
./packaging/appimage/build.sh
|
||||
# -> packaging/appimage/winpodx-<version>-x86_64.AppImage (lean: host FreeRDP too)
|
||||
```
|
||||
|
||||
Prerequisites: Python 3.11+, `pip install python-appimage build`,
|
||||
internet (pulls PySide6 + extras from PyPI). This path does **not**
|
||||
bundle FreeRDP either — it relies on the host's FreeRDP / podman,
|
||||
exactly like the wheel / `.deb` / `.rpm`.
|
||||
|
||||
## Licensing
|
||||
|
||||
WinPodX itself is **MIT** and stays MIT. The Thin AppImage redistributes
|
||||
only the FreeRDP 3 client stack from Fedora 41, so its license + NOTICE
|
||||
texts travel inside it:
|
||||
|
||||
- WinPodX `LICENSE` (MIT) + `THIRD_PARTY_LICENSES.md` at
|
||||
`${APPDIR}/usr/share/doc/winpodx/`
|
||||
- bundled FreeRDP package licenses (Apache-2.0: `freerdp-libs`,
|
||||
`libwinpr`) under `${APPDIR}/usr/share/doc/winpodx/third-party/<pkg>/`
|
||||
- the python-build-standalone (PSF) license
|
||||
- PySide6 / Qt6 (LGPL-3.0), cairosvg (LGPL-3.0), pyxdg (LGPL-2.0),
|
||||
Pillow (HPND) carry their license in their `*.dist-info` inside
|
||||
`${APPDIR}/opt/python`. The AppImage SquashFS is `--appimage-extract`-able,
|
||||
satisfying LGPL relinking.
|
||||
|
||||
The pre-Thin podman-stack license dirs (`podman/`, `podman-compose/`,
|
||||
`conmon/`, `crun/`, `netavark/`, `passt/`, `slirp4netns/`) stay vendored
|
||||
in-repo at `packaging/appimage/licenses/` for provenance + to make a
|
||||
future re-bundling cheap, but they no longer ship inside the AppImage
|
||||
because the binaries they cover are no longer bundled.
|
||||
|
||||
See the repo-root `THIRD_PARTY_LICENSES.md` for the full breakdown,
|
||||
including the bundled rdprrap (MIT + vendored Apache-2.0 rdpwrap) and
|
||||
rcedit (MIT) that ship in every channel via the wheel's OEM payload.
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local AppImage build helper.
|
||||
#
|
||||
# Builds an x86_64 AppImage of winpodx using python-appimage. The
|
||||
# resulting `winpodx-<version>-x86_64.AppImage` lands in this
|
||||
# directory. CI uses the same flow via .github/workflows/appimage-
|
||||
# publish.yml.
|
||||
#
|
||||
# Prerequisites (host):
|
||||
# - Python 3.11+ with pip
|
||||
# - The python-appimage package on PATH (`pip install python-appimage`)
|
||||
# - `build` (PyPA build) for generating the local wheel
|
||||
# - Internet access (PySide6 + Pillow + cairosvg wheels pulled from PyPI)
|
||||
#
|
||||
# Output:
|
||||
# - winpodx-<version>-x86_64.AppImage in packaging/appimage/
|
||||
#
|
||||
# The recipe directory describes a "lean" Thin AppImage: Python runtime +
|
||||
# winpodx wheel + PySide6 (Qt6) + reverse-open extras. System binaries
|
||||
# (FreeRDP, podman/docker) are NOT bundled; winpodx's existing
|
||||
# `winpodx setup` / `winpodx doctor` paths detect them and emit per-
|
||||
# distro install hints if any are missing.
|
||||
#
|
||||
# The CI Thin AppImage additionally bundles the FreeRDP 3 client stack
|
||||
# (xfreerdp3 / wlfreerdp3 / sdl-freerdp3 + libwinpr from Fedora 41) via
|
||||
# bundle-system-bins.sh -- see packaging/appimage/README.md for the
|
||||
# split between this local-lean build and the CI release artefact.
|
||||
# Neither build bundles a container runtime (0.6.0 item A, #357 / #363).
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "${HERE}/../.." && pwd)"
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
echo "[appimage] Building winpodx wheel + sdist into ./dist/ ..."
|
||||
python3 -m pip install --upgrade build python-appimage >/dev/null
|
||||
python3 -m build --outdir dist
|
||||
|
||||
# Find the wheel we just built and rewrite the recipe's requirements.txt
|
||||
# to point at it. This keeps the AppImage in sync with the current
|
||||
# branch's code instead of pulling whatever's on PyPI.
|
||||
WHEEL="$(ls -t dist/winpodx-*.whl | head -n1)"
|
||||
if [ -z "${WHEEL}" ]; then
|
||||
echo "[appimage] No wheel produced under dist/; aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[appimage] Using wheel: ${WHEEL}"
|
||||
|
||||
WHEEL_ABS="$(readlink -f "${WHEEL}")"
|
||||
REQ="${HERE}/recipe/requirements.txt"
|
||||
# Pin to absolute path of the freshly-built wheel + keep the extras so
|
||||
# pip pulls PySide6 + Pillow + cairosvg into the AppImage.
|
||||
printf '%s[gui,reverse-open]\n' "${WHEEL_ABS}" > "${REQ}"
|
||||
|
||||
cd "${HERE}"
|
||||
echo "[appimage] Running python-appimage build ..."
|
||||
python3 -m python_appimage build app -p 3.11 recipe/
|
||||
|
||||
echo "[appimage] Resulting AppImage(s):"
|
||||
ls -lh winpodx-*-x86_64.AppImage 2>/dev/null || {
|
||||
echo "[appimage] No AppImage produced. Check python-appimage output above." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Slim the bundled Qt6 to match the CI release artefact: PySide6 wheels
|
||||
# ship the whole Qt6 stack (QtWebEngine etc.), but winpodx links only
|
||||
# QtCore/QtGui/QtWidgets/QtSvg/QtDBus. Extract -> prune -> repack. Best-
|
||||
# effort locally: if appimagetool isn't on PATH, the slimmed AppDir is left
|
||||
# in squashfs-root/ and CI still slims the release build.
|
||||
APP="$(ls -t winpodx-*-x86_64.AppImage | head -n1)"
|
||||
echo "[appimage] Slimming bundled Qt6 in ${APP} ..."
|
||||
rm -rf squashfs-root
|
||||
"./${APP}" --appimage-extract >/dev/null
|
||||
PYSIDE_DIR="$(find squashfs-root -type d -name PySide6 -path '*site-packages*' | head -n1)"
|
||||
if [ -n "${PYSIDE_DIR}" ]; then
|
||||
bash "${HERE}/slim-pyside6.sh" "${PYSIDE_DIR}"
|
||||
if command -v appimagetool >/dev/null 2>&1; then
|
||||
ARCH=x86_64 appimagetool squashfs-root "${APP}" >/dev/null 2>&1
|
||||
chmod +x "${APP}"
|
||||
rm -rf squashfs-root
|
||||
echo "[appimage] Re-packed slim ${APP}: $(du -h "${APP}" | cut -f1)"
|
||||
else
|
||||
echo "[appimage] appimagetool not on PATH -- slimmed AppDir left in squashfs-root/; install appimagetool to repack locally (CI repacks the release build)." >&2
|
||||
fi
|
||||
else
|
||||
echo "[appimage] PySide6 dir not found in extracted AppImage -- skipping slim." >&2
|
||||
rm -rf squashfs-root
|
||||
fi
|
||||
|
||||
# Restore the recipe to its committed shape so the next git status is clean.
|
||||
printf 'winpodx[gui,reverse-open]\n' > "${REQ}"
|
||||
echo "[appimage] Done."
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stage system-level FreeRDP 3+ binaries into an existing python-
|
||||
# appimage AppDir so the resulting AppImage runs on hosts that do not
|
||||
# have a FreeRDP 3 client installed.
|
||||
#
|
||||
# Thin AppImage (0.6.0 item A): the container stack (podman /
|
||||
# podman-compose / conmon / crun / netavark / aardvark-dns / pasta /
|
||||
# passt / slirp4netns / fuse-overlayfs) is INTENTIONALLY NOT bundled.
|
||||
# Rootless podman fundamentally needs host systemd / subuid integration
|
||||
# that an AppImage cannot carry, and bundling it caused #357 (Ubuntu
|
||||
# 26.04 -- bundled podman-compose shadowed the host's working stack)
|
||||
# and #363 (Fedora Bluefin -- LD_LIBRARY_PATH prepend poisoned host
|
||||
# systemd-run / aardvark-dns with bundled libcrypto). The Thin AppImage
|
||||
# requires the user to install podman / docker / libvirt from their
|
||||
# distro package manager (same model as install.sh). FreeRDP stays
|
||||
# bundled because it is a leaf binary that does not spawn host helpers.
|
||||
#
|
||||
# Caveats (FreeRDP-only):
|
||||
#
|
||||
# - libX11 / libXrandr / libxkbcommon / libGL / libwayland-client stay
|
||||
# on the host so xfreerdp3 integrates with the user's actual X /
|
||||
# Wayland session (RAIL would break otherwise).
|
||||
# - glibc + libdl / libpthread / libc / libm / libresolv / libnsl /
|
||||
# libcrypt + ld-linux likewise stay on the host. Bundling glibc into
|
||||
# an AppImage is a well-known footgun; the runner-side Fedora glibc
|
||||
# would conflict with the user-side glibc on every reasonable distro.
|
||||
#
|
||||
# Usage:
|
||||
# bundle-system-bins.sh <AppDir>
|
||||
#
|
||||
# Must be invoked from a Fedora 41+ environment that already has the
|
||||
# packages below installed via dnf. See appimage-publish.yml workflow
|
||||
# for the CI variant.
|
||||
set -euo pipefail
|
||||
|
||||
APPDIR="${1:?usage: $0 <AppDir>}"
|
||||
if [ ! -d "$APPDIR" ]; then
|
||||
echo "[bundle] AppDir not found: $APPDIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib"
|
||||
|
||||
# Binaries to bundle. Names match the Fedora package layout; the
|
||||
# script tolerates missing entries so it can be re-used on newer
|
||||
# distros where the FreeRDP 3 binary naming shifts (xfreerdp /
|
||||
# xfreerdp3 / wlfreerdp / sdl-freerdp).
|
||||
#
|
||||
# Thin AppImage: only FreeRDP 3 client binaries here. The container
|
||||
# stack (podman / podman-compose / conmon / crun / netavark /
|
||||
# slirp4netns / pasta / passt) is intentionally NOT bundled -- see the
|
||||
# header comment for the root-cause rationale (#357 / #363).
|
||||
BINARIES=(
|
||||
xfreerdp3
|
||||
xfreerdp
|
||||
wlfreerdp3
|
||||
wlfreerdp
|
||||
sdl-freerdp3
|
||||
sdl-freerdp
|
||||
)
|
||||
|
||||
echo "[bundle] Copying binaries into $APPDIR/usr/bin/ ..."
|
||||
for bin in "${BINARIES[@]}"; do
|
||||
for path in "/usr/bin/$bin" "/usr/libexec/podman/$bin" "/usr/libexec/$bin"; do
|
||||
if [ -f "$path" ]; then
|
||||
cp -L "$path" "$APPDIR/usr/bin/"
|
||||
echo " + $bin (from $path)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Defensive sweep: if the FreeRDP package shipped any /usr/bin/*freerdp*
|
||||
# binary we didn't enumerate, grab it. Fedora package naming for the
|
||||
# FreeRDP 3 client has changed across releases (xfreerdp / xfreerdp3 /
|
||||
# freerdp / sdl-freerdp + arch suffix); this catches whichever variant
|
||||
# is present in the install.
|
||||
echo "[bundle] Defensive freerdp glob:"
|
||||
for path in /usr/bin/*freerdp* /usr/libexec/*freerdp*; do
|
||||
if [ -f "$path" ]; then
|
||||
base="$(basename "$path")"
|
||||
if [ ! -f "$APPDIR/usr/bin/$base" ]; then
|
||||
cp -L "$path" "$APPDIR/usr/bin/"
|
||||
echo " + $base (from $path, defensive)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Library exclude list -- these MUST come from the host even on
|
||||
# distro-agnostic AppImages. Bundling them is either a crash hazard
|
||||
# (glibc family) or a desktop-integration hazard (X / Wayland /
|
||||
# GL stack).
|
||||
HOST_LIBS_REGEX='^/(usr/)?(lib(64)?(/[^/]+)?)/('\
|
||||
'ld-linux[^/]*\.so[^/]*'\
|
||||
'|libc\.so[^/]*'\
|
||||
'|libm\.so[^/]*'\
|
||||
'|libdl\.so[^/]*'\
|
||||
'|libpthread\.so[^/]*'\
|
||||
'|librt\.so[^/]*'\
|
||||
'|libutil\.so[^/]*'\
|
||||
'|libresolv\.so[^/]*'\
|
||||
'|libnsl\.so[^/]*'\
|
||||
'|libcrypt\.so[^/]*'\
|
||||
'|libgcc_s\.so[^/]*'\
|
||||
'|libstdc\+\+\.so[^/]*'\
|
||||
'|libX11\.so[^/]*'\
|
||||
'|libXrandr\.so[^/]*'\
|
||||
'|libXi\.so[^/]*'\
|
||||
'|libXcursor\.so[^/]*'\
|
||||
'|libXfixes\.so[^/]*'\
|
||||
'|libXrender\.so[^/]*'\
|
||||
'|libXext\.so[^/]*'\
|
||||
'|libxcb[^/]*\.so[^/]*'\
|
||||
'|libxkbcommon[^/]*\.so[^/]*'\
|
||||
'|libwayland-[^/]+\.so[^/]*'\
|
||||
'|libGL\.so[^/]*'\
|
||||
'|libGLX\.so[^/]*'\
|
||||
'|libGLdispatch\.so[^/]*'\
|
||||
'|libEGL\.so[^/]*'\
|
||||
'|libdbus-1\.so[^/]*'\
|
||||
')$'
|
||||
|
||||
echo "[bundle] Traversing ldd for transitive deps ..."
|
||||
declare -A SEEN_LIBS=()
|
||||
copy_lib() {
|
||||
local lib="$1"
|
||||
[ -f "$lib" ] || return 0
|
||||
local base
|
||||
base="$(basename "$lib")"
|
||||
[ -z "${SEEN_LIBS[$base]:-}" ] || return 0
|
||||
SEEN_LIBS[$base]=1
|
||||
if [[ "$lib" =~ $HOST_LIBS_REGEX ]]; then
|
||||
return 0
|
||||
fi
|
||||
cp -L "$lib" "$APPDIR/usr/lib/" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ldd-traverse every bundled binary + every lib we copy in (transitive).
|
||||
# Two-pass: collect, then copy, then re-traverse the copies until
|
||||
# fixpoint (libs depending on other libs).
|
||||
queue=()
|
||||
for bin in "$APPDIR/usr/bin"/*; do
|
||||
[ -f "$bin" ] || continue
|
||||
queue+=("$bin")
|
||||
done
|
||||
|
||||
while [ ${#queue[@]} -gt 0 ]; do
|
||||
next_queue=()
|
||||
for item in "${queue[@]}"; do
|
||||
# ldd output: " libfoo.so.1 => /usr/lib/libfoo.so.1 (0x...)"
|
||||
while read -r lib; do
|
||||
[ -n "$lib" ] || continue
|
||||
local_before_count=${#SEEN_LIBS[@]}
|
||||
copy_lib "$lib"
|
||||
# If we copied a new lib, queue it for transitive ldd
|
||||
base="$(basename "$lib")"
|
||||
if [ -f "$APPDIR/usr/lib/$base" ] && [ "${SEEN_LIBS[$base]:-}" = "1" ] && [ $local_before_count -lt ${#SEEN_LIBS[@]} ]; then
|
||||
next_queue+=("$APPDIR/usr/lib/$base")
|
||||
fi
|
||||
done < <(ldd "$item" 2>/dev/null | grep -oE '/[^ ]+\.so[^ ]*')
|
||||
done
|
||||
queue=("${next_queue[@]}")
|
||||
done
|
||||
|
||||
echo "[bundle] Bundled binaries:"
|
||||
ls -1 "$APPDIR/usr/bin" | sed 's/^/ /'
|
||||
echo "[bundle] Bundled libraries: $(ls "$APPDIR/usr/lib" | wc -l) files"
|
||||
echo "[bundle] AppDir size: $(du -sh "$APPDIR" | cut -f1)"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user