13 KiB
Architecture
English | 한국어
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) |
| 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 →.cproctracking + reaper thread + desktop notification. - App install (Linux side). AppInfo (TOML) →
.desktopfile 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()→ checkpassword_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>.exeshim → atomic JSON write to\\tsclient\home\.local\share\winpodx\reverse-open\incoming\<uuid>.json→ host listener picks it up →safe_open_uncTOCTOU-safe path resolution →xdg-openinvocation 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.
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:
- Deliver
/oem— guestInvoke-WebRequest+tar -xzfintoC:\OEM.install.batis not re-run (it carries one-shot first-boot logic — autologon, account setup — that must not fire on a live install). - urlacl reservation — re-applies install.bat's netsh block over
/exec(delete overlapping:8765reservations, re-addhttp://+:8765/with theWDSID SDDL). - Idempotent registry / runtime fixes — calls
apply_windows_runtime_fixes(cfg)(same chain as apply-fixes), which also re-activates rdprrap against the refreshed binaries. - Restart the agent — the agent serves the
/execit runs through, so it can'tStop-Processitself synchronously. A one-shot scheduled task fires ~5 s later to stop and relaunchC:\OEM\agent.ps1; the/execcall returns first, then the new agent rebinds:8765under the corrected urlacl. - 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:
-
Place your
.isosomewhere readable (e.g.~/winpodx-custom.iso). -
Edit your
winpodx.tomlto setwin_version = "custom":[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.
-
Edit the generated
~/.config/winpodx/compose.yamlto mount the ISO at the path dockur looks for:services: windows: volumes: - ~/winpodx-custom.iso:/storage/custom.iso # ...existing volumes stay -
Recreate the container:
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.