Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

241 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 13 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.