3e779be6f3
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
241 lines
13 KiB
Markdown
241 lines
13 KiB
Markdown
# 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.
|