Files
kernalix7--winpodx/CHANGELOG.md
T
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

324 KiB
Raw Blame History

Changelog

English | 한국어

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project aims to follow Semantic Versioning.

[Unreleased]

Fixed

  • A non-purge uninstall.sh no longer deletes the Windows VM disk (#716, thanks @munir-abbasi — data-loss report). The default VM storage lives under the app-data dir (~/.local/share/winpodx/storage/data.img), and the "remove app definitions" step did a plain rm -rf ~/.local/share/winpodx — so a non-purge uninstall (documented as keeping the VM data), especially via the package-manager postrm re-entry where it runs unattended, recursively wiped the Windows VM out from under storage/. Uninstall now canonicalizes both paths and, when the storage dir is DATA_DIR itself or nested beneath it and --purge was not given, preserves the storage subtree and removes only the other app-data entries. A custom WINPODX_STORAGE_PATH outside the app-data dir is untouched as before, and --purge still wipes everything. Regression-tested via the uninstall smoke harness (nested-storage, external-storage, and purge cases).

Added

  • Launching a Linux app directly from its "Linux Apps" shortcut on Windows now runs it (#616, thanks @notnotno). The reverse-open shims placed under Windows' Start Menu / Desktop "Linux Apps" folder are primarily "Open with…" chooser entries, but clicking one directly (with no file) used to do nothing — the guest shim silently exited because it required a file argument. It now emits a launch-only request (origin: "launch", empty path) and the host runs the Linux app with no file (the %f/%u placeholder is stripped rather than filled), so the shortcuts double as plain app launchers. Requires the updated guest shim (re-provisions on the next winpodx guest sync / apply-fixes).

Fixed

  • The GUI's libxcb-cursor0 dependency is now auto-installed on a minimal desktop (#712, thanks @numericOverflow). Qt 6.5+ (PySide6) refuses to start its xcb platform plugin without libxcb-cursor.so.0 — "could not load the Qt platform plugin 'xcb'" — and it isn't pulled in transitively on a fresh minimal install (e.g. Linux Mint 22). install.sh now installs it (distro-mapped: libxcb-cursor0 on Debian/Ubuntu/openSUSE, xcb-util-cursor on Fedora/Arch) when the GUI is enabled and the runtime lib isn't already present. Presence is detected by probing the real .so on disk across the standard lib dirs rather than ldconfig -p — on openSUSE the library lives in /usr/lib64 but is absent from the ld.so cache, so an ldconfig probe re-prompted every run even with the package installed.

[0.9.0] - 2026-07-11

Added

  • Windows apps can be registered as URL-scheme handlers on Linux (#421, #694 — host-side groundwork). When an app carries URL schemes (mailto, https, slack, vnc, …), its .desktop now gets x-scheme-handler/<scheme> entries and the Exec accepts a URL, so clicking such a link on the host opens it in the Windows app — the URL is passed straight through to the app (not mapped to a $HOME file path). A shared policy module (denylist + strict scheme regex + a /app-cmd sanitizer) blocks dangerous schemes (file:, javascript:, data:, …) and neutralizes command-line injection at both registration and launch. Discovery now auto-harvests each app's schemes from the guest (per-scheme UrlAssociations default, per-app Capabilities\URLAssociations, and the UWP manifest's windows.protocol), so an app that handles mailto/https/etc. is registered automatically — no manual app.toml edit needed.

Changed

  • The optional Windows debloat pass opts out of more ads / telemetry (#669, #684, thanks @GameSoul7Eugene). Broadened the advertising and suggestion opt-outs (advertising ID, lock-screen "tips & tricks", Start / Settings / search suggestions, silent consumer-app auto-install), added a disable for the SQM/CEIP telemetry scheduled task, and applied RDP-friendly visual-effect tweaks — all kept to a conservative, reversible scope.

Fixed

  • A discovered Windows app can no longer silently seize the host's default http / https handler (security hardening). Registering a Windows app as a URL-scheme handler ran xdg-mime default for every declared scheme — so an app discovered from the (semi-trusted) guest could, via app install --mime or doctor --fix, become the system default for http/https and receive every web link the user clicks (session tokens included). Those two universal web schemes are now excluded from the automatic default grab: a Windows browser still works as a URL target (app run <browser> https://…) and stays available in "Open with", but making it your web default is now an explicit, opt-in choice. mailto and vendor schemes are unaffected.
  • The session window-reaper no longer mis-kills a just-relaunched app, nor mass-kills every session on a transient scan failure (#680 follow-up). Closing a document and immediately reopening it could SIGTERM the fresh session (the reaper tracked apps by name, and a relaunch overwrites the process marker) — the reaper now binds each reap to the exact PID it armed against. And a non-zero wmctrl exit (the window manager momentarily busy) was read as "no windows open", so two transient failures in a row reaped every running app; a failed scan is now treated as a failure, not as zero windows.
  • The install one-liner now auto-installs git if it's missing instead of aborting (#705, thanks @numericOverflow). On a fresh system without git (e.g. Linux Mint 22) the curl … | bash installer failed at the clone step with "git is required for remote install" — even though it auto-installs every other dependency. It now installs git through the same package-manager path as the rest, and only errors out (with a manual-install hint) if the package manager can't provide it.
  • kio-fuse is no longer reported "not installed" when it actually is (#697, thanks @twkirk161). The Info/health check only looked for the kio-fuse binary in a fixed set of paths, so on distros that put it elsewhere — Fedora Kinoite / KF6-versioned libexec, or the Debian multiarch /usr/lib/<triplet>/libexec/kio-fuse (three levels deep, missed by the old two-level glob) — it read as missing even though reverse-open guest-disk mounting (#616) works fine. Detection now also matches the multiarch path and, as the authoritative signal, the org.kde.KIOFuse D-Bus activation service that the mount actually calls.
  • The reverse-open listener now autostarts with the tray, and tray Quit stops it deliberately (#691, thanks @notnotno). The listener was only (re)started by pod start and the app-launch path, so starting just winpodx gui (or logging in with the tray autostart) left Windows→Linux "Open with" silently dead even with the feature enabled. And Quit only killed it by accident: the listener is a double-forked daemon that inherits its parent's command line, so the pkill …gui in Quit matched it only when it happened to be started from the GUI settings panel. The tray now runs the idempotent listener self-heal at startup (off the UI thread) and Quit calls stop_listener() explicitly.
  • Fast suspend/resume recovery actually subscribes to systemd-logind now (#690, thanks @notnotno). The tray's PrepareForSleep D-Bus subscription passed a bare Python function to QDBusConnection.connect, which PySide6 rejects at runtime (called with wrong argument types) — so the fast post-resume pod refresh (#225) never actually armed and every install silently fell back to the 30 s status poll, logging a warning at each winpodx gui/tray start. The subscription now uses a QObject receiver with a SLOT("onPrepareForSleep(bool)") signature (held on the tray icon so it can't be garbage-collected out from under the connection), verified live against org.freedesktop.login1.
  • A Windows app that stays resident after you close its window no longer shows "RUNNING" forever (#680, thanks @mdshahalam3). A FreeRDP RemoteApp client only exits when the guest disconnects, but Office keeps its process (and the RemoteApp session) resident after the document window closes — so the session never terminated, the app listed RUNNING indefinitely, and it left a half-stuck \\tsclient\home redirect that broke the next open. The system-tray helper now watches every session's RAIL windows and, once an app's windows have all closed (with a short debounce), terminates the stuck session so it drops off the running list. It runs in the long-lived tray (not the short-lived winpodx app run process, which exits immediately) so it fires for menu / CLI launches too. Best-effort (X11/XWayland with wmctrl); it only reaps a session whose window was actually seen, so a launching or window-less app is never reaped early.
  • FreeRDP's cosmetic [get_next_comma]: Invalid quoted argument warning at every RemoteApp launch is silenced (#680, thanks @mdshahalam3). It came from the quoted cmd:"<UNC>" form winpodx keeps so a file path with spaces isn't split; the FreeRDP subprocess now runs with WLOG_FILTER=com.winpr.commandline:FATAL, which mutes that one WLog tag without changing the delivered command line (the /log-filters flag is parsed too late to suppress its own parser's warning).
  • Office 2016 (MSI) apps no longer collapse into one broken "Microsoft Office 2016 component" entry (#680 regression, thanks @mdshahalam3). 0.8.0's Start-Menu-only discovery read each shortcut's raw .TargetPath, but MSI installs use advertised shortcuts (Windows Installer "Darwin descriptors") whose target resolves to a per-product icon stub under C:\WINDOWS\Installer\{ProductCode}\ (e.g. xlicons.exe). Those stubs all share the file description "Microsoft Office 2016 component", so Word/Excel/PowerPoint/Outlook all looked identical and deduplicated into a single non-runnable entry — they vanished from winpodx app list. Advertised shortcuts are now resolved to their real installed executable via MsiGetShortcutTarget + MsiGetComponentPath before recording the target; normal (Click-to-Run / direct) shortcuts are unaffected.
  • "Open with" a Windows app while it's already running now reliably opens the file in a fresh window (#675 thanks @ajeshchrist, #680 thanks @mdshahalam3). It used to silently do nothing (the already-running path swallowed every error), or — when the file was delivered to the running app via the guest agent — pop "Access denied", or hang ~30 s before a crash-looking relaunch. Root cause: the guest runs multi-session RDP, so any attempt to hand a file to the already-visible app lands in a different session (the agent even runs in the autologon console session, which has no per-session \\tsclient\home redirect at all). So winpodx no longer attempts a "warm" delivery — opening a file while the app is running goes straight to a fresh RemoteApp window carrying the file (whose credentialed connect also unlocks / re-logs-in the session). A file outside the shared $HOME / media locations raises a visible error instead of vanishing. Also hardened the silent-failure surface: the .desktop Exec uses %f (multi-select opens one window per file instead of a no-op), and notify-send + file:// URIs resolve by absolute path so error toasts and file arguments survive a stripped-PATH launch.
  • The guest lock-screen check is now session-scoped, and the cold launch waits longer for a slow guest (#680/#675, thanks @mdshahalam3 & @ajeshchrist). winpodx waits for the guest desktop to be interactive before creating a RemoteApp window, but the check used a machine-wide Get-Process LogonUI — so a lock screen in another session (the console, or a stale disconnected RAIL session) wrongly reported the app's own session as locked and made launches wait the full timeout. It now matches LogonUI/explorer by session ID. The cold launch also waits up to 45 s (was 20 s) for a slow guest to finish autologon before drawing the window, so it no longer paints a stale logon frame over the app.

Contributors

Thanks to everyone who reported issues or contributed fixes in this release: @notnotno (#694, #691, #690), @twkirk161 (#697), @mdshahalam3 (#680), @ajeshchrist (#675), @numericOverflow (#705), and @GameSoul7Eugene (#669, #684).

[0.8.0] - 2026-06-30

Added

  • Windows apps are now grouped on the Linux menu by their Start Menu folder (#581, thanks @Milliw). The Start Menu subfolder each app lives in (e.g. Microsoft Office\Tools) is mirrored into nested sub-groups under the "winpodx" menu folder, just like Windows shows them. Renders on KDE Plasma, XFCE, Cinnamon, MATE and LXQt (the freedesktop .menu mechanism); on GNOME the apps still appear but ungrouped (its overview is a flat grid). Top-level apps and apps with no folder stay directly under "winpodx".
  • Your [pod] keyboard setting now drives the FreeRDP session keyboard layout (#660, thanks @lsvab). The locale you pick for the Windows install (e.g. keyboard = "hu-HU") is now mapped to the matching Windows layout and passed to FreeRDP as /kbd:layout:0x…, so a non-US keyboard works in the RemoteApp window without hand-writing rdp.extra_flags. The default en-US is left untouched (FreeRDP keeps auto-detecting your host XKB layout, so users who never changed the setting aren't forced onto US), an explicit /kbd in rdp.extra_flags always wins, and an unmapped locale falls back to auto-detect. (Passing /kbd manually via rdp.extra_flags was already unblocked in 0.7.4.)

Changed

  • App discovery now surfaces only the apps your Windows Start Menu actually shows, by default (#581, thanks @Milliw). Previously every registered executable was scanned (registry App Paths, Chocolatey/Scoop shims, every UWP package) and dumped into the Linux menu, flooding it with uninstallers, helpers, and background processes. Discovery now defaults to Start-Menu-only: Start Menu shortcuts, Start-Menu-visible UWP apps (intersected with Get-StartApps), and the OS essentials (File Explorer / Calculator / Settings). Turn the old behaviour back on with winpodx config set desktop.full_app_scan true or the Settings → "Discover all installed apps (not just Start Menu apps)" checkbox — useful for portable apps that have no Start Menu entry. Takes effect on the next winpodx app refresh.
  • Bundled rdprrap bumped 0.1.3 → 0.3.0. rdprrap (the multi-session RDP wrapper that lets each RemoteApp window get its own session) now derives its termsrv.dll patch sites dynamically — it disassembles each target function at runtime and encodes the patch bytes, instead of relying on hardcoded struct offsets / register choices / byte templates. This keeps multi-session working across Windows build-to-build termsrv.dll struct-layout shifts. OEM version 27 → 28, so existing installs pick it up on the next winpodx guest sync / apply-fixes.

Fixed

  • The GUI no longer crashes (SIGSEGV) from a layout-recursion when app names are long/CJK or the app list is empty (#553, thanks @hermitguo). Word-wrap QLabels inside the resizable app-list scroll area (the empty-state panel, the launcher tiles) had a wrap width that tracked the viewport, so their height fed back into their width and re-entered QBoxLayout::setGeometryheightForWidth without bound on Qt 6.11 — a hard segfault on startup (empty list) or right after adding a CJK-named app. Those labels now have a constant (fixed) wrap width, breaking the feedback loop.
  • The Debloat / maintenance task window now closes itself when the operation finishes (#550, thanks @ismikes). The progress BusyDialog was closed with QTimer.singleShot(0, dlg.finish) from inside the worker — but a bare threading.Thread has no Qt event loop, so that timer never fired and the window hung open until dismissed by hand (the v0.7.2 change only fixed when the worker starts, not the close). BusyDialog.finish() is now signal-based, so a cross-thread close queues accept() onto the GUI thread and the window closes on completion (Debloat, Grow Disk, Sync Guest, Apply Fixes).
  • "Refresh Apps" in the GUI no longer crashes (SIGSEGV) on completion. The discovery worker (a parentless, Python-owned QObject) had two delete paths — its own deleteLater on the worker thread and the Python ref-drop on the main thread. Qt6 emits QThread.finished before flushing the worker's deleteLater, so the cleanup slot dropped the last Python ref while that delete was still in flight, double-freeing the worker across threads (QObject::~QObject segfault). Now there is exactly one delete path (the ref-drop, after thread.wait() confirms the worker thread is dead), the start guard also bails on a live thread ref (so a fast re-click can't drop a still-finishing worker), and the window joins in-flight worker threads on close.
  • The GUI no longer crashes (SIGABRT) when a background worker reads the display scale. QGuiApplication.screens() is GUI-thread-only, but it was reachable from worker threads (the Info panel's gather_info, etc.), which spammed QObject::setParent: ... different thread warnings and could abort the whole app with __cxa_pure_virtual during the worker's thread teardown — surfacing as the GUI dying on a refresh. The Qt scale probe now short-circuits to None off the main thread and falls back to the subprocess/env detection.
  • winpodx app refresh now removes apps that are no longer discovered, not just adds new ones (#581). A refresh used to only ever add entries; discovered apps that vanished — an app uninstalled inside Windows, or everything dropped by the new Start-Menu-only default — lingered in the Linux menu until manually deleted. Refresh now prunes the stale discovered profiles (and their launchers) so the menu actually migrates to the current set. Manually-added apps (~/.local/share/winpodx/apps/) are never touched, and a failed/empty scan never wipes the menu.
  • --win-iso now actually installs from your ISO instead of downloading Windows anyway (#647, thanks @ismikes). The local ISO was staged into <storage>/custom.iso after winpodx setup had already run compose up — so the container had booted and dockur had started its Microsoft download before the file existed (dockur looks for custom.iso the moment it boots). The staging now happens inside winpodx setup, after the storage path is resolved but before the container is (re)created, so dockur finds the ISO and installs from it. Also exposed as winpodx setup --win-iso <path>.
  • The reverse-open listener now self-heals on app launch instead of staying dead until the next pod start. A winpodx pod stop / tray Quit stops the listener (stop_listener()); while the pod kept running, nothing re-spawned the watcher, so "Open with → a Linux app" from Windows silently did nothing. ensure_ready (every winpodx app run / GUI launch) now idempotently ensures the listener is up when reverse_open is enabled. Surfaced during the v0.7.4 smoke.

Contributors

Thanks to everyone who reported issues fixed in this release: @Milliw (#581), @lsvab (#660), @ismikes (#647, #550), @hermitguo (#553), and @liveifsh (#659).

[0.7.4] - 2026-06-23

Added

  • install.sh --storage-dir <path> (and winpodx setup --storage-path) chooses where the Windows VM lives (#646, thanks @realahmed7777). Put the VM disk + ISO on a roomier partition instead of ~/.local/share/winpodx/storage — the directory is created with the same prep as the default (chattr +C on btrfs so the raw disk doesn't fragment, SSD emulation when the target is non-rotational). Fresh installs only; relocating an existing install stays winpodx setup --migrate-storage --migrate-storage-target.
  • Fresh installs on a 24 GB+ host now default the VM to 8 GB RAM instead of 6 (#630, thanks @ismikes). Windows 11 runs noticeably smoother with 8 GB, and a host with ≥24 GB can spare it — the auto-tier that pre-fills pod.ram_gb during winpodx setup bumps the mid tier from 6 to 8 GB on those hosts (CPU sizing unchanged; ≥32 GB / ≥12-thread hosts still get the 12 GB high tier). Existing installs keep your configured value; change it any time in Settings or with winpodx config set pod.ram_gb 8.
  • install.sh --win-iso <path> installs from a local Windows ISO instead of downloading (#647, thanks @ismikes). Point the installer at a Windows ISO you already have and it's staged into the storage dir as dockur's custom.iso, so the install skips the ~5-8 GB Microsoft download — handy for repeated purge/reinstall cycles. Reflink-copied where the filesystem supports it (btrfs/xfs), so it costs no extra disk. (You could already drop a custom.iso into the storage dir by hand; this wires it to a flag and documents it in --help.)

Fixed

  • The Windows password is no longer written to the log in cleartext. The "Launching RDP" / "Relaunching RDP" log lines printed the full xfreerdp argv, which includes /p:<password>. Password tokens (/p:, /gp:) are now masked to /p:*** before logging. (Local-only logs; pre-existing — hardened during the 0.7.4 security review.)
  • The Logs tab's Status / Pod logs / Inspect buttons now use the selected container backend instead of always running podman (#658). On a Docker-backend install, the Terminal-tab diagnostics buttons shelled out podman ps / podman logs / podman inspect regardless of pod.backend, so they failed (or probed the wrong runtime) whenever podman wasn't the active backend. They now honour cfg.pod.backenddocker when Docker is selected (manual falls back to podman, where the container commands are inert). The pod-log tail's on-screen $ … echo was also corrected to print the real backend instead of a hardcoded podman.
  • Opening a second document in an already-running Windows app now works instead of silently doing nothing (#657). With an app already open (e.g. Word), launching a second file for it — a second gio launch, or right-click → Open with → the app — dropped the file: the live session was reused but the path was discarded. The file is now delivered into the running session — mapped to its \\tsclient UNC path and opened via Start-Process, sent over the guest agent with a FreeRDP RemoteApp fallback when the agent is unreachable. Best-effort: failures are logged and never block the launch.
  • Right-click → Open with a Windows app now works on desktops that launch with a stripped PATH (e.g. Deepin) (#657). Generated .desktop files used a bare Exec=winpodx …, which fails with exec: winpodx: not found when the desktop environment runs the launcher as a systemd transient unit that drops ~/.local/bin from PATH (Deepin's dde-application-manager). Launching via gio launch from a terminal worked because it inherited the shell PATH, which masked the bug. The entry now embeds winpodx's absolute path, resolved with shutil.which() at install time (falling back to the bare name when it can't be resolved).
  • A missing RDP password now fails with a clear error instead of an unanswerable prompt (#569, thanks @biskasarchaniotakis). When neither rdp.password nor rdp.askpass was set, xfreerdp fell back to an interactive password prompt that can't be answered under a GUI launch, so the launch failed only with the opaque Inappropriate ioctl for device / ERRCONNECT_CONNECT_CANCELLED. build_rdp_command now raises up front, naming the missing setting.
  • Password rotation now surfaces a rejected net user instead of reporting success (#569). The guest-side PowerShell printed password set regardless of the net user exit code, so a rejected change left the stored and actual Windows passwords out of sync while reporting rc=0 — which then shows up downstream as failed RDP auth / apps not opening. It now checks $LASTEXITCODE and exits non-zero on failure.
  • /kbd is now an allowed FreeRDP extra flag (#657). Pass a keyboard-layout override via rdp.extra_flags (e.g. /kbd:layout:0x409) to work around FreeRDP keycode-scanning warnings on some layouts.
  • The Debian/Ubuntu .deb now pulls in podman-compose, and a missing compose provider fails loudly instead of cryptically (#644, thanks @paolodongilli). On Debian 13 the .deb installed winpodx + podman but not podman-compose, so winpodx setup couldn't create the container and died downstream with no such container "winpodx-windows". podman-compose is now a package Recommends (apt installs it by default), and if no compose provider is present at setup time winpodx prints an actionable error naming the package to install per distro instead of silently skipping container creation. (The curl … install.sh path already installed it — this closes the .deb gap.)
  • The maintenance task dialog (Debloat, Grow Disk, Sync Guest, …) no longer opens cramped (#550, thanks @ismikes). The BusyDialog progress window had only a 380px minimum width and sized its height to content, so it came up around 392×139 — too small to read comfortably, most visibly on the Debloat Speed run. Given a 480×168 floor. (The picker-window size and the fast-op auto-close were already addressed in 0.7.2.)

Contributors

Thanks to @cxgreat2014 for the code contributions (PRs #657, #658), and to @ismikes (#630, #550), @realahmed7777 (#646), @paolodongilli (#644), and @biskasarchaniotakis (#569) for the reports and suggestions that drove this release.

[0.7.3] - 2026-06-20

Added

  • +multitouch is now an allowed FreeRDP flag — touchscreen / stylus / pen passthrough (#623, thanks @Scratch2xs). Add +multitouch to your extra FreeRDP flags (rdp.extra_flags in winpodx.toml, or the Settings extra-args field) so a touchscreen, drawing tablet, or pen works inside Windows apps — handy for drawing software with no Linux build. It's an input-only toggle, so it joins the FreeRDP flag allowlist with no security trade-off.
  • Optional idle auto-stop to free the VM's RAM (#622, thanks @hermitguo). Settings → Idle Action chooses what the Idle Timeout does when it elapses: Pause (default — freezes the VM, frees CPU, keeps RAM, instant resume) or Stop (frees the VM's RAM; the next launch is a full boot). Off by default — Idle Timeout itself is off by default, and the action stays Pause unless you opt into Stop. (The winpodx interface is already multi-language, also part of this request: Settings → UI Language picks from 8 languages including 中文 / 日本語 / 한국어.)
  • Reverse-open now opens files that live on the Windows VM itself, not just your shared Home (#616, thanks @notnotno). Picking a Linux app from a Windows file's Open with menu used to only work for files under your shared Home (\\tsclient\home); a file on the Windows Desktop or anywhere on C: failed with a confusing "No such file". The guest's C: is now shared to the host over SMB (loopback only) and mounted on demand, so a host app opens the real guest file — and your edits save straight back to it. Delivered entirely at runtime (guest share via the agent + host mount), so install.bat is untouched. Requires kio-fuse on the host (KDE); winpodx doctor warns if it's missing.
  • Debloat disables more telemetry and ad surfaces (#590, thanks @GameSoul7Eugene). The Ads & suggestions item now clears additional ContentDeliveryManager suggestion keys and the rotating lock-screen ads, and the Unnecessary scheduled tasks item disables more pure-telemetry tasks (AitAgent, ProgramInventoryUpdater, CEIP BthSQM, Feedback\Siuf, WindowsAI Copilot/Insights data collection, and the Office telemetry agents). Security- and system-critical tasks (Windows Defender, licensing/activation, certificate services, Windows Update repair, language packs, Windows Hello) are deliberately left untouched so debloat can't break activation, updates, or the IME.

Fixed

  • Dashboard RAM + Disk C: gauges no longer get stuck on "n/a" (#634, thanks @ismikes). Both read from the guest over the agent (CPU is host-side, which is why it kept working), and the quiet poll's timeout was a tight 4s — on a slow or freshly-relaunched guest the read didn't finish in time and both gauges blanked to "n/a". Bumped to a generous 12s (the poll runs off the GUI thread with a re-entrancy guard, so the longer budget never freezes or stacks the dashboard). Same too-tight-timeout class as #619.
  • winpodx app refresh no longer times out at 30s on a slow guest (#619, thanks @KyleSanderson). Discovery enumerates the guest's Start Menu + AppX packages, which on a cold or low-spec guest legitimately runs over a minute — but the CLI capped it at 30s (/exec timed out after 29.0s) while a fresh install discovered fine at the library's own 180s. The timeout is now a single generous default (DEFAULT_DISCOVERY_TIMEOUT = 300s) across every path (provision, app refresh, GUI Refresh); it exists only to bound a genuinely wedged guest, not to cap normal completion. Raise it with winpodx app refresh --timeout for a pathologically slow guest.
  • install.sh --ref <branch> now installs the branch's latest commit on a re-run (#616). It fetched the remote but then checked out the stale local branch ref (and only reset --hard for main), so re-running an update against a feature branch reinstalled whatever was first cloned — a silent no-op. It now checks out origin/<ref>.
  • An update now applies compose-template changes and starts a stopped pod, instead of deferring (#616). install.sh regenerates compose.yaml on every upgrade but recreated the container only when the pod was already running, and skipped the Windows-side apply entirely when the pod was stopped — so guest-side changes shipped by an update silently didn't land until the next manual launch. An update now recreates on a compose change even when stopped, and starts a stopped pod to apply (existing install — no ISO redownload).
  • Removed the USB drive-letter auto-mapper that kept destabilizing the install (#613, #638, thanks @zephir2008 and @ismikes). media_monitor.ps1 tried to surface each USB volume as a drive letter (E:, F:, …), but it could not do so reliably in RemoteApp (RAIL) sessions, and shipping it in the OEM bundle re-triggered the intermittent Windows Defender / rdprrap first-boot install deadlock (fresh installs hung right after the file was copied). It's gone. USB media is still reachable in every session via the \\tsclient\media redirection and the desktop USB shortcut; for a real drive letter or raw block-device access, use USB passthrough. Existing pods have the stale WinpodxMedia autostart entry cleaned up on the next apply-fixes.
  • The usbredirect not found hint now names the right package per distro (#593, thanks @techabsol). Debian/Ubuntu is usbredirect, Fedora is usbredir-tools (plus an Atomic Fedora rpm-ostree form), and openSUSE stays usbredir — the previous message pointed at packages that don't carry the binary.
  • install.bat no longer throws a syntax error reserving the agent URL (#614, thanks @zephir2008). The netsh http add urlacl … sddl=D:(A;;GX;;;WD) line left the SDDL unquoted, so cmd.exe treated its (, ;, and ) as metacharacters and the reservation could fail. The SDDL value is now quoted (sddl="D:(A;;GX;;;WD)").
  • The guest agent recovers from a drifted bearer token instead of staying 401 forever (#615, thanks @zephir2008). The agent reads its token once at boot from a baked copy of C:\OEM\agent_token.txt; if that diverges from the host token, guest_exec / guest_summary (and any authenticated /exec) return HTTP 401 with no way for the agent to fix itself. A new winpodx guest resync-token re-pushes the current token over the FreeRDP channel (which auths with the Windows password, so it works while the agent is 401) and respawns the agent to re-read it. winpodx doctor now auto-runs this when its guest_exec probe hits a 401, then re-checks.

Contributors

Thanks to @notnotno (#616), @hermitguo (#622), @Scratch2xs (#623), @KyleSanderson (#619), @ismikes (#634, #638), @GameSoul7Eugene (#590), @techabsol (#593), and @zephir2008 (#613, #614, #615) for the reports and contributions that drove this release.

[0.7.2] - 2026-06-15

Fixed

  • GUI no longer crashes when clicking "Refresh Apps" on KDE/Plasma Wayland (#567). The app list is a widgetResizable scroll area whose as-needed vertical scrollbar made QScrollArea.updateScrollBars re-query the word-wrapped labels' heightForWidth without bound → stack-overflow SIGSEGV. The scrollbar is now pinned so the viewport width can't oscillate.
  • Tray "Terminate Session" and "USB Devices" submenus open again on KDE/Plasma (#573). Their menu entries were built with parentless QActions inside a helper, so PySide6 garbage-collected them once the helper returned and the submenus exported with zero children (the arrow showed but nothing opened). Each action is now parented to its submenu so it survives.
  • Chinese / Japanese / Korean app names are no longer dropped from discovery (#553). A purely non-ASCII name sanitized to an empty slug and the app vanished; it now keeps the original characters as the display name and gets a stable internal id, so CJK-named apps appear like any other.
  • winpodx pod stop keeps the container instead of removing it. It used compose down (which deletes the container), so updating winpodx while stopped recreated the container from compose every time — "Container 'winpodx-windows' is missing — creating it …" plus an unnecessary Windows reboot. It now uses compose stop; start just restarts the kept container. The disk is unaffected.
  • A missing Windows username now fails fast with a clear message instead of a cryptic FreeRDP Inappropriate ioctl for device error (#569). With no credentials xfreerdp fell back to an interactive prompt that can't run under a GUI launch; winpodx now says to run winpodx setup.
  • winpodx doctor / extra RDP flags accept the current FreeRDP 3 spellings (#380): the cache toggles are /cache:bitmap:on|off (etc.) — the old FreeRDP-2 +/-bitmap-cache forms that xfreerdp 3 rejects are dropped from the allow-list.
  • -gfx can be passed to disable the GFX pipeline (#393) as a workaround for RAIL render glitches (blue/warping windows) on some XWayland/Plasma setups; the bare +gfx/-gfx toggles were previously blocked by the extra-args allow-list.
  • The Debloat picker opens at a usable size, and the "running debloat" dialog now closes itself when the run finishes (a fast preset could leave it hanging) (#550).

Contributors

Thanks to @ismikes (#393, #550, #573), @notnotno (#380), @hermitguo (#553), @bangetto (#567), and @biskasarchaniotakis (#569) for the bug reports that drove this release.

[0.7.1] - 2026-06-13

Added

  • Quick app launcher (winpodx launch). A Start-menu-style picker for your Windows apps — search, category pills, reveal-highlight tiles, and a compact mode — sourced from the same app registry as the main GUI and launched through winpodx app run. Bind it to a desktop-environment custom shortcut (KDE/GNOME) for a system-wide hotkey; no extra dependencies and Wayland-safe (#561).
  • Automatic file associations — discovered Windows apps now appear in your file manager's "Open with" menu (#545). On by default; disable with winpodx config set desktop.mime_associations false or the GUI Settings toggle. Apps are only added to the "Open with" / Recommended list — winpodx never sets a Windows app as the default handler for a type. The associations are read live from Windows and unioned across every place an app declares what it can open (per-extension UserChoice, per-app Capabilities\FileAssociations, Applications\<exe>\SupportedTypes, and the UWP AppxManifest windows.fileTypeAssociation), so e.g. .png offers Paint/Photos/Snipping Tool, .pdf offers Edge, .txt offers Notepad. Extensions are mapped to MIME types via the system shared-mime-info database (no hardcoded tables), and the change auto-applies on the next install.sh update via the discovery refresh — no manual app refresh.
  • GUI app management — reset, custom icons, and multi-select (#530). Per-app Reset to detected restores the auto-discovered name/icon; a custom icon picker overrides the icon; multi-select enables bulk Hide selected / Remove selected; and removed apps land in a restore list so a deletion can be undone (plus a full re-detect button). Checkboxes now use a visible (contrasting) colour.
  • winpodx doctor warns on an old FreeRDP build with broken RemoteApp windows (#546). A native xfreerdp 3.x below 3.6.0 (e.g. the 3.5.1 that Ubuntu/Budgie 24.04 ships via apt) has RAIL window-mapping bugs that leave a RemoteApp connected but its window never shown. doctor now flags this as a warning (not a failure) and points to an upgrade or the winpodx AppImage.
  • Checksum-gated periodic icon refresh. Discovered-app icons re-sync from the guest only when the source executable's checksum changes, so a routine refresh no longer rewrites unchanged icons (#539).

Fixed

  • winpodx gui no longer blocks the terminal (#549). Launched from a shell it now detaches into its own session, brings up the tray + dashboard, and returns the prompt immediately. .desktop autostart and the quick launcher already ran it detached; winpodx gui --foreground keeps it attached for debugging.
  • install.sh --main (and --ref / --source / --image-tar) is honoured on Atomic Fedora (#548). rpm-ostree systems (Silverblue/Kinoite/Bazzite/...) defaulted to the OBS RPM and ignored an explicit source override; the RPM only ships tagged releases, so --main could never reach you. The rpm-ostree path is now gated on those flags being unset, so custom-image builders can layer winpodx from source. Default Atomic installs are unchanged.
  • install.sh auto-loads the kvm module when /dev/kvm is missing but the CPU supports virtualization (#541).
  • Bare-metal disguise no longer crash-loops the container (#246 follow-up). After winpodx re-writes the synthetic SMBIOS blob, the file is relabelled so the container can read it back; a stale label previously broke the next boot.
  • winpodx doctor no longer flags intentionally hidden apps as missing desktop entries (#535).
  • uninstall.sh now removes everything winpodx creates — the consolidated "WinPodX" menu folder (.directory / .menu fragments, which otherwise left an empty submenu), stale mimeapps.list handler entries, and (on --purge) the /etc/modules-load.d/winpodx-kvm.conf drop-in.
  • A discovered app name can no longer inject extra keys into its .desktop launcher. Control characters in the guest-supplied name are stripped from the Name= field, matching the existing Comment= handling (hardening; a hostile guest is required to trigger it).

Changed

  • winpodx gui detaches by default for interactive launches, with --foreground to opt out (#549).

Contributors

Thanks to @MirzaAyBaig12 for contributing the app launcher (#561) and for reporting #530, #535, and #545; to @hermitguo for reporting #541 and #546; to @notnotno for reporting #548; and to @ismikes for reporting #549.

[0.7.0] - 2026-06-11

Added

  • Bare-metal disguise — make the Windows guest read like a physical machine to VM-detection software (#246). Opt-in, off by default. Software that refuses to run under a detected hypervisor — Nvidia GPU-passthrough "code 43", launch-gate VM checks, VM-hostile installers — sees a genuine-looking bare-metal box instead of QEMU/KVM. It works in two layers, picked by the bare-metal level (winpodx config set pod.disguise_level off | balanced | max, or the GUI Settings selector):
    • Per-VM, no rebuild (balanced+): 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, and injects a synthetic SMBIOS sensor/descriptor blob (voltage / temperature probe, cooling device, cache, memory array + DIMMs, 40+ structures) so the Win32_* / CIM_* WMI sensor classes report like real hardware.
    • Patched-QEMU image (max / "Hardened"): winpodx disguise build-image compiles QEMU locally (~2040 min; no binary is ever shipped) with the VM-identifying strings QEMU can't reach via the command line rewritten to the host's real values — ACPI OEM IDs (BOCHS/BXPC → host), the FADT hypervisor-vendor + PM-profile byte, device _HIDs, the WAET table signature, disk / optical model and INQUIRY vendor — plus an injected thermal-zone SSDT and a WSMT table. At max, winpodx also presents a SATA system disk, an e1000 NIC, std VGA, and a 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 the unused virtio driver service keys in the guest. Selecting Hardened in the GUI builds + wires the image 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).
    • 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).
  • Default Windows guest username is now WPX-User (was User), which avoids al-khaser's exact-match sandbox-username list. Change it with winpodx config set rdp.user <name> before first install.

Fixed

  • Opening a file under a symlinked subdirectory of $HOME — e.g. ~/Documents symlinked to /mnt/store/Documents — no longer fails with "Path is outside shared locations" (#547). The host→guest path is normalised lexically instead of resolved, so a symlinked subdir is traversed like a folder (FreeRDP follows it); .. is still blocked, and the Fedora Atomic /home → /var/home handling is kept (#418).
  • USB hot-plug works again on the new dockur image. dockur v5.16 moved the QEMU monitor from telnet:7100 to a unix socket, which broke the live USB attach path with "QEMU monitor unreachable"; winpodx now talks to the socket (and still falls back to telnet for older dockur) (#286).
  • The GUI Devices panel no longer flickers when a host USB device is unplugged while the tab is open, and a device yanked mid-enumeration no longer raises into the Qt slot.

Changed

  • Pinned dockur/windows bumped to v5.16 (x86_64) and the dockur/windows-arm :latest security rebuild (aarch64) (#554, #555).

[0.6.0] - 2026-06-05

Removed

  • The libvirt backend was dropped (breaking). backend = "libvirt" is no longer valid — the default dockur backend is QEMU/KVM in a container and now covers device passthrough too (#286), so the thin "bring-your-own libvirt domain" wrapper had no remaining justification. --backend accepts podman | docker | manual; an existing config with backend = "libvirt" falls back to podman on load (with a warning). The libvirt pip extra (libvirt-python) and the install.sh/AUR/RPM/DEB libvirt references are gone; winpodx[all] is now libvirt-free (no libvirt-dev build dependency). Run Windows in your own libvirt domain? Keep using winpodx ≤ 0.5.x, or point the manual backend at its RDP endpoint.

Added

  • A redesigned, Start-menu-style GUI — resource Dashboard home + left navigation sidebar (#460#471). The window is now a Start-menu shell: a left vertical navigation sidebar (one row per page, with the active page highlighted) and a new Dashboard home showing 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 moved to an "All apps" page. It also brings a unified design system, an in-house SVG icon set (replacing the old unicode-glyph "icons"), responsive layouts that reflow (stack columns, drop grid tiles) on narrow / fractionally-scaled windows, fit-to-screen window sizing, and a hero search that doubles as a command bar. The display name is now WinPodX across the UI, site, and docs (the lowercase winpodx command / package identifier is unchanged).
  • A project website — winpodx.org (#436#451). A landing page plus Features / Get-started / FAQ pages, deployed via GitHub Pages and localized to the same languages as the app.
  • Hide / show individual apps in the Linux menu (#319, #415). A Hide action in the GUI app library (grid + list) removes a Windows app from your Linux application menu; hidden apps persist in their profile and a Hidden toggle brings them back. CLI: winpodx app hide <name> / winpodx app show <name>.
  • winpodx pod recreate --keep-iso — reinstall Windows without re-downloading the ISO (#416). Wipes the Windows disk + install markers but keeps the cached install ISO, so dockur rebuilds from it instead of re-fetching ~58 GB from Microsoft (falls back to a normal download when no ISO is cached).
  • Host USB / PCI device passthrough to the Windows guest — CLI + GUI (#286). You can now hand a host device (a USB security dongle, a capture card, a non-GPU PCI card, …) to the Windows guest. Because the default backend is dockur (QEMU/KVM in a container), passthrough is wired at the QEMU layer — no libvirt needed. winpodx device list shows host USB/PCI devices and which are assigned; device attach <id> / detach <id> assign/release them (persisted in cfg.pod.devices), and a Devices tab in the GUI is a two-column host↔guest mover. USB hot-plugs live into the running guest (cfg.pod.usb_live, default on): device attach <usb> adds it with no restart by driving dockur's built-in QEMU -monitor (reached via <backend> exec ... bash -c '/dev/tcp') — no custom -qmp socket and no device_cgroup_rules (both crash-looped Windows boot on rootless Podman). usb_live just binds the host USB bus (/dev/bus/usb) into the container, which boots cleanly rootless; QEMU (root, via dockur) attaches the device to its existing qemu-xhci controller. Set usb_live = false to keep the USB bus out of the container. (USB mass storage also still works via the \\tsclient\media drive share.) PCI binds to vfio-pci, which can't be hot-plugged into a container QEMU, so it's boot-added and needs a guest restart, gated behind a safety check — risky devices (the primary GPU, the boot-disk controller, the active NIC) require an explicit --force (CLI) or a confirmation dialog (GUI), and the whole IOMMU group is flagged since it moves together. Device ids are hex-validated, so nothing dangerous reaches the generated compose / QEMU args.
  • System-tray USB switcher (#300). The tray menu now has a USB Devices submenu with a checkable entry per host USB device — tick it to redirect the device into the running guest, untick to hand it back to the host. It's the quick-access surface alongside the CLI (device attach/detach) and the GUI Devices tab: the toggle runs the persist + live usbredir attach/detach off the UI thread (so the tray never freezes, and a pkexec prompt can still appear), and the submenu rebuilds on each open so it tracks hot-plugged devices and the current assignment. The persist half is now a single shared helper (core.devices.assign_device / unassign_device) behind all three surfaces.
  • Multi-monitor RAIL now works by default — a remote app window keeps working input when dragged onto a second monitor (cfg.rdp.multimon, defaults to span). Without it, a RAIL app launch sizes the session desktop to a single monitor, so a window dragged onto a second monitor lands at host-virtual-screen coords outside that desktop — clicks miss, then stop registering entirely. winpodx now adds /span to RAIL app launches, sizing the session desktop to the bounding box of all host monitors (one wide rectangle, no per-monitor MonitorDefArray). /multimon was tried first but sends the full monitor layout, which the guest's rdprrap RAIL helper can't handle — it kills input outright — so the default is span, not multimon. Set cfg.rdp.multimon = "off" (or winpodx setup --multimon off) for non-rectangular layouts where the spanned bounding box leaves dead space; multimon is kept as a diagnosis-only value.
  • --extra-args / cfg.rdp.extra_flags now allow the multi-monitor + repaint knobs (/multimon, /multimon:force, /span, /gdi:sw|hw, /smart-sizing[:WxH], /monitors:0,1), each value-validated. These are the manual levers for the RAIL window-move corruption some multi-display setups hit (a remote app window blurs / breaks when dragged between monitors of different resolution or DPI). With cfg.rdp.multimon now defaulting to span (above), the input-loss case is handled out of the box; these flags remain for per-launch experimentation with the repaint / scaling knobs.
  • winpodx doctor --fix — idempotent auto-remediation for common findings (0.6.0 item K). winpodx doctor stays read-only by default (it prints suggested commands and never mutates state), but the new --fix flag turns each finding that has a known fixer into an automatic, idempotent repair: doctor collects findings, runs the registered fixer for every warn/fail finding, then re-probes that single check and reports [fixed] / [still failing] (findings with no fixer print [skip] no auto-fix available). Every fixer is a no-op when the underlying state is already healthy, so --fix is safe to run repeatedly. Four remediations ship: (1) dead agent — when the pod is RUNNING but the in-guest agent /health is down, doctor kicks the in-guest WinpodxAgentKeepAlive scheduled task (over the agent transport, which falls back to FreeRDP when the agent itself is unreachable) and polls /health until the agent is back; (2) stale lock files.cproc markers in ~/.local/share/winpodx/run/ whose owning PID is no longer a live FreeRDP process are purged (live sessions are left untouched); (3) missing desktop entries — apps present in the index but with no installed .desktop file are re-registered through the existing desktop-entry install path (entry + icon + MIME + cache refresh); (4) oem-version drift — when the host's oem_bundle stamp is newer than the guest's recorded version, doctor triggers guest_sync.maybe_autosync to push the refreshed guest scripts. --fix implies the slow container-health / guest-exec probes so the two guest-touching fixers are reachable. The stale-lock and missing-desktop-entry fixers are host-only; the dead-agent and oem-drift fixers touch the Windows guest. Without --fix, winpodx doctor behaviour is unchanged. See docs/design/ROADMAP-0.6.0.md item K.
  • Clean, interactive install progress (and install.sh --verbose for the raw firehose). The Windows first-boot wait used to dump hundreds of raw dockur/wget lines (…K …… 78% 4.55M 21m22s) plus UEFI BdsDxe: boot-loader noise. By default pod wait-ready --logs now shows the ISO download as a single self-erasing line that updates in place — Downloading Windows ISO [#########-----] 78% 4.55 MB/s ETA 21m22s — with a Windows is booting… heartbeat for the non-download phases and UEFI/mknod noise hidden; only real dockur milestones stay on screen. The live line is written straight to /dev/tty, so it animates on the terminal without polluting install.sh's tee-captured log (non-TTY consumers fall back to occasional percentage lines). install.sh --verbose / -v (or winpodx pod wait-ready --logs --verbose) streams the full raw container output instead. The installer also ends with a tidy summary box (version / backend / GUI / venv path) + concrete next-step commands. Deadline auto-extension on slow downloads is unchanged.
  • install.sh v2 — a pre-sudo system check, install modes, a mandatory private venv, and rollback on failure (resolves #271). The curl | bash installer now scans the host before touching sudo: it prints distro + version, presence/version of podman / docker / libvirt / freerdp / python3, whether /dev/kvm exists, and whether python3 -m venv actually works (Debian/Ubuntu split python3-venv/ensurepip out, so it can be missing). It flags podman older than major 4 as too old — dockur/winpodx need rootless group_add: keep-groups + modern compose, and Ubuntu 22.04 ships podman 3.4 (#271) — and suggests either upgrading podman (Kubic / devel:kubic:libcontainers) or using the Docker backend. It then offers four modes (preselect with --mode r|a|c|n / WINPODX_MODE for piped/non-interactive runs): [R]ecommended (today's behaviour — Podman backend, install all missing deps), [A]utomatic (reuse what's installed, pick an already-working docker/podman/libvirt backend, minimal sudo), [C]ustom (choose backend + GUI yes/no), [N]o (cancel cleanly, no changes). Two new flags feed winpodx setup: --backend podman|docker|libvirt|manual (WINPODX_BACKEND) and --no-gui (WINPODX_NO_GUI=1, headless — skips PySide6). Python now always runs from a private venv under ~/.local/bin/winpodx-app/.venv — the winpodx-run launcher execs the venv's interpreter instead of the system python3 + PYTHONPATH, so there's no --user/--break-system-packages system-python pollution and the reverse-open icon deps (cairosvg + pyxdg) install cleanly into the venv (no more distro-package best-effort). And a failed fresh install now rolls back — it removes only winpodx's own artifacts created that run (the venv, winpodx-run + winpodx launcher/symlink, desktop entry + icon, the in-progress marker), never system packages and never a pre-existing ~/.config/winpodx config; on an upgrade a failure leaves the working install untouched. --manual, --skip-deps, --source, --ref/--main, --win-version, and --image-tar all behave as before.
  • winpodx-git AUR package — install the latest main from source (#482, #483, #484). Alongside the stable winpodx AUR package (tagged release tarballs), a new winpodx-git VCS package builds from the GitHub main branch: yay -S winpodx-git, with the version derived from git so yay -Syu --devel rebuilds it whenever main moves. It provides/conflicts winpodx, so install one or the other — not both. Recipe + maintainer notes live in packaging/aur-git/; documented in docs/INSTALL.md.

Changed

  • GUI UX overhaul — clearer feedback, safer actions, less jargon. A broad pass over the desktop app (no new telemetry, no network, no bloat): a shared toast/notification + "busy" dialog + inline warning-callout + actionable-error layer (_widget_helpers) now backs consistent feedback everywhere. Highlights: app launches and device attach/detach show a non-blocking toast ("Launching …" → success/fail) instead of silent RDP spawns; the app library shows a context-specific empty state (Windows not running / no search match / all hidden / none discovered) and a unified grid-vs-list card layout (labelled Launch, consistent Show/Hide, confirmed delete), a labelled search box, a "+N more" overflow for categories beyond the first 8, and an "X of Y" count that reconciles with the status bar; refresh failures use an actionable dialog (Start pod / Retry / View logs); the bring-up dialog disables Cancel during the non-interruptible phases (with a tooltip) and shows honest per-phase ETA hints + a clear "✓ Ready" finish; the status banner now distinguishes "running but transport degraded" (RDP/agent unreachable) from "stopped", and a down agent that still has working FreeRDP fallback shows amber (not red); pod state is no longer triplicated across chip/banner/info-bar; Settings marks which controls apply immediately, surfaces a wipe/recreate warning callout with a time estimate before destructive saves, shows an always-on RAM-budget line, and adds inline help for the power-user knobs; long maintenance ops (grow-disk / sync-guest / debloat / apply-fixes) show a busy dialog with an ETA; PCI passthrough spells out in plain language which host devices the IOMMU group gives up; the Add-app dialog explains its fields and lightly validates the Windows executable path; keyboard shortcuts (Alt+N to switch pages, Ctrl+F to search) were added; and the License page links each third-party project. (Funding stays in the repo FUNDING.yml / README — never surfaced in the app.)
  • Windows apps are now grouped under a single "winpodx" menu folder instead of being scattered across native categories. Previously each installed app inherited its discovered categories (Office, Graphics, …) and landed loose among your native Linux apps; on a 50-app guest that buried the menu. winpodx now creates a dedicated winpodx (Windows Apps) submenu — the same mechanism Wine uses: a freedesktop .directory file names the folder, an applications-merged/winpodx.menu fragment maps a custom X-winpodx category into it, and every generated .desktop carries that one category. The folder is created on first app install and torn down when the last Windows app is removed (both idempotent + self-healing on winpodx app refresh). Honored by KDE Plasma, XFCE, Cinnamon, MATE and LXQt; GNOME's overview is a flat grid that ignores menu folders, so there the apps still appear but aren't grouped. Menu search still finds apps by name (Keywords=windows;winpodx;<name>). Re-run winpodx app refresh to migrate an existing install.
  • install.sh prints an install plan before it touches the system. Once the mode (R/A/C/N) and every dependency source are resolved, the installer shows a short plan that lists every major component evenlypython3, the venv probe, the container backend, FreeRDP, /dev/kvm, and the GUI — each with its detected state and the action this run will take (use existing / install / host-requirement), plus the exact packages it will install via the distro package manager and the Windows-VM provisioning steps — before any package install or sudo. The run is transparent instead of jumping straight from the mode prompt into installing things.
  • FreeRDP client source is now selectable; the launcher prefers the Flatpak client, with the native client as a fallback / opt-in (#269, #366, #393). Previously install.sh always installed the native freerdp3 package even when the Flatpak com.freerdp.FreeRDP was already there (redundant — #269). Now: (1) the launcher prefers the Flatpak com.freerdp.FreeRDP (core/rdp.find_freerdp auto order is flatpak-first) — it ships a self-contained FreeRDP 3+ with no host package skew. Its earlier RAIL multi-display rough edges (a remote app window losing input when dragged to a second monitor) are now handled by cfg.rdp.multimon = "span", so the Flatpak is viable as the preferred client; the native xfreerdp is the fallback when the Flatpak is absent, or when explicitly pinned via --freerdp-source native. (2) install.sh never installs a redundant client — if any FreeRDP (native or Flatpak) is already present it installs nothing; when none is present auto installs the native package (lightweight, no Flatpak-runtime pull), and the launcher's auto order then prefers an existing Flatpak only when one is actually present. (3) Custom install mode lets you choose each major dependency's source — container backend (podman/docker/libvirt), FreeRDP client (auto / native / flatpak), and the GUI — and winpodx setup --freerdp-source <auto|native|flatpak> persists the choice to cfg.rdp.freerdp_source (set native to pin the native client on hosts where the Flatpak sandbox is a problem).
  • Reverse-open refresh output now states its direction and the deprecation / summary plumbing is de-duplicated. winpodx host-open refresh prints a Reverse-open (host apps → Windows "Open with") header so its Discovered/staged/skipped counts aren't confused with the opposite-direction Windows-app discovery that runs just above it during provision, plus a pointer to winpodx host-open list for the staged set. Separately, the winpodx provision summary no longer dumps the raw apply_fixes dict repr (it renders a compact N/N fixes OK / k: v line), and the two byte-identical pod-deprecation-notice helpers (_deprecate_pod / _emit_deprecation) are collapsed to one.
  • install.sh --verbose now reaches the upgrade path too. install.sh --verbose (and WINPODX_VERBOSE=1) used to forward to winpodx provision --verbose on a fresh install but was silently dropped on the upgrade branch, because install.sh invoked winpodx migrate --non-interactive without a verbose flag and migrate's injected _rich_wait hardcoded verbose=False. winpodx migrate now accepts --verbose / -v, install.sh forwards $WINPODX_VERBOSE to both provision and migrate, and the migrate wait-ready stage honours the flag so an upgrade run streams the raw container firehose just like a fresh install. No behaviour change when --verbose is off (the clean self-erasing line stays the default for both paths).
  • agent_keepalive no longer fails the migrate apply burst on a post-restart agent flicker. During an upgrade winpodx migrate, the container restarts (OEM reboot pass) and the in-guest agent goes through the TermService cycle that rdprrap (re)activation triggers. A single /health OK during that window can be the agent momentarily up — it dies again right after, and the apply burst then hit a closed socket (agent_keepalive: channel failure: /exec socket error: Remote end closed connection without response, seen in 0.6.0 upgrade smoke). Two layers of defence now: (A) the require_agent settle stage waits for 3 consecutive /health OK probes (≈ up to 30 s) before proceeding, so the chain starts only once the agent has actually stabilised rather than on the first flicker; (B) _apply_via_transport — the shared channel used by every _apply_* runtime fix (agent_keepalive, oem_runtime_fixes, multi_session, vbs_launchers, max_sessions, rdp_timeouts) — now retries a TransportError (closed socket / /health timeout) twice with a 5 s backoff, re-dispatching each attempt so a recovered agent is re-picked (or a still-dead one falls to FreeRDP). A genuine rc != 0 from a payload that actually ran is still surfaced on the first try, unretried. No behaviour change on a healthy first try.
  • Provisioning unification (0.6.0 item B) restored four behaviours its first cut had homogenised away. Folding the four post-create paths into finish_provisioning had conformed everything to a silent wait + a single agent gate, dropping behaviours each path had accumulated for specific issues: (1) the dynamic wait — the live self-erasing progress line + wget-ETA deadline auto-extension for slow links (#126) — so a fresh install no longer shows one line then a silent multi-minute hang; it's restored via an injected wait_fn that routes the wait-ready stage through the rich pod wait-ready machinery. (2) agent-first install protection (#271): require_agent=True now exports WINPODX_REQUIRE_AGENT=1 across the apply + discovery stages (not just the one-shot settle re-probe), so discovery/apply defer instead of falling back to a FreeRDP RemoteApp connect that can kick install.bat's autologon session during first boot; a persistent agent-unavailable during discovery now defers cleanly to the pending machinery (exit 5) instead of recording a generic failure. (3) upgrade → winpodx migrate: install.sh now branches fresh vs upgrade — fresh runs provision --require-agent, upgrade runs migrate (which syncs the refreshed guest scripts / agent.ps1 into the existing guest, pins the image, then runs the same apply → discovery → reverse-open chain); the first cut ran provision for both and left upgraded guests on stale guest scripts. (4) the pending.resume "migrate" step now runs guest_sync too, so a deferred upgrade resumed later also refreshes guest scripts. migrate additionally gained the reverse-open stage (it's now the upgrade path's sole driver). See docs/design/PROVISION_UNIFY_FIDELITY_AUDIT.md.
  • Command taxonomy reorganised — guest, install, and doctor are the new canonical homes (0.6.0 item G). The pod junk-drawer carried 14 subcommands across three unrelated domains; the diagnostics surface had three partially-overlapping commands (info, check, doctor). As of this release: winpodx pod keeps only the lifecycle subcommands (start, stop, status, restart, recreate, wait-ready). winpodx guest is the new canonical home for guest-side operations: apply-fixes, sync (renamed from sync-guest), sync-password, multi-session, recover-oem. winpodx install is the new canonical home for install-progress and storage operations: status (from pod install-status), resume (from pod install-resume), grow-disk (from pod grow-disk), disk-usage (from pod disk-usage). winpodx doctor is the canonical diagnostic command; it gains two new flags: --json (machine-readable Finding list as a JSON array) and --quick (skip slow container-health / guest-exec probes, run only cheap local checks in < 1 s). winpodx info and winpodx check keep their exact current output and behaviour, but now print a one-line deprecation notice to stderr: [deprecated] 'winpodx info' will be removed in 0.7.0; use 'winpodx doctor' (and analogously for check). All old pod <x> subcommands remain registered and keep working through 0.6.x — they print the same deprecation pattern before delegating to the shared handler function; aliases are removed in 0.7.0. Shell scripts (install.sh, uninstall.sh, postinst, postrm, postrm-common.sh) contained no live invocations of the moved subcommands, so no update was needed. Full old→new command mapping: pod apply-fixesguest apply-fixes; pod sync-guestguest sync; pod sync-passwordguest sync-password; pod multi-sessionguest multi-session; pod recover-oemguest recover-oem; pod install-statusinstall status; pod install-resumeinstall resume; pod grow-diskinstall grow-disk; pod disk-usageinstall disk-usage; infodoctor; checkdoctor. See docs/design/ROADMAP-0.6.0.md item G. Deprecated aliases will be removed in 0.7.0.
  • Backend auto-selection unified through backend/select.choose_backend. Three places used to decide which container backend to use, and they disagreed on Ubuntu 22.04: install.sh's Automatic-mode picker walked podman → docker → libvirt and gated podman < 4 as unusable (#271), but cli/setup_cmd.py's non-interactive branch did a one-line "podman" if which("podman") else "docker" with no version gate — so the setup wizard happily picked the broken 3.4 podman the install path had just declined. choose_backend(prefer, deps, podman_min_major=4) is now the single Python source of truth: explicit --backend wins, otherwise walk AUTO_PRIORITY = ("podman", "docker", "libvirt") and the first usable wins (podman gated on podman_major_version() >= 4); fall back to "podman" when nothing is present so the recommended install path can install it. install.sh's bash picker stays as the one intentional shell mirror (same pattern as the pre-venv deps probe — it runs before Python is installed) with a comment pointing back at the Python SoT, and the test suite pins both copies to the same priority order + minimum major so they cannot silently drift. Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.md item E).
  • Host dependency detection unified through utils/deps.py:check_all. Pre-0.6.0 each consumer (utils/deps.py, core/deps_quickcheck.py, cli/doctor.py, cli/setup_cmd.py) carried its own shutil.which() list for FreeRDP binaries, and they had drifted — deps_quickcheck and doctor missed sdl-freerdp3 and the Flatpak fallback, so a host with only those reported MISSING in the GUI Quick Start and in winpodx doctor. check_all() now also reports /dev/kvm (via a new check_kvm()), so setup_cmd drops its inline KVM probe and doctor's _check_kvm delegates too. Every Python consumer now goes through check_all() / check_freerdp() / check_kvm(); the shell side of install.sh keeps a minimal pre-venv probe (genuinely shell-unique — it runs before Python exists) and that's the single intentional duplicate. A regression test pins it: an AST scan over deps_quickcheck.py and doctor.py fails CI the moment a hardcoded shutil.which("xfreerdp...") call comes back. Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.md item D).
  • Version + Windows-edition lists collapsed to a single source each. pyproject.toml is now the only place the project version is declared; src/winpodx/__init__.py derives __version__ via importlib.metadata.version("winpodx") (with a clearly-marked 0.0.0+source fallback for a non-installed source checkout), so a release-prep bump can't go missing from one of the two files. The curated Windows-edition list moved to a WIN_VERSION_LABELS dict in core/config.py; the CLI help text (winpodx setup --win-version), the interactive setup prompt, and the GUI Settings dropdown all derive from it, so adding a new edition is a one-line change. The packaging-version CI guard (scripts/ci/verify_versions.py) drops the obsolete __init__.py literal check, picks up packaging/rpm/winpodx.spec, and adds a round-trip assertion against importlib.metadata; the spec's stale Version: 0.1.5 literal is bumped to current and gains a pointer to the new guard. Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.md items F + N).
  • winpodx.toml now carries a schema_version marker (groundwork for future config migrations). The on-disk config file has changed shape across versions (renamed keys, moved sections) and we've been relying on _apply()'s "unknown keys are silently ignored" fallback. That works for adding fields but loses data the moment we rename or move one. Adding schema_version = 1 at the top of every saved file (and a _migrate_config(data, from_version) hook in core/config.py) gives a future 0.7.0+ rename a clean upgrade path without dropping user settings. The hook is intentionally a no-op today — 0.6.0 didn't change the layout — but the marker lands now so existing 0.5.x files get tagged on first load + save, and a later release that does restructure can transform them safely. A hand-edited file without the marker is treated as schema 0 (pre-0.6.0). Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.md item J).
  • Agent port 8765 is a single Python constant now. core/agent.AGENT_PORT is the single host-side source of truth; the compose template, the urlacl strings pushed into the guest, and the AgentClient URL all derive from it. The guest-side files (agent.ps1, install.bat, agent-keepalive.ps1, agent-respawn.ps1) keep their own literal because PowerShell can't import a Python constant — that pairing is now documented and locked by tests, and install.sh's /health curl carries a comment pointing back at the constant. Pure internal cleanup; no behaviour change. Part of the 0.6.0 consolidation work (see docs/design/ROADMAP-0.6.0.md item C).
  • The legacy FreeRDP host→guest fallback is now logged (groundwork for retiring it). Before the in-guest agent existed, host→guest commands ran PowerShell over a FreeRDP RemoteApp; that path still exists as the silent fallback when the agent isn't reachable. It was logged at debug, so there was no way to tell how often it actually fires in practice. Every fallback now logs a WARNING to winpodx.log tagged FreeRDP-fallback, with the reason (agent /health detail) and the operation that fell back. Count them with grep -c FreeRDP-fallback ~/.local/state/winpodx/winpodx.log. No behaviour change — this is measurement so we can decide, from real usage, how aggressively to strengthen agent recovery and shrink the fallback (rather than removing it blindly and risking breakage).
  • Logging hygiene pass — one consistent logger variable name across modules (0.6.0 item L). The reverse_open/ package and gui/reverse_open_panel.py defined their module logger as logger, while the other ~50 modules use log (both via logging.getLogger(__name__)); the call sites are now standardised on log. A one-pass audit of log levels confirmed they are already consistent — failures / fallbacks / refusals log at WARNING (the console threshold), routine milestones at INFO, and diagnostics at DEBUG — so no level changes were needed. Pure internal cleanup with no behaviour change: the logger name is the module path regardless of the Python variable, so log output is byte-for-byte identical. Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.md item L).
  • First-run discovery retries trimmed from 6 to 2 (0.6.0 item M). When the provisioning chain (winpodx provision, run by install.sh on a fresh install, and by the setup auto-provision path) scans the guest for installed apps, it retries discovery with exponential backoff if the guest agent is briefly not ready. The fixed 6× loop install.sh historically used predated the WinpodxAgentKeepAlive watchdog (#359); now that the keep-alive keeps the agent reliably up, 6 attempts is overkill and just slows a clean first run. The winpodx provision --retries default and the finish_provisioning(retries=...) default both drop to 2, so discovery does at most one 2 s-backoff retry before reporting. The migrate / pending.resume upgrade paths keep their existing retries=3 (deliberately set during item B and out of scope here). Override with winpodx provision --retries N if a slow guest needs more. Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.md item M).
  • Documentation refresh for 0.6.0 (0.6.0 item H). README.md, docs/USAGE.md, docs/ARCHITECTURE.md, docs/FEATURES.md, docs/INSTALL.md, and every matching docs/*.ko.md mirror now reflect the 0.6.0 shape: the new winpodx guest / winpodx install / winpodx doctor command surface (with pod <x> aliases noted as deprecated and removed in 0.7.0), the Thin AppImage redesign (only FreeRDP + Python + Qt + winpodx; host container runtime required; #357 / #363 root-caused), and winpodx provision as the single post-pod-running chain. The USAGE.md cheat-sheet splits the old "Pod management" block into three sections (pod lifecycle / guest ops / install ops) and the System section gains the new doctor flags (--json, --quick, --fix), provision, and migrate. The version blurb in both README.md files is rewritten for 0.6.0.

Fixed

  • UWP / Store apps now appear in the Linux taskbar (#472). A UWP app's visible frame is owned by ApplicationFrameHost and arrives over FreeRDP RAIL marked _NET_WM_STATE_SKIP_TASKBAR / SKIP_PAGER, so it stayed off the panel even though its window class already matched the launcher. winpodx now re-lists the window via wmctrl after launch (best-effort, X11 / XWayland; no-op otherwise).
  • Opening a host file whose path contains a space now works (#473). The UNC path (\\tsclient\home\…) handed to FreeRDP's RemoteApp command is now double-quoted, so the guest no longer splits it on spaces — previously the app received only the first token and reported "path not found".
  • A mixed-scale dual-monitor host no longer breaks app launch / leaves frozen windows (#474). On KDE Plasma 6 Wayland (XWayland) with two monitors at different fractional scales, the default /span multi-monitor desktop was doubly broken: the compositor's sub-pixel rounding leaves the monitors' logical rectangles non-tileable (a 1 px gap, mismatched heights, desktopScale: 0), so FreeRDP rejected /span at pre_connect (ERRCONNECT_PRE_CONNECT_FAILED) and nothing launched; and even when a session did come up, a RAIL window dragged onto the differently-scaled monitor froze and stopped taking input — FreeRDP RAIL + XWayland can't remap a window across per-monitor fractional scales (an upstream limit no client flag fixes). winpodx now detects the per-monitor scales (kscreen-doctor on KDE, swaymsg/hyprctl on wlroots) and, when they differ, pins the RemoteApp to the primary monitor — so the app launches and stays responsive there — and logs the one real fix for using both monitors together: set them to the same scale (or, on KDE, Display → Legacy Applications → Apply scaling themselves, which makes XWayland hand xfreerdp a uniform pixel grid). With uniform scales /span is used exactly as before (a window moves freely across monitors). Single monitor / undetectable scales are unaffected; cfg.rdp.multimon = "off" forces single-monitor regardless. As a connect-time backstop, a launch that still dies with that pre_connect monitor error retries on a single-monitor desktop.
  • Terminate a running RDP session from the GUI and the tray (#450, #452, #453). A Running sessions strip on the GUI Dashboard lists each live app session and terminates it on click; the tray gained a Terminate Session submenu. Terminating now signals the whole FreeRDP process group rather than just the leader (#458) and can force-kill a stuck session (#459); the tray also retries showing itself until the desktop's StatusNotifier host is up and re-asserts on its heartbeat so it survives a host restart (#455, #465).
  • The Dashboard resource centre now actually shows CPU / RAM / disk, and refreshes fast. Three problems compounded on real machines: (1) podman/docker stats was queried by the configured container name, but podman-compose prefixes it (winpodx_winpodx-windows_1), so the probe hit "no such container" and every gauge stayed blank — the name is now resolved via <cli> ps --filter name= first; (2) the CPU/RAM probe and the guest-disk probe ran sequentially, so a slow disk read stalled the whole snapshot — they now run concurrently with a hard cap, the disk is sampled on a slower cadence, and its last good value is cached instead of blanking between reads; (3) the disk probe fell back to a FreeRDP RemoteApp PowerShell that flashed a visible window in the guest on every poll — it's now agent-only (windowless /exec), so a passive dashboard never pops a console. Finally, rootless podman routinely reports CPU% (and sometimes MEM) as -- in stats, leaving those gauges empty even with a healthy pod; winpodx now back-fills them by reading the container's cgroup v2 files directly (memory.current for RAM, a cpu.stat usage_usec delta for CPU%), so CPU and RAM populate on the rootless setups where stats won't. Many rootless slices delegate only cpu/pids (not memory) to the user, so memory.current is absent even when cpu.stat reads fine — RAM then falls back to summing the VmRSS of every process in the container cgroup (kernel-accounted, controller-agnostic; the dockur QEMU process dominates), so the RAM gauge fills there too.
  • The Dashboard RAM gauge now reflects the guest's actual usage, and the panel paints faster. Every host-side memory read (cgroup memory.current or summed process VmRSS) reported the VM's memory — for the dockur (QEMU) backend the host sees nearly all the guest RAM resident, so the gauge sat pinned near 100 %. RAM now comes from inside Windows via the guest agent (Win32_OperatingSystem total/free physical memory), the only figure that reflects what the guest is really using; it shares the disk probe's single agent round-trip (one /exec for both) and slower cadence, and the last value is cached so it doesn't blank between polls. CPU stays host-side but now reads the cgroup cpu.stat delta first (an instant file read) and only falls back to the multi-second podman stats sampling, so the panel populates without that stall on every tick.
  • The Dashboard now auto-refreshes from the moment the window opens. The live-refresh timer was started only by the nav's page-switch handler, but the Dashboard is the default page shown at startup — no switch fires for it — so the panel painted once and then sat frozen until you navigated to another page and back. The timer now starts when the Dashboard is built, so resources and pod status update on their own from launch.
  • GUI pages no longer clip / grow a horizontal scrollbar on narrow or fractionally-scaled windows. The "All apps" Pinned/Recent shelves were a non-wrapping row, so a long shelf forced the whole page wider than the viewport and clipped the app grid on its right edge (worst on hi-DPI fractional-scale displays). Every page's scroll area now pins the horizontal scrollbar off, the tile grids budget a touch more width per tile so the last column can't overflow, and the Dashboard/All-apps shelves cap to the number of tiles that actually fit (everything stays reachable in the grid below) and re-flow on resize.
  • Settings combo boxes no longer change value when you scroll past them. Hovering a drop-down while scrolling the Settings page used to silently change its value (scale, backend, edition, …). Combo boxes / spin boxes now ignore the wheel unless they're focused — click one first to scroll its value — and the wheel falls through to scroll the page instead.
  • Upgrades restart the running GUI / tray so the new version takes effect immediately (#467). The installer gracefully restarts a running winpodx tray / GUI at the end of an upgrade (via setsid, so it outlives the installer); the pod keeps running. A no-op on fresh installs.
  • File sharing now works on Fedora Atomic / Silverblue / Kinoite (#418, #420). There /home is a symlink to /var/home, which made the UNC-path conversion report home-folder files as "outside shared locations". The home and media base paths are now resolved to their real targets before the comparison.
  • Setup verifies the container backend daemon is actually reachable, not just installed (#395, #419). A podman / docker CLI on PATH backed by a dead daemon (e.g. a stale DOCKER_HOST) used to pass the dependency check and then fail at pod start; the check now probes reachability and reports a clear DAEMON DOWN status.
  • manual backend now reaches the guest agent at the VM's address instead of loopback (#426). The agent client always targeted 127.0.0.1:8765, even when the Windows machine lived elsewhere — so on the manual backend pointed at a VM (e.g. a VMware guest at LTSC11P.local), RDP worked but every agent-backed feature fell back to FreeRDP-only, and winpodx check reported agent_health unreachable even though the agent answered fine at the VM address. AgentClient now derives its host from cfg.rdp.ip — the same address the RDP reachability check uses — so it follows the VM on manual and stays on 127.0.0.1 for podman/docker (where the container publishes 8765 to host loopback and cfg.rdp.ip is already loopback). No per-call-site changes; one fix in the client constructor.
  • Reverse-open's Windows shim is no longer quarantined by Microsoft Defender (#425). winpodx-reverse-open-shim.exe (a tiny, stripped, unsigned Rust binary) trips Defender's ML heuristic as Trojan:Win32/Rafvartar!rfn — a false positive. install.bat already excluded C:\OEM and C:\winpodx, but register-apps.ps1 stages the shim and its per-slug winpodx-<slug>.exe copies under C:\Users\Public\winpodx, which wasn't excluded — so Defender quarantined them and reverse-open silently broke. The first-boot Defender-exclusion step now also covers C:\Users\Public\winpodx and the winpodx-reverse-open-shim.exe process. The shim is also now built with an embedded Windows VERSIONINFO resource (CompanyName / ProductName / FileDescription / version via a winresource build script) — a metadata-less stripped PE scores worse on AV heuristics, so stamping real publisher info is a cheap legitimacy signal that the per-slug copies inherit. (Longer-term the shim should be Authenticode-signed / submitted to Microsoft as a false positive; the exclusion is the turnkey fix for winpodx's own guest.)
  • The Setup tab's "Enable reverse-open" checkbox now starts/stops the listener live (#425). Ticking the box only flipped cfg.reverse_open.enabled and refreshed the status label — the daemon stayed down until the next pod bringup, so users had to also click "Start daemon." The checkbox now starts the listener immediately when enabled and stops it when disabled (and persists the flag). It's best-effort + quiet: if the guest isn't up yet, the flag still persists and the listener comes up on the next pod start, with no error modal for that expected case.
  • Fresh install no longer hangs forever at wait-ready on hosts where dockur picks bridge-NAT networking (#269, #387). The agent on guest port 8765 was unreachable from the host after Windows booted — wait-ready polled it indefinitely while noVNC showed a finished desktop. Root cause: the compose set USER_PORTS: "8765" but left dockur to auto-select its network mode. On hosts where dockur's default bridge-NAT path succeeds (typically rootful backends), that path silently ignores USER_PORTS, so QEMU never forwarded 8765 to the guest (the Podman host↔container publish was fine; the container↔guest hop was the gap). dockur only consults USER_PORTS in its user-mode (passt) / slirp paths — which is exactly why rootless hosts were unaffected (rootless can't build the bridge, so dockur already fell back to passt and forwarded the port). The compose now pins NETWORK: "user" so dockur always uses the port-forwarding user-mode path regardless of host. winpodx only ever reaches the guest over forwarded ports (RDP 3389, the web viewer 8006, the agent 8765) and never needs the guest on the host LAN, so user-mode is the correct mode here; on rootless hosts the change is a no-op (they were already on passt).
  • install.sh no longer blindly proceeds with a too-old podman in Recommended mode (#271). Automatic mode already walked a podman < 4 host (Ubuntu 22.04 ships 3.4) down to docker / libvirt, but Recommended mode and an explicit --backend podman skipped that fallback — so the install ran to completion and only failed later at provisioning, after installing packages. A guard now runs right after the backend is resolved (before any package install): when the chosen backend is podman but podman is too old, an interactive run offers to switch to an installed docker / libvirt (or continue at your own risk / abort), and a non-interactive run exits cleanly without modifying the system, pointing at --backend docker|libvirt, a podman upgrade, or --allow-old-podman / WINPODX_ALLOW_OLD_PODMAN=1 to force. This completes the "graceful exit" part of #271 (the distro/version check and the runtime selector already shipped in the install.sh v2 rework).
  • Upgrades no longer prompt for an install mode or replay the old first-boot log. Two upgrade/re-run UX warts: (1) install.sh showed the [R]ecommended / [A]utomatic / [C]ustom / [N]o mode menu even when a winpodx config already existed — pointless, since an upgrade reuses the existing backend/config and runs migrate. It now detects the existing install and resolves to Automatic without prompting. (2) pod wait-ready --logs (used by the upgrade's migrate path) tailed podman logs --tail 100, which on an already-running container replays the original first-boot ISO-download + image-build output — under --verbose that looked alarmingly like Windows was being re-downloaded on every update. wait-ready now replays no history (--tail 0) when the pod is already up (RDP reachable), keeping --tail 100 only for a genuine fresh boot where the in-progress download is worth showing.
  • The Flatpak FreeRDP now does per-app RemoteApp instead of opening the full desktop. flatpak run com.freerdp.FreeRDP runs the app's default command — the SDL client, which has no RAIL (FreeRDP #9078) — so launching a single app via the Flatpak opened the whole Windows desktop / login screen instead of one app window. winpodx now invokes the Flatpak as flatpak run --command=xfreerdp … com.freerdp.FreeRDP, forcing the X11 xfreerdp binary (the only client with working RAIL), and grants the sandbox the holes every winpodx RDP flag needs so the Flatpak behaves like a native client: --share=network (localhost RDP), --socket=x11/--socket=wayland (RAIL + clipboard), --socket=pulseaudio (sound), --socket=cups (printer), --device=dri (display), and --filesystem=home + the removable-media mount roots (\\tsclient\home + \\tsclient\media drive redirection). Surfaced after 0.6.0 started preferring the Flatpak when present.
  • The install-mode menu (R/A/C/N) now works under curl … | bash. The interactive mode prompt — and the Custom-mode backend/GUI sub-prompts and the dependency-install confirm — were gated on [ -t 0 ] and read from stdin, so under the canonical curl … | bash install (where stdin is the script pipe, not a terminal) they were silently skipped and the install always defaulted to Recommended. install.sh now also detects a reachable controlling terminal via /dev/tty and reads every prompt from it, so a curl … | bash run in an attended terminal shows the mode menu and lets you choose (the same /dev/tty trick the live progress line already uses for output). Fully non-interactive runs (CI / cron / stdin and /dev/tty both absent) stay non-interactive and default to Recommended, and --mode / WINPODX_MODE still preselect without prompting.
  • Fresh-install provisioning now waits for a flaky guest agent until it actually recovers, instead of giving up on a timer. The agent-first settle gate and the discovery stage used to give up after a fixed window (a ~30 s settle ceiling; 2 discovery retries over a few seconds), so when the in-guest agent flickered down right after the first-boot reboot — it can lag up to a keepalive-watchdog cycle (~60 s) — provisioning deferred and the install only finished later via winpodx app refresh. Both stages now wait with no time cap, gated on pod liveness: as long as the pod is RUNNING, the WinpodxAgentKeepAlive watchdog will bring the agent back, so they poll /health and proceed once it is stably up (settle requires 3 consecutive OK) — completing discovery in-line however long the agent takes. The wait ends only if the pod itself stops (no watchdog → genuinely unrecoverable), the same real-signal-bounded philosophy as the wget-ETA dynamic deadline in pod wait-ready (#126) rather than an arbitrary duration. The bounded retry budget (--retries, 0.6.0 item M) now applies only to non-agent transient discovery errors. winpodx migrate (the upgrade path) gets the same patient recovery.
  • A deferred fresh install no longer rolls itself back. On a fresh install.sh run where Windows downloaded + booted fine but the agent-first discovery deferred (winpodx provision exit 5, e.g. the in-guest agent lagging a minute after the first-boot reboot), install.sh rolled back the entire install — throwing away ~15 minutes of ISO download + boot for a state that finishes itself. Root cause: bash fires the ERR trap on a failing pipeline even under set +e, so the rollback trap fired on the provision pipe before the rc-handling (which already treats a non-zero provision as "record pending, keep install") could run. The provision call now explicitly disarms the ERR trap (trap - ERR / re-arm after) instead of relying on set +e, and exit 4 (wait-ready ran long) / exit 5 (discovery deferred) are handled as an explicit deferred-not-failed case: the remaining steps are recorded as pending (they auto-resume on the next winpodx run, or winpodx app refresh) and the install is kept. A genuine early failure (deps / venv / container creation) still rolls back via the trap as before.
  • Install / provision now lists the apps it registered, not just a count. The discovery stage's shared _register_desktop_entries printed only Registered N app(s) in your desktop menu. — during winpodx provision / install.sh / winpodx migrate the user never saw which apps landed, even with --verbose. It now lists each registered app name (matching winpodx app refresh), and when the discovery count and the registered count differ — discovered apps that have no bundled profile can't be registered — it prints a one-line Note: N discovered app(s) had no bundled profile and were not added to the menu: … so the gap is explained instead of silent.
  • migrate --verbose reached only one of its two apply paths (regression of the just-shipped verbose fix). run_migrate's cross-version upgrade branch called _apply_runtime_fixes_to_existing_guest(non_interactive) without verbose=verbose, so on a real version-crossing upgrade — exactly the path install.sh drives with migrate --non-interactive --verbose — the raw container firehose was still dropped (the --verbose local was dead on that branch). Both call sites now thread verbose=verbose.
  • The Windows-exec wrapper script is no longer written world-readable. run_in_windows() wrote ~/.local/share/winpodx/windows-exec/<desc>.ps1 (and created its directory) at the process umask (commonly 0644 / 0755). On the FreeRDP-fallback password-rotation path that script embeds the freshly-rotated cleartext Windows password, so it was briefly persisted readable by any other local user. The directory is now 0700 and the script 0600, matching how the agent token, compose.yaml, config, and the rotation marker are already written.
  • The .cproc session lock is no longer briefly truncated to empty while a session launches. launch_app opened the PID lock file with mode "w" (truncating it to zero bytes) before the FreeRDP child PID was known, leaving a window where a concurrent reader — list_active_sessions / the idle monitor — could see an empty file and unlink the live session's lock as corrupt. The lock fd is now opened without O_TRUNC; the PID is written as a single ftruncate+write+fsync mutation under flock once the child exists, so a reader only ever sees the prior or the new PID, never empty.
  • Discovered Windows executables with a comma in the path can no longer inject a FreeRDP /app: sub-key. The FreeRDP-3 /app:program:<exe>,name:<name> arg interpolated app_executable raw, while the adjacent default_args and the UWP AUMID were already sanitised/validated against exactly this. app_executable now gets the same comma→space treatment before the combined arg is built.
  • A failed pod resume now fails fast with a clear message instead of a long opaque RDP timeout. ensure_pod_awake ignored resume_pod's result, so a pod that didn't actually unpause was handed straight to launch_app. It now re-checks is_pod_paused after the resume attempt and raises ProvisionError("failed to resume paused pod …") when the pod is still paused.
  • First-boot discovery keeps a retry that fixes the UWP gap even when the total app count is unchanged. discover_apps's retry-on-empty only kept the retry result when it had strictly more apps total, so an equal-size retry that recovered the missing UWP (Store) apps — the exact signal that triggered the retry — was discarded. It now also keeps the retry when it has more UWP entries.
  • The AppImage no longer ships a bundled container stack that fights the host — Thin AppImage redesign (#357, #363). The pre-0.6.0 fat AppImage bundled the entire podman stack (podman + podman-compose + conmon/crun/netavark/aardvark-dns/pasta/passt/slirp4netns + transitive .so deps) into ${APPDIR}/usr/bin and ${APPDIR}/usr/lib, with the entrypoint prepending both to PATH and LD_LIBRARY_PATH. That broke every host that already had a working podman: #357, Ubuntu 26.04podman-compose resolved to the bundled copy, which probed for the bundled podman (can't run standalone: no host /etc/containers config, no subuid/subgid, no systemd integration) and died with it seems that you do not have podman installed, hiding the host's working podman 5.7 + podman-compose 1.5; #363, Fedora Bluefin — podman shelled out to the host systemd-run for rootless aardvark-dns, but the prepended ${APPDIR}/usr/lib forced that host binary to load the AppImage's bundled libcrypto.so.3OPENSSL_3.4.0 not found (required by host libsystemd-shared), so aardvark-dns failed and container start died. PR #365's _hostenv helper patched around the symptoms; 0.6.0 item A removes the root cause by dropping the entire container stack from the AppImage. The AppImage now bundles only what is safe to bundle (FreeRDP 3, Python, Qt, winpodx — leaf binaries that don't spawn host helpers) and requires a host container runtime (podman recommended, docker / libvirt supported) installed via the distro package manager, same model as install.sh. Dropping the container stack on its own only saved ~20 MB, though (≈296 MB fat → ≈274 MB): the real bulk is PySide6, which bundles the entire Qt6 stack (QtWebEngine alone is ~195 MB) while winpodx links only QtCore/QtGui/QtWidgets/QtSvg/QtDBus, so a companion pass (packaging/appimage/slim-pyside6.sh) strips the unused Qt6 modules + their plugins/resources + the now-orphaned FFmpeg libs, bringing the AppImage to ~110 MB. _hostenv collapses to a single job — strip ${APPDIR} from LD_LIBRARY_PATH for every container-backend subprocess so the host runtime + the host helpers it spawns load HOST libcrypto / libssl, not the bundled ones still on the AppImage's own LD_LIBRARY_PATH (the #363 mitigation that survives because bundled FreeRDP / Python / Qt still need those bundled libs). host_path() and resolve_backend_bin() are gone — nothing in ${APPDIR}/usr/bin shadows the host container runtime anymore, so standard subprocess PATH resolution finds the host podman / docker directly. 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. #357 and #363 are root-cause-fixed but stay open until the original reporters smoke-verify the rebuilt AppImage on the next 0.6.0 release. Outside an AppImage this is a strict no-op (same binaries, environment inherited unchanged), so the ~99% of non-AppImage installs are byte-for-byte unaffected. See docs/design/ROADMAP-0.6.0.md item A.
  • The guest agent now survives session churn instead of dying until the next reboot. The in-guest HTTP agent (agent.ps1, the :8765 listener the host uses for windowless /exec) had exactly one autostart: an HKCU\Run entry that fires once per interactive logon, with the agent running as a child of the autologon session. When that session was torn down — RDP single-session enforcement kicking it when a FreeRDP connection arrives before rdprrap multi-session is active, or the TermService cycle during rdprrap (re)activation — the agent died with the session and HKCU\Run did not re-fire, so the agent stayed dead until a pod reboot (/health timed out on a pod up for hours; pod restart revived it). A new WinpodxAgentKeepAlive scheduled task is now the persistent watchdog HKCU\Run never was: it runs an idempotent agent-keepalive.ps1 AtLogOn and every 1 minute, which (re)launches the agent via the existing hidden-launcher.vbs wrapper (no console flash) only when no agent.ps1 process is running — it never kills a healthy agent. The task runs as the interactive autologon user (not SYSTEM/S4U) so the agent's /exec keeps the user's HKCU + Start Menu context that app discovery and per-user reverse-open registration depend on; the World-SID :8765 urlacl reservation and the World-readable C:\OEM\agent_token.txt stay reachable unchanged. It's registered at OEM time (install.bat, OEM bundle bumped 25 → 26) and staged by the apply chain (winpodx pod apply-fixes / migrate / guest-sync) so already-provisioned pods get it without a container recreate. A crashed-but-session-alive agent is back within ~1 min; the case of a session kick with no re-logon is prevented by the already-idempotent rdprrap activation (the keep-alive's 1-minute repetition is also the backstop after a TermService cycle settles).
  • install.sh now installs the reverse-open icon dependencies, so far fewer apps get a placeholder icon. The curl | bash installer runs winpodx under the system python3 without a venv, so winpodx's declared icon deps — cairosvg (SVG → PNG) and pyxdg (full freedesktop icon-theme resolution) — weren't present, and every Linux app whose icon is an SVG or lives in a non-Hicolor theme (Papirus, breeze, …) fell back to a generic placeholder in the Windows "Open with" menu (e.g. 13 of 18 placeholders in one smoke were just missing cairosvg). The installer now installs python3-cairosvg + python3-pyxdg via the distro package manager when they're missing — best-effort and non-fatal (apps still launch; the feature degrades to a placeholder if the packages or sudo aren't available). The AppImage already bundles both, so it was never affected.
  • Guest sync no longer flashes console windows or strands the post-upgrade chain. When guest sync first actually fires (e.g. a 0.5.8 → 0.5.9 upgrade — stamp older than host), its OEM-pull / urlacl / agent-restart steps went through FreeRDP RemoteApp (run_in_windows), which pops a visible PowerShell/console window per call — the console-flash regression. They now ride the windowless agent /exec channel (run_via_transport) like the rest of guest sync; the agent is up for all three (the restart's /exec returns before its scheduled task kills the agent). Separately, the agent restart was fire-and-forget, so the install's downstream work (migrate apply chain, app discovery, reverse-open) raced the relaunch, found the agent unreachable, and degraded to a pending-resume. sync_guest now waits — generously, bounded — for the agent's /health to answer again after the restart before returning, so those steps see a live agent. Pending-resume stays the backstop if the relaunch is unusually slow.
  • Reverse-open icons that only ship as a multi-colour XPM (e.g. veracrypt) no longer fall back to a blank placeholder. Pillow's bundled XPM decoder only handles one char per pixel (≤256 colours), so veracrypt's icon — its only icon is /usr/share/pixmaps/veracrypt.xpm at 1770 colours / 2 chars per pixel — raised KeyError and the app got a generic placeholder in the Windows "Open with" menu. winpodx now decodes such XPMs with a small pure-Python reader (parse the colour table + pixel rows, build the RGBA image directly), so those apps get their real icon. No new dependency and no external tool — it works identically on every install method including the AppImage. The remaining placeholder case is genuinely unavoidable (an app that ships no icon at all).

Contributors

Thanks to everyone who reported the issues addressed in this release: @ismikes (#269, #357, #387, #393, #450), @vlombardino (#271), @vkkindia (#286), @vw72 (#319), @jmayniac (#363), @MirzaAyBaig12 (#366), @urbantigerau (#395), @notnotno (#418, #425), @sundaysfantasy (#426), @mhmdzaien (#473), @nemonein (#474) — and @ismikes for a code contribution (#423).

[0.5.9] - 2026-05-27

Highlights

Multilingual UI, self-growing disk, and a guest that updates without a reinstall.

  • The whole tray / GUI / CLI is now translatable and fully translated to 7 languages (en/ko/zh/ja/de/fr/it) — picked from your system locale, switchable with winpodx language or the GUI dropdown (fixes the stray-Korean reports, #335).
  • The Windows C: drive grows on its own when it fills up — and can be grown by hand with winpodx pod grow-disk — including past the WinRE recovery partition dockur leaves behind (#318).
  • Guest sync: upgrading winpodx on the host now pushes the new agent / fixes / binaries into a running guest with no wipe-reinstall (winpodx pod sync-guest, automatic on pod start).
  • Pod auto-start on login is now a real, opt-in feature (winpodx autostart on).
  • Fixes: USB shortcut erroring with no media mounted, RemoteApp painting the logon screen (#332), AppImage podman-compose ModuleNotFoundError on atomic distros (#322), and a too-tight install agent-wait that gave up early.

Added

  • Selectable, fully translated UI — 7 languages (en/ko/zh/ja/de/fr/it). winpodx's own tray / GUI / CLI text is now translatable, and all ~814 user-facing strings are wrapped and fully translated to all six non-English languages (placeholders, backtick command snippets, and product names preserved verbatim). [ui] language (default auto) picks the language from the host locale ($LANG) and falls back to English; override it from the CLI (winpodx language ko, or winpodx language to show current) or the GUI Settings → winpodx UI language dropdown (applies on next launch). English is the source/baseline — always 100% complete — and any string a future language hasn't translated falls back to English (no blanks). Catalogs live at winpodx/locale/<lang>.json as flat {english: translation} maps, so adding a language is a file drop with no code change. Addresses the stray-Korean reports (#335).
  • Pod auto-start on login (opt-in). cfg.pod.auto_start was a stored-but-unused flag; now it works — but it's off by default and explicitly opt-in (booting Windows on every login is heavy, so a plain install never forces it). Turn it on with winpodx autostart on (or the GUI Settings checkbox): that installs the tray autostart entry and sets auto_start, so the tray — launched on login — starts the pod (or resumes it if suspended). winpodx autostart off / status manage it. Background + best-effort (a failure just leaves the pod stopped, logged; never crashes the tray); no-op when already running.
  • Guest sync — apply host updates to a running guest without a reinstall. Upgrading winpodx on the host used to leave the guest's agent.ps1, urlacl reservation (#269), rdprrap/shim binaries, and registry fixes stale until a wipe-reinstall. /oem is a live bind mount of the host's config/oem, so after a host upgrade the container already has the new files; winpodx now delivers them into the running guest (same channel as pod recover-oem, but automated over the agent /exec), re-applies the idempotent fixes, restarts the agent (via a one-shot scheduled task so it doesn't kill the /exec it's serving), and stamps the guest version (C:\winpodx\install-state\guest_version.json). Runs automatically once per pod start when the guest stamp is older than the host (guest_autosync, default on); also triggered by winpodx migrate (so a package / AppImage / flatpak upgrade — whose update path never runs install.sh — gets the full guest refresh from a single command), manual via winpodx pod sync-guest [--force], and a GUI Tools → Sync Guest action. No agent change. See docs/design/GUEST_SYNC_DESIGN.md.
  • Windows disk auto-grow + manual grow (#318). The Windows C: drive now grows on its own: when the system volume fills past disk_autogrow_threshold_pct (default 80%) and the pod is idle, winpodx grows the virtual disk enough to restore disk_autogrow_target_free_pct free space (default 30%, in whole disk_autogrow_increment steps — not a flat bump), recreates the container so dockur grows the image, then extends C: to fill via Resize-Partition — no manual Disk Management step. The grow is bounded by the host's free space (minus a safety reserve); disk_max_size is now an optional explicit ceiling (empty by default) rather than a fixed cap. The same operation is exposed manually: winpodx pod grow-disk (add one increment), winpodx pod grow-disk 128G (absolute target), winpodx pod grow-disk --extend-only (just extend C: into existing unallocated space), and winpodx pod disk-usage (size / free / used%). The GUI Tools page gains a Grow Disk action. Auto-grow runs only while idle so it never interrupts a live RemoteApp session, and the guest-side work reuses the agent /exec path (no agent change). Default-on; set disk_autogrow = false to manage size manually. Note: dockur has no online disk resize, so each grow recreates the container (a quick guest reboot) — winpodx schedules auto-grows during idle for exactly this reason. dockur's Windows layout puts a WinRE Recovery partition right after C:, so the grown space lands behind it and a plain Resize-Partition can't reach it; the extend step detects this, detaches WinRE, removes the blocking recovery partition, extends C: to fill, and re-enables WinRE (in C:\Windows when no dedicated partition remains). Reported by @drjwhitty (Linux Mint 22.2).

Fixed

  • Upgrade re-install hung at [4/4] waiting for the OEM reboot pass. Re-running install.sh over an already-provisioned guest froze pod wait-ready --logs at phase 4. Two causes: (1) --logs --tail 100 replayed the original first-boot download log, and a stale wget ETA line inflated the wait deadline to tens of minutes; (2) phase 4 (_wait_for_oem_reboot) blocked for that whole inflated window when the agent was still transitioning, because the "marker never reappears → this is an upgrade" exit only fired on a clean probe, never while the agent was rejecting connections. Fixes: the deadline no longer extends once the container is running (any ETA line after that is replayed history); phase 4 is hard-capped at 180s (it's a marker poll, not a download); and the upgrade-path exit now triggers on the appear-grace clock regardless of probe outcome.
  • USB desktop shortcut errored when no removable media was mounted. install.bat always drops a USB shortcut pointing at \\tsclient\media, but winpodx only redirected that drive when a removable-media base (/run/media/$USER etc.) existed — so clicking USB with nothing plugged in failed with "\\tsclient\media is not accessible … Attempt to access invalid address." winpodx now always redirects a media drive: the real base when mounted, otherwise an empty placeholder dir, so the shortcut opens to an empty folder instead of erroring.
  • Stray Korean text in the UI (#335). The tray "Quit" confirmation dialog and the CPU/RAM tier preset labels (Low/Mid/High) were hardcoded in Korean, so an English-locale user got a Korean prompt on exit. Both are now English (winpodx's UI is English; a full selectable-UI-language i18n layer is planned separately). Reported by @camegone (CachyOS).
  • Install: agent-readiness wait is now dynamic + generous, with no misleading errors. On a slow first boot (cold cache / slow disk / a long ISO download that pushes the whole install late) the post-RDP agent /health wait was capped at a hard 180s, so it gave up early — leaking a scary agent didn't answer within 180s WARN and then skipping apply-fixes / discovery even though the agent was just minutes from coming up. The wait now honors the caller's full (dynamic, ISO-ETA-extended) deadline — pod wait-ready phase 3 already passes the auto-extending deadline, so the agent gets as long as the install legitimately needs. Separately, the guest version stamp read/write now go through the windowless agent /exec channel (run_via_transport) instead of FreeRDP (#346) — so a transitioning agent on a fresh first boot fails clean and the stamp is re-attempted on the next pod start, rather than leaking a scary FreeRDP timed out after 30s / ERRCONNECT_ACTIVATION_TIMEOUT warning mid-install. The stamp is best-effort and surfaces at info level ("deferred; will retry next start"), never a warning. The post-install app-discovery retry budget was also raised from 3 to 6 attempts (~50s) so it outlasts the apply chain's TermService/rdprrap cycle instead of falling through to pending-resume on the next launch.
  • First-run setup prompt fired on every invocation (#341). "winpodx has not been set up yet" is gated on an internal initialized flag, not on the config file existing. The non-interactive existing-config path printed "skipping setup" and returned before flipping that flag, so a config with it still false (created via install.sh's own skip path, or written before the flag existed) made the prompt loop forever — pick "Auto", setup short-circuits, prompt returns next time. The skip path now marks the install initialized too. Reported by @ntruhan (Fedora 44).
  • RemoteApp launched into the logon/lock screen → corrupted render (#332). Apps sometimes came up showing the Windows login background instead of the app, with xf_Pointer: Invalid appWindow spam — the app had launched, but FreeRDP's RAIL window was created while the guest session was still transitioning through the logon/lock screen (dockur's autologon session briefly re-spawns), so FreeRDP painted the stale logon framebuffer and never repainted the app. Version-independent (reproduced on current FreeRDP). winpodx now waits — best-effort, agent-only so it never flashes a FreeRDP probe window — until the guest console is interactive (explorer.exe up, no LogonUI.exe) before firing the RemoteApp connection; if the agent is unreachable or it times out, the launch proceeds as before. Reported by @tolistim (Mint 22.3).
  • AppImage: podman-compose ModuleNotFoundError (#322). The fat AppImage bundled Fedora's /usr/bin/podman-compose launcher script but not the podman_compose Python module it imports (it's pure Python, so ldd can't pull it in), and the script's shebang fell through to the host's python3 — which lacks the module on atomic distros. Pod creation failed with ModuleNotFoundError: No module named 'podman_compose'. Now the build pip-installs podman-compose into the AppImage's bundled interpreter and ships a usr/bin/podman-compose wrapper that runs it via python3 -m podman_compose (a pip console script can't be used directly — its shebang is the dead build-time path, same reason AppRun uses python3 -m winpodx). Reported by @jmayniac (Bluefin / Fedora atomic).

[0.5.8] - 2026-05-24

Reliability + reach release: fixes the two big fresh-install failures users reported (#269 agent can't bind 8765, #287 install.bat never runs), ships a distro-agnostic fat AppImage, and rounds out the setup wizard so a standalone winpodx setup provisions end-to-end.

Highlights

Fresh-install reliability + a self-contained AppImage. Two fresh-install dead-ends fixed, slow connections + slow hosts no longer time out, and a single-file AppImage now bundles FreeRDP + Podman for immutable distros.

  • #269 fixed -- the agent's http://+:8765/ urlacl reservation is now created for the World SID so the non-admin guest User can bind it; agent.ps1 retries + logs the urlacl state on failure (thanks @ismikes).
  • Fat AppImage (#227) -- winpodx-fat-x86_64.AppImage bundles Python + Qt + FreeRDP + Podman + podman-compose; winpodx setup-host handles the host-side kvm-group / subuid bits via a single pkexec prompt (requested by @leandromqrs).
  • Dynamic + generous timeouts (#126) -- pod wait-ready auto-extends from dockur's download ETA, compose-up is activity-based, and every fixed timeout was widened so slow links / hosts don't fail mid-op (reported by @xiyeming).
  • winpodx pod recover-oem (#287) -- re-stages C:\OEM + runs install.bat when dockur's first-boot OEM copy fails (driver @ankranidiotis).
  • Setup wizard completion (#255) -- --customize walks edition / language / region / keyboard / tuning, and a standalone winpodx setup now runs the full provision flow (wait-ready + discovery + reverse-open) instead of stopping at "container created".
  • podman-compose required up-front (#288) -- no more silent keep-groups failure when podman compose delegates to docker-compose (reported by @magicdiablo).

Added

  • winpodx pod recover-oem -- manual OEM recovery for failed dockur first-boot copy (#287). When dockur's $OEM$ -> C:\OEM copy silently fails (so install.bat never runs, the agent never installs, and port 8765 RSTs), this command tars /oem out of the container, serves it over a short-lived HTTP server on container port 8766, and prints the exact noVNC PowerShell to download + extract + run install.bat from the full OEM tree. podman/docker only. Driver: @ankranidiotis (Linux Mint 22, Podman 4.9.3).
  • pod wait-ready dynamic deadline + winpodx setup-host (#126, #227). pod wait-ready parses dockur's wget ETA and extends its deadline (no upper bound -- a genuinely stalled download stops producing ETA lines and the wait expires naturally; +60min slack absorbs blips) so slow ISO downloads don't trip the 60min default. winpodx setup-host (also python -m winpodx.setup_wizard) detects + pkexec-fixes the host bits an AppImage can't do from user space: kvm group membership, /etc/subuid + /etc/subgid for rootless podman, persistent kvm module load. Reported by @xiyeming.
  • Fat AppImage with bundled FreeRDP + Podman + pkexec wizard (#227). Initial lean AppImage in #302 (Python + winpodx + Qt only) layered up to a fat bundle in this release: packaging/appimage/ recipe downloads a python-build-standalone portable Python 3.11, installs the winpodx wheel + gui + reverse-open extras into it, then overlays a Fedora 41 binary bundle (xfreerdp / wlfreerdp / sdl-freerdp, podman, podman-compose, conmon, crun, netavark, slirp4netns, passt, pasta) with all transitive .so deps that aren't on the host-critical exclude list (glibc / libX11 / libGL / libwayland / libxkbcommon stay on host -- bundling them would break desktop integration or hard-crash on glibc mismatch). Single winpodx-fat-x86_64.AppImage (~290 MB squashfs / ~920 MB AppDir) self-contained for everything user-space can carry. New winpodx setup-host subcommand (plus python -m winpodx.setup_wizard) wraps the host-side bits that AppImages legitimately can't do from user space -- kvm group membership, /etc/subuid + /etc/subgid for rootless podman, persistent kvm module load -- in a single pkexec polkit prompt. Target audience: immutable distros (Fedora Silverblue / Kinoite / Aeon, Steam Deck) where layering system packages is heavy or unavailable, plus locked-down environments where curl install.sh | bash isn't an option. CI bypasses python-appimage entirely (its bundled appimagetool runs without honouring our PATH wrapper and silently fails on FUSE-less GitHub runners); the new pipeline builds the AppDir layout from scratch, layers the binary overlay via a Fedora 41 docker container, and packs with a preinstalled extract-and-wrap appimagetool at /usr/local/bin. The bundled third-party license texts (FreeRDP / Podman / podman-compose / ...) are vendored in packaging/appimage/licenses/ so they travel inside the AppImage independent of CI's dnf nodocs policy, and the build fail-closes if a required dir is absent. README + INSTALL.md (en + ko) updated. Driver: @leandromqrs.
  • Setup wizard completion -- --customize prompts + full standalone provision (#255). The wizard now prompts for Windows edition, UI language, regional format, keyboard layout, and tuning profile (previously CLI-skipped, GUI-only). A standalone winpodx setup runs the same post-create flow install.sh drives -- wait-ready, apply-fixes, app discovery, reverse-open -- so it finishes like a complete install. New --create-only flag keeps install.sh's create-then-orchestrate path intact.
  • CLI + GUI first-run setup prompt (#255 PR 1). When cfg.pod.initialized is False (or no config exists), the first winpodx <cmd> invocation surfaces a three-way prompt: [Y]es (auto -- host-detected defaults, no prompts), [C]ustom (wizard -- pick every knob), [n]o (skip). Skip-list bypasses introspection / config / uninstall / gui / tray commands and non-TTY stdin. GUI shows the same modal on first launch. After a successful setup, initialized flips to True and the prompt stops firing.
  • winpodx setup --customize flag (#255 PR 1). Opts into the wizard mode (existing interactive prompts; full multi-step wizard with debloat / tuning / anti-detection knobs lands in PR 7). Default winpodx setup is now non-interactive (host-detected defaults).
  • install.sh --manual flag (#255 PR 2). Skips winpodx setup + pod wait-ready + app discovery + reverse-open setup. Binary + desktop entry + icon land normally; provisioning is deferred to the first-run prompt that fires on the next winpodx invocation (CLI Y/C/n or GUI modal). Useful when you want to pick custom knobs (edition / language / debloat / tuning / anti-detection) without sitting through the auto path first. Equivalent: WINPODX_MANUAL=1 env var. docs/INSTALL.md + Korean mirror gain a "Manual install" section.
  • winpodx doctor subcommand (#255 PR 6). Read-only diagnostic. Walks ~9 checks (install source detected, freerdp present, /dev/kvm present, backend on PATH, config + binary state, container health, pending setup marker, autostart entry integrity, cfg.pod.initialized flag) and prints per-check [OK] / [WARN] / [FAIL] findings with the suggested next command. Read-only -- never mutates state; users copy the suggestions. Exit 1 when any FAIL finding; useful in CI / install scripts as a quick health gate. Designed to be cheap (< 2s on a healthy install): every subprocess probe has a short timeout, no network calls.
  • winpodx setup registers the GUI launcher .desktop (#255 G7). Standalone winpodx setup invocations (pip install, dev checkout, or any path where install.sh / packaging didn't drop the GUI entry) now copy data/winpodx.desktop to ~/.local/share/applications/winpodx.desktop, mirroring what install.sh does for curl installs. Skips when /usr/share/applications/winpodx.desktop already exists -- package installs own that path and a user-level copy would shadow the package version on upgrade. install_gui_launcher_desktop() lives next to install_winpodx_icon() in winpodx.desktop.icons.
  • winpodx uninstall consolidated to a single bash script (#255 follow-up). The pre-this-PR #255 PR 3 landed a ~470-line Python implementation in winpodx.cli.uninstall alongside the existing ~300-line uninstall.sh. Two parallel implementations drifted (10+ cleanup steps diverged: MIME cache, gtk-update-icon-cache, guest-registry scrub, final pkill sweep, volume glob, storage bind-mount wipe, GUI launcher .desktop removal, ...), repeatedly producing bin/winpodx-symlink-survives-purge bugs (#278 / #279). Consolidation: uninstall.sh is now the canonical implementation. winpodx uninstall is a ~50-line Python wrapper that locates uninstall.sh (in /usr/share/winpodx/, ~/.local/bin/winpodx-app/, ~/.local/share/winpodx/, sys.prefix/share/winpodx/, or the dev checkout) and os.execvp's it -- so uninstall keeps working even when the Python install is half-broken. The same uninstall.sh now ships in every channel (debian/winpodx.install, rpm %files, AUR PKGBUILD package(), pyproject shared-data for pip wheels, curl install bundle dir) at one well-known path: /usr/share/winpodx/uninstall.sh. Install-source detection (dpkg / rpm / pacman) moved into bash; if a package manager owns the binary, the script prompts to run sudo apt remove / sudo dnf remove / sudo pacman -Rns first, then re-enters the same script via the package's postrm hook with --from-postrm to do user-side cleanup -- the only ordering that keeps dpkg/rpm db consistent with disk state. postrm-common.sh simplified: delegates to /usr/share/winpodx/uninstall.sh --from-postrm --yes [--purge] instead of calling the Python CLI. Flags normalised: --yes (was --confirm -- kept as silent alias), --purge, --from-postrm (internal, set by postrm hooks). The --no-package-prompt flag introduced by PR 3 is removed (its behaviour folds into --from-postrm). Tests: deleted tests/test_uninstall_consolidation.py (Python helpers no longer exist); added tests/test_uninstall_wrapper.py (10 tests: candidate-path coverage, execvp argv forwarding, SystemExit on script-not-found) and tests/uninstall_smoke.sh (sandbox $HOME smoke that asserts every expected path is gone post-run, shellcheck-clean).
  • uninstall.sh detects pip / source installs (#255 G6). Previously fell through to unknown -- pip users saw no hint. Now identifies site-packages or dev-checkout paths and prints pip uninstall winpodx after user-side cleanup as the canonical removal command for that install kind. Detection is heuristic only (the script doesn't know the venv prefix to drive pip uninstall directly), so the hint is informational, not auto-exec'd.
  • Packaging post-install / post-remove hooks across debian / rpm / aur (#255 PR 4). Every package install path now ends with a banner pointing at winpodx setup (or just opening winpodx -- first-run prompt covers it). Every package remove path runs a tiny cleanup that pkill's tray / GUI / helper processes + stops the reverse-open listener via winpodx host-open stop-listener, so users aren't left with orphan processes after apt remove / dnf remove / yay -Rns. debian's apt purge additionally runs winpodx uninstall --purge --yes --no-package-prompt per user with a winpodx config dir, matching apt purge semantics. rpm and aur lack a purge concept; their post-remove hooks tell users how to do the full wipe (winpodx uninstall --purge --yes). Shared logic lives in packaging/scripts/postrm-common.sh, installed to /usr/share/winpodx/packaging/postrm-common.sh by every distro. New files: debian/postinst, debian/postrm, debian/winpodx.install, packaging/aur/winpodx.install. Modified: packaging/rpm/winpodx.spec (%post + %postun added), packaging/aur/PKGBUILD (install=winpodx.install + scripts install), .github/workflows/aur-publish.yml (push winpodx.install alongside PKGBUILD).
  • winpodx --version install-source suffix. Output now reads winpodx 0.5.8 (installed via apt) / (curl install) / (pip install) / (install source not detected) so users see provenance at a glance.
  • winpodx info [System] install-source line. Same detection surfaced in the info command's System block alongside winpodx / OEM bundle / rdprrap / distro / kernel lines.
  • winpodx.utils.install_source -- new helper. Detects install provenance via dpkg -S / rpm -qf / pacman -Qo (3 s timeout each), with ~/.local/bin/winpodx-app curl-install heuristic + site-packages source-checkout fallback. Returns InstallSource(kind, label, package_name, removal_command) for downstream uninstall / version / info / doctor consumers.
  • Extended Windows-on-KVM tuning profile (#245). auto and safe now apply 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) plus virtio-rng-pci backed by /dev/urandom. Significant scheduling / VM-exit win on multi-vCPU guests; entropy pool fills quickly so first-boot CryptoAPI / TLS handshakes don't stall. auto additionally exposes nested-virt CPU features (+vmx Intel / +svm AMD) and hv-evmcs (Intel) when /sys/module/kvm_intel/parameters/nested (or kvm_amd) reads Y -- enabling Hyper-V / WSL2 / Docker Desktop inside the Windows guest. No-op when the host kernel hasn't opted into nested KVM. safe excludes nested-virt + hv-evmcs (those need explicit host-side opt-in). New CLI override: winpodx pod start --tuning {auto,safe,off,manual} for one-shot per-run profile selection (does not persist to winpodx.toml). GUI Settings gains a Tuning Profile dropdown plus a live format_tuning_summary() panel showing what the resolved profile applies on this host. New TuningCapability.nested_kvm probe + TuningProfile.apply_hv_enlightenments / apply_virtio_rng / apply_evmcs / apply_nested_virt flags in winpodx.utils.specs.
  • performance tuning profile + Settings card relocation (#245 follow-up). New profile that behaves like auto but bypasses the dedicated_host gate -- CPU pinning + no-balloon flip on regardless of current host idle CPU / free RAM. 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 so the profile can't force a CPU flag QEMU would reject. CLI: winpodx pod start --tuning performance. GUI: Settings now has a dedicated "Performance Tuning" card -- dropdown at top, format_tuning_summary() detection panel below in the same card frame.
  • Settings UI polish + design tokens. Split the old "Container / VM" card (10 form rows, taller than the 7-row RDP card next to it) into a "Hardware" card (Backend / Edition / CPU / RAM / Idle / Max Sessions = 6 rows) plus a full-width "Localization" card (Language / Region / Keyboard / Timezone = 4 rows) so the top row reads as a height-balanced two-column layout. Added a design-token layer to winpodx.gui.theme -- SPACE_XS/S/M/L/XL/XXL, RADIUS_XS/S/M/L/XL/XXL, and FONT_CAPTION/BODY/SUBHEAD/HEADER/TITLE/HERO -- so the patchwork of magic numbers (24, 22, 14, 12, 11, ...) across cards, headers, summary panels, and form labels can converge on a single named scale. Settings page + tuning card converted to the new tokens; remaining pages will migrate in a follow-up.

Changed

  • winpodx setup default flipped to non-interactive auto (#255 PR 1). Previously interactive with host-detected defaults + a handful of prompts. Now defaults to fully auto (= existing --non-interactive behaviour). Use winpodx setup --customize for the wizard. The --non-interactive flag stays as a deprecated alias for install.sh and other scripted callers.
  • Timeouts widened across the board + activity-based compose-up (#298, #126). Slow hosts / slow links no longer fail ops that are still making progress: compose up is now activity-based (kills only after 5min of silent output, not a 120s wall clock) with a 4h hard cap; wait_for_windows_responsive 90 -> 300s default / 180 -> 600s at call sites; agent /health + FreeRDP RDP-port probes 2 -> 5s; transport.exec / run_in_windows guest calls bumped 2-3x (sync-password 30 -> 90s, multi-session 20/45 -> 60/120s, etc.); compose down 60 -> 180s; daemon suspend/resume/inspect 30/10 -> 90/30s. Tight TCP poll-loop probes (check_rdp_port inside pod_status) intentionally left short -- the budget there is the poll interval.
  • CPU sub-flags moved from ARGUMENTS to dedicated CPU_FLAGS + VMX env vars; hv- delegated to dockur.* Three problems collapse into one refactor: (1) PR #289's -msg timestamp=on workaround for dockur proc.sh:137 bash slice on empty post-strip ARGUMENTS, (2) PR #281's hv-* enlightenments duplicating dockur's own hv_passthrough (default HV=Y) causing QEMU 10's Ambiguous CPU model string. Don't mix both "-hv-evmcs" and "hv-evmcs=on" warning, (3) PR #281's explicit +vmx / +svm nested-virt injection duplicating dockur's VMX env handling. winpodx now uses dockur's documented env-var interface: CPU_FLAGS: "arch_capabilities=off,+invtsc" on x86 (the two sub-flags dockur doesn't add itself), VMX: "Y" when nested-virt is wanted (dockur picks the right +vmx / +svm per host CPU). ARGUMENTS carries only the virtio-rng device pair now. proc.sh:137 strip code path is never triggered (no -cpu host, in ARGUMENTS) so the -msg timestamp=on marker is removed. The ambiguous-string warning goes away (we don't add hv-evmcs -- dockur owns the entire hv-* set). And winpodx is decoupled from QEMU sub-flag deprecation: when QEMU 11/12 changes hv-* syntax, dockur handles it, we don't.

Fixed

  • Agent 8765 bind failure -- urlacl reserved for the wrong owner (#269). install.bat ran to completion (RDP listener up, HKCU\Run registered, agent spawned) but the agent FATAL'd on every boot: HttpListener.Start() failed: ... 'http://+:8765/' ... conflicts with an existing registration on the machine. The agent runs as the non-admin autologon User, so binding the strong-wildcard prefix needs a urlacl reservation it's permitted to use. install.bat's old netsh http add urlacl ... user=Everyone listen=yes >nul 2>&1 either created a wrong-owner reservation or hit ERROR_ALREADY_EXISTS (183) -- both masked by the >nul redirect. Now: delete all overlapping 8765 reservations first, then netsh http add urlacl url=http://+:8765/ sddl=D:(A;;GX;;;WD) (World SID directly, locale-proof), with every result logged to setup.log + a netsh http show urlacl dump after. agent.ps1 retries the bind 5x (absorbs the reservation-landing race) and dumps the live urlacl state to agent.log on persistent failure. OEM bundle 24 -> 25. Diagnosed end-to-end with @ismikes (Kubuntu 26.04) -- three earlier test branches ruled out the install.bat changes before his agent.log screenshots pinpointed the urlacl conflict.
  • dockur proc.sh:137 ARGUMENTS-strip crash, agent never installs (#287, #269). dockur (via the qemus/qemu base image) parses the compose ARGUMENTS: env, strips out -cpu host,<sub-flags> for its own CPU_FLAGS pipeline, then runs ARGUMENTS="${args::-1}" on the leftover. When the user's tuning profile produces nothing else (tuning_profile = off for example), the leftover is an empty string and the bash slice fails with proc.sh: line 137: -1: substring expression < 0. proc.sh exits on that line; /oem is never copied into the Windows guest; install.bat never runs; the winpodx agent service is never installed; port 8765 accepts TCP but RSTs every handshake; winpodx pod wait-ready times out at 60 min. Reported by @ankranidiotis on v0.5.7 (Linux Mint 22) and @ismikes (Kubuntu 26.04) -- both fresh installs with default tuning. Workaround in _qemu_arguments_for_host(): when no extra QEMU args would be emitted, append -msg timestamp=on (timestamps QEMU log lines, otherwise a no-op) so proc.sh's strip leaves at least one space-separated token in ARGUMENTS and the bash slice sees a non-empty string. The auto / safe / performance tuning profiles already emit hv-* / virtio-rng extras (#245) and aren't affected; the workaround triggers for off and any future profile that produces a pure -cpu host,... line.
  • podman-compose required up-front instead of trusting podman compose (#288). On Fedora-family systems with the docker-compose CLI plugin installed, podman compose delegates to docker-compose, which doesn't understand the group_add: [keep-groups] magic value winpodx uses for rootless /dev/kvm passthrough -- the container fails to start with Unable to find group keep-groups. install.sh's old presence check accepted that delegation (podman compose version succeeds via it). Now install.sh requires the standalone podman-compose binary, and backend.podman._compose_cmd raises a clear per-distro install hint instead of silently delegating. Reported by @magicdiablo (Nobara).
  • -no-hpet removed -- QEMU 10 (dockur v5.15+) rejects it as invalid option. PR #281's hv-enlightenments work appended -no-hpet to the QEMU ARGUMENTS when the auto / safe / performance profiles selected the Hyper-V machine timer. dockur v5.15 ships QEMU 10.x via the qemus/qemu v7.30 base image; QEMU 10 dropped the -no-hpet flag entirely (replacement is -machine ...,hpet=off, which would override dockur's machine type). Container start now bombs with qemu-system-x86_64: -no-hpet: invalid option. Dropping the flag has no functional impact -- the hv-stimer + hv-stimer-direct enlightenments already steer Windows off HPET as the clock source. Reported by @ismikes on Kubuntu 26.04 against main HEAD after PR #289 merged.
  • Structured [agent-install] markers in install.bat (#269 diagnostics). Each agent-install step (hkcu-run-register, spawn, post-spawn-probe, urlacl) is fenced with [agent-install] step=<name> status=<enter|exit> lines, the HKCU\Run write + Start-Process are wrapped in try/catch that log the exception, and a 5s post-spawn probe checks the 8765 listener + tails agent.log -- so a failed agent install localises itself in setup.log without another round-trip.
  • RPM spec License: tag widened to MIT AND Apache-2.0 (#301). packaging/rpm/winpodx.spec declared License: MIT but config/oem/rdprrap-*-windows-x64.zip bundles stascorp/rdpwrap (Apache-2.0). Fedora packaging guidelines require the SPDX expression to enumerate every license that applies to the redistributed binary content. debian/copyright and THIRD_PARTY_LICENSES.md already broke this out correctly; the RPM spec now matches.

[0.5.7] - 2026-05-21

Feature release wrapping up #247 (per-item debloat picker, four phases) and #254 (locale + edition save flow, three phases), plus the OEM-perm regression #266/#267 caught at the same time.

Highlights

Per-item debloat with CLI + Qt + TTY pickers, Windows-guest timezone wiring via dockur's TZ env var, GUI Settings → Container/VM Localization dropdowns + auto-recreate on save, and winpodx pod recreate [--wipe-storage].

  • winpodx debloat -- per-item catalog driven by data/debloat/items.toml (11 items, 4 presets). --list / --preset / --items / --undo / --menu on the CLI plus a Qt picker dialog with checkboxes + risk badges (#247).
  • Windows guest timezone is now host-autodetected and passed to dockur via the TZ env var; cfg.pod.timezone GUI dropdown + winpodx config set pod.timezone --auto + setup-wizard prompt all wired (#254).
  • Settings → Container/VM gains Language / Region / Keyboard / Timezone dropdowns alongside the existing Edition picker, with a unified save flow that auto-runs _recreate_container (or _wipe_pod_storage first when language/edition changed) on dirty fields (#254 P3).
  • winpodx pod recreate [--wipe-storage] -- regenerates compose.yaml + destroys/recreates the container (Windows disk preserved by default; --wipe-storage triggers a fresh Windows install for language/edition changes) (#254 P1).
  • Config.save() now persists pod.language / pod.region / pod.keyboard / pod.timezone / pod.tuning_profile -- pre-this fix those five fields were loaded but never written back, silently dropping winpodx config set on the next save.

Added

  • winpodx debloat --menu text-mode picker (#247, phase 4). Headless-friendly alternative to the Qt dialog for users on SSH / TTY-only installs. Pure input() + print() -- no curses dependency, no display server, no terminal capability sniffing. Loop renders the catalog as a numbered list with current selection state + preset name + risk badges + (one-way) tags, then prompts for a command: <N> toggles item N, <name> toggles by name, p lists presets, p <name> switches preset, a applies, q quits, h shows help. run_menu accepts injectable input_fn / print_fn so the same code path drives interactive shells and CI integration tests with mocked stdin.
  • GUI debloat picker dialog (#247, phase 3). Tools → Debloat now opens a modal dialog (gui/debloat_picker.py) instead of running the normal preset blind. Every catalog item is a row with a checkbox + colored risk badge (green / amber / red) + tooltip showing the description; preset radios (Normal / Full / Performance / Speed / Custom) seed the checkbox state, and toggling any item flips the radio to Custom. Apply runs the selection via the existing run_via_transport channel; Cancel exits without touching the guest. One-way items (onedrive, startup_programs) carry a "(one-way)" tag in the row so users see at a glance which selections can't be reverted via #247 P2's --undo path.
  • winpodx debloat --undo + per-item undo scripts + state JSON tracking (#247, phase 2). Each catalog entry can carry an optional undo_script field; reversible items (9 of the 11) now ship an undo/<name>.ps1 sibling that reverts the apply. winpodx debloat --undo --items <list> (or --undo --preset <name>) runs the undo path; mixed selections containing one-way items (onedrive, startup_programs) abort with a clear "items have no undo path" error so a partial undo doesn't leave the user thinking the one-way item was reverted too. The apply orchestrator now writes a per-item entry to %ProgramData%\winpodx\debloat-applied.json after each successful apply, and the undo orchestrator removes it -- groundwork for the GUI picker (phase 3) to show "currently applied" state next to each checkbox.
  • winpodx debloat --list / --preset / --items (#247, phase 1). Refactors the previously-monolithic winpodx debloat (which always ran the same 70-line debloat.ps1 script) into a per-item catalog + selection resolver. The catalog ships at data/debloat/items.toml with 11 items (telemetry, ads, onedrive, sysmain, web_search, widgets, scheduled_tasks, startup_programs, visual_effects, search_indexing, transparency), each backed by a small per-item .ps1 under scripts/windows/debloat/. Four presets (normal, full, performance, speed) seed the most common selections; bare winpodx debloat (no args) keeps the previous behaviour by defaulting to normal (telemetry + ads). --list prints the full catalog + presets for inspection without touching the guest. --items accepts a comma-separated explicit name list and overrides --preset when both are given. Out of scope for this phase (tracked under #247): per-item undo, GUI picker dialog, TTY-menu (--menu).
  • GUI Settings → Container / VM card gains Localization dropdowns (#254, phase 3). Four new rows alongside the existing Edition / CPU / RAM picks: Language, Region, Keyboard, Timezone. Each row's first option is Auto (...) and maps to an empty TOML string (= host autodetect at compose time); the timezone row's auto-label surfaces the live detect_timezone() result so the user can see what would land on the guest. Save handler routes the recreate prompt through one of two paths: a plain "Restart now?" when only timezone / CPU / RAM / port / user are dirty (Windows disk preserved, OEM tzutil re-applies on every recreate), or a louder "Wipe and reinstall now?" when Edition or any of language / region / keyboard is dirty (dockur honors those env vars only on the initial Windows install, so a plain recreate would silently no-op). Accepting the wipe prompt calls the same _wipe_pod_storage helper as winpodx pod recreate --wipe-storage.
  • winpodx config set pod.<key> --auto shorthand (#254, phase 2). Resolves the host-detected value for the named key instead of taking it from the positional <value> argument. Currently wired: pod.timezone (IANA name from utils.locale.detect_timezone). Other locale keys (language / region / keyboard) print a clear "not yet supported" error pointing at #254 and exit non-zero; their detectors land in a follow-up. --auto and a positional <value> are mutually exclusive.
  • winpodx setup interactive timezone prompt (#254, phase 2). Wizard now prompts for cfg.pod.timezone after the CPU / RAM rows, defaulting to the host-detected IANA zone. Non-interactive setup (install.sh's call path) leaves cfg.pod.timezone at its default empty string so the compose generator's host autodetection still fires at first boot.
  • cfg.pod.timezone — Windows guest timezone wiring (#254, phase 1). New config knob. Empty string (the default) triggers host autodetection at compose time via timedatectl show --property=Timezone --value, falling back to readlink /etc/localtime then /etc/timezone then UTC. The IANA result is translated to a Windows TZ ID through the CLDR-derived data/locale/windows_zones.toml table (~150 entries from the 001 wildcard subset). The resolved Windows TZ ID lands in <oem_dir>/timezone.txt; the OEM install.bat reads it on first boot and runs tzutil /s "<id>" instead of the previous unconditional tzutil /s "UTC". Explicit IANA names or bare Windows TZ IDs in the TOML are honored; detection-failure UTC skips the file so the guest stays on its current zone rather than being forced onto UTC. Host autodetection covers the @ismikes report (#204) that fresh installs default the Windows guest to UTC regardless of host locale.
  • winpodx pod recreate [--wipe-storage] subcommand (#254, phase 1). Regenerates compose.yaml from the current config, then destroys and re-creates the container -- a cleaner primitive than winpodx pod restart for picking up first-boot env knob changes (timezone, edition, backend). --wipe-storage also destroys the Windows disk volume / bind-mount so dockur re-runs the full install, which is required for language / region / keyboard / edition changes to actually reach the guest (dockur honors those env vars only on the initial Windows install). The wipe path prompts for an explicit WIPE confirmation.
  • config/oem/install.bat reads C:\OEM\timezone.txt if present (#254, phase 1). Falls back to the previous tzutil /s "UTC" behaviour when the file is absent.
  • data/locale/windows_zones.toml — IANA -> Windows TZ ID mapping table (#254, phase 1). Shipped under share/winpodx/data/locale/ via the pyproject shared-data layout. Refresh helper (scripts/ci/refresh_windows_zones.py) deferred to a follow-up.

Changed

  • Timezone wiring redesigned around dockur's native TZ env var (#267, refactor of #254 P1). The original P1 design dropped a timezone.txt file into the OEM bind-mount dir and added a tzutil block to install.bat to consume it. That forced an always-copy OEM dir path so per-config files could land without polluting the source bundle, which re-introduced the parent-dir traversal problem PR #95 originally dodged: dockur's in-container OEM cp runs as a non-root sub-UID that can't traverse a 0700 ~/.config or ~/.local ancestor, producing cp: cannot stat /oem/./<file>: Permission denied for every OEM file at first boot. Switching to dockur's TZ env var (which already drives the <TimeZone> element in its Sysprep unattend.xml) removes the OEM-file requirement entirely, lets _find_oem_dir go back to its pre-#254 two-regime layout (bundle-direct when user-writable / copy when read-only), and eliminates the umask-077 perm trap. install.bat's old tzutil block was removed; utils/locale.py (host detection + IANA→Win mapping table) stays in place for GUI display and the wizard's auto-default.

Fixed

  • OEM cp Permission denied cascade on fresh installs with umask 077 (#267). Symptom: dockur's first-boot OEM-copy step failed with cp: cannot stat '/oem/./<file>': Permission denied for every OEM file -- container's non-root sub-UID couldn't traverse the 0700 ~/.config/ ancestor introduced by #254 P1's always-copy OEM dir path. Fixed by reverting to bundle-direct OEM mounting when the user owns the bundle (see Changed entry above for the deeper refactor).
  • Config.save() now persists pod.language / pod.region / pod.keyboard / pod.timezone / pod.tuning_profile (#254, phase 1). Pre-this fix those five fields were loaded from winpodx.toml but never written back -- programmatic changes (winpodx config set pod.language ...) were silently dropped on the next save. Hand-edited values still survived because nothing was overwriting them. Now all five fields round-trip through save/load.

[0.5.6] - 2026-05-21

Hotfix release driven by @ismikes (#214). Fixes the "Launching... but no RDP window appears" symptom on modern rootless podman + pasta (Ubuntu/Kubuntu 26.04 default), corrects the winpodx info VNC reachability false negative, and adds a belt-and-braces tray sweep at the end of uninstall.sh.

Highlights

RDP launches that hung at "Launching..." on Kubuntu 26.04 work again, and winpodx info stops lying about VNC reachability (#214).

  • core/rdp.py no longer wraps FreeRDP in podman unshare --rootless-netns — the wrap put xfreerdp3 inside the container's net ns where the host-side publish was invisible. Reported by @ismikes, diagnosed by @smoore100, both confirmed the fix.
  • winpodx info VNC reachability probe switched from RDP X.224 handshake to plain TCP accept (VNC speaks RFB, not RDP).
  • uninstall.sh adds a final tray/GUI sweep so processes that came up during the uninstall window (other-terminal GUI launches, XDG-autostart races, dbus activation) still get killed before the script exits.

Fixed

  • App launch no longer hangs at "Launching..." on modern rootless podman + pasta (#214). Reported by @ismikes on Kubuntu 26.04 (Wayland + Plasma 6 + FreeRDP 3.24.2); diagnosis from @smoore100. core/rdp.py previously wrapped every FreeRDP invocation in podman unshare --rootless-netns, putting xfreerdp3 inside the container's network namespace where the host-side port publish is invisible — so "Launching..." printed but no RDP window appeared. The wrap is removed; FreeRDP now runs on the host loopback directly, where 127.0.0.1:<rdp_port> is reachable via the existing podman publish. Side benefits: removes an implicit dependency on the podman binary being on PATH for the FreeRDP launch path (flatpak-FreeRDP-only setups now work), and the Plasma 6 / Wayland / FreeRDP 3.24 edge case from #214 is unblocked.
  • winpodx info no longer falsely reports "VNC unreachable" while NoVNC is serving (#214). The VNC port (8007 by default) was probed with check_rdp_port, which sends an X.224 Connection Request and waits for a TPKT reply — meaningless against an RFB-speaking VNC server, so the probe always returned False. VNC now uses check_tcp_port (plain TCP accept), which matches what "is the NoVNC endpoint accepting connections" actually means.
  • uninstall.sh final tray/GUI sweep before exit. WINPODX_NO_TRAY_SPAWN=1 + the section-0a pkill cover the case where uninstall.sh is the only thing touching the tray, but they miss tray/GUI processes that came up during the uninstall window — a GUI window opened in another terminal that already spawned a tray, a KDE/GNOME XDG-autostart race, a dbus-activated launch that bypasses cli/main.py's spawn check. A quiet final pkill -f 'python.*winpodx' + pkill -f winpodx-app pass right before the summary catches those.

Internal

  • packaging/obs/obs-publish.yml anchors the download grep to the current tag version so stale older RPMs left in the OBS publish index by rolling-release repos (Tumbleweed / Slowroll lazy GC) don't get re-uploaded as release assets. v0.5.5 was the first release to surface this — two stale 0.5.4 RPMs landed in the Release page and had to be deleted by hand.

[0.5.5] - 2026-05-21

User-report follow-up release driven by @tolistim (#216) and @ismikes (#215). Fixes a setup-rerun lockout that desynced the host config from the Windows guest password, lands a host-adaptive Windows-on-KVM tuning profile so safe perf tweaks turn themselves on without user configuration, and ends the "tray stuck on starting forever" symptom that long-idle pods used to show.

Highlights

winpodx setup rerun is safe again, the dockur compose auto-tunes to the host, and idle pods no longer freeze the tray on starting (#215, #216).

  • winpodx setup rerun on an existing install preserves the working Windows credentials instead of silently overwriting cfg.rdp.password; the lockout that produced LOGON_FAILED_BAD_PASSWORD is gone. Reported by @tolistim (#216).
  • New cfg.pod.tuning_profile (default "auto"): winpodx probes the host once at compose time (invariant TSC, kernel ≥ 5.6 for io_uring, vm.nr_hugepages, idle CPU + RAM headroom) and turns on every safe Windows-on-KVM tweak the host can support. +invtsc lands in the x86_64 QEMU args on supported CPUs. winpodx info / winpodx setup print a new [Tuning] block so the auto-applied set is never hidden. Suggested by @ismikes (#215).
  • safe / off / manual escape hatches for the tuning profile; host-side prerequisites (hugepages, CPU pinning, VFIO) stay documented operator tasks rather than wired into winpodx automatically.
  • New PodState.UNRESPONSIVE plus auto-recovery flow: pod_status() now distinguishes a still-booting container (STARTING, container uptime under 600 s) from a long-running one whose Windows guest has gone unresponsive on the RDP port (UNRESPONSIVE, container uptime past 600 s). The tray observes the RUNNING → UNRESPONSIVE transition, fires a desktop notification, and spawns a background worker that cycles TermService in-guest via the agent transport. On recovery a "pod recovered" notification fires; on failure the user gets a "needs manual restart" notification pointing at winpodx pod restart. OEM install.bat also tightens every powercfg timeout to 0 and clears PlatformAoAcOverride so Modern Standby can't drop the NIC out from under a long-idle session in the first place.

Added

  • PodState.UNRESPONSIVE + auto-recovery flow. pod_status() resolves to the new UNRESPONSIVE value when the container has been running longer than 10 minutes but the RDP port isn't answering — historically misreported as STARTING forever. The tray driver caches the previous state and on a RUNNING → UNRESPONSIVE transition fires notify_pod_unresponsive + a background worker that calls try_recover_rdp (new core/pod/recovery.py). Recovery asks the agent to Restart-Service -Force TermService and re-probes the RDP port for 30 s; success fires notify_pod_recovered, failure fires notify_pod_needs_manual_restart with the failure mode (agent unreachable / rdp still down) so the user knows to run winpodx pod restart. Backend.uptime_secs() is the new ABC method; Podman + Docker implement it via inspect -f '{{.State.StartedAt}}'; libvirt + manual return None so they fall back to the legacy STARTING answer.

  • config/oem/install.bat disables every Windows idle timeout that can kill RDP. The Ultimate Performance plan alone wasn't enough — Modern Standby (S0 low-power idle) can still drop the virtio NIC and stall TermService. Sets standby / hibernate / monitor / disk timeouts to 0 on AC + DC, clears HKLM\SYSTEM\CurrentControlSet\Control\Power\PlatformAoAcOverride to force Desktop class power behaviour, and forces STANDBYIDLE 0 on the current scheme as a belt-and-braces guard. Eliminates the root cause behind the RUNNING → UNRESPONSIVE transition on idle pods.

  • Host suspend / resume no longer leaves the tray frozen on starting. Two-pronged: the guest gets a SYSTEM-level scheduled task that subscribes to Win32_PowerManagementEvent (config/oem/power-monitor.ps1) and restarts TermService 5 s after EventType 7 / 18 (resume from suspend / modern-standby), so the in-guest RDP listener is rebuilt before the next probe. The host tray subscribes to org.freedesktop.login1's PrepareForSleep(active=False) signal via QtDBus and refreshes pod state 5 s after the resume event, triggering the existing UNRESPONSIVE auto-recovery path immediately instead of waiting for the 30 s poll. Both halves degrade gracefully — D-Bus subscription is best-effort; the scheduled task is installed only on fresh OEM apply (existing pods pick it up via winpodx pod apply-fixes in a follow-up).

  • OEM install ends with a scheduled reboot so registry edits actually take effect. PlatformAoAcOverride = 0 (Modern Standby off) and several NIC binding tweaks land in HKLM but the running session keeps the old values until next boot. install.bat now writes a C:\winpodx\oem_reboot_pending.txt marker, registers a RunOnce key that deletes the marker on the second Windows boot, and issues shutdown /r /t 15 as its last action. winpodx pod wait-ready gains a [4/4] Waiting for OEM reboot pass... phase that polls the marker via the agent transport; an upgrade-path install without the marker short-circuits through a 30-second appear-grace window so the existing flow keeps working unchanged.

  • Tray UX overhaul: always-running tray, Dashboard shortcut, confirmed Quit. The tray is now auto-spawned 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 UNRESPONSIVE auto-recovery driver. The tray icon now renders properly (bundled winpodx-icon.svg set before tray.show(); previously KDE Plasma / GNOME suppressed the indicator entirely because no icon was assigned). New "Open Dashboard" menu item at the top of the tray context menu spawns the main GUI window. Quit now confirms via a QMessageBox ("winpodx 를 완전히 종료할까요?") 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. tray.lock flock under $XDG_RUNTIME_DIR/winpodx/ prevents stacked instances when the user manually re-launches the tray.

  • ensure_ready two-stage RDP recovery on CLI launch. CLI launches (winpodx app run, GUI app clicks, etc.) now self-heal a stalled-RDP guest before spawning FreeRDP: stage 1 calls try_recover_rdp (agent-driven TermService cycle, ~5-30 s, keeps Windows uptime), stage 2 falls back to recover_rdp_if_needed (whole-container restart, ~30 s, resets Windows uptime). The old behaviour was to fail the launch with a connection-refused that the user had to debug; now the cheap path tries first, only escalates when the agent isn't reachable.

  • GUI Settings: "Launch winpodx tray at login" checkbox. New toggle in the Settings page writes / removes ~/.config/autostart/winpodx-tray.desktop via the XDG autostart spec — portable across KDE / GNOME / XFCE / Cinnamon without per-DE branching. File existence is the source of truth (no cfg.toml field, no daemon to manage), and the user can drop the .desktop by hand to opt out. X-GNOME-Autostart-enabled=true is included so GNOME's session manager honours it after DE-default resets. Toggle applies immediately — no Save Settings click needed.

  • uninstall.sh covers the new tray + autostart surfaces. Two new sections: (0a) pre-removal pkill -f against winpodx gui and winpodx tray plus 1-s grace, so the killed processes release their FDs into the install / runtime / config dirs we're about to rm -rf and stop firing auto-recovery notifications against a soon-to-be-gone container; (6b) always-remove ~/.config/autostart/winpodx-tray.desktop regardless of --purge because the .desktop is useless without the binary and leaving it triggers a winpodx: command not found at next login.

  • Host-adaptive Windows-on-KVM tuning profile (#215). cfg.pod.tuning_profile controls how aggressively winpodx tunes the dockur compose for the host. Default "auto" probes the host once at compose time (constant_tsc + nonstop_tsc, kernel ≥ 5.6 for io_uring, vm.nr_hugepages > 0, idle CPU + RAM headroom) and enables the matching subset of standard tweaks — currently +invtsc on x86_64 hosts that expose invariant TSC. Other profiles: "safe" (Tier-1 only — +invtsc + Windows platform_tick, no host-side prerequisites), "off" (dockur defaults only), "manual" (safe shape; future per-knob overrides). winpodx info and winpodx setup both print the detected capability + resolved profile so users can see exactly what was auto-applied. Suggested by @ismikes (#215); standard sysguides.com Windows-on-KVM tuning items that need host-side setup (hugepages, CPU pinning) are documented in docs/USAGE.md as user-host tasks rather than wired into winpodx automatically.

Fixed

  • winpodx setup rerun no longer locks out users by silently rewriting the Windows password (#216). Re-running setup on an existing install (e.g. to bump cores/RAM) used to reprompt for the Windows password and write whatever was entered into winpodx.toml. Because dockur honors USERNAME / PASSWORD env vars only on first boot, the host config silently desynced from the Windows guest account and the next RDP launch failed with LOGON_FAILED_BAD_PASSWORD. The interactive prompt now detects an existing config with a populated cfg.rdp.user + cfg.rdp.password, preserves them, and prints guidance to use winpodx rotate-password (which goes through the Windows-side password change mechanism). Non-interactive install.sh setup and fresh installs are unchanged. Reported by @tolistim on Linux Mint 22.3 / v0.5.4.

  • Docs: Fedora 42 / 43 / 44 install snippet uses dnf5 syntax (#228). Old sudo dnf config-manager --add-repo <URL> fails on Fedora 41+ because dnf5 changed the subcommand shape. Snippet now reads sudo dnf config-manager addrepo --from-repofile=<URL>; the dnf4 form is noted below for Fedora ≤40 (EOL). Updated in README.md / docs/README.ko.md / docs/INSTALL.md / docs/INSTALL.ko.md / packaging/obs/README.md. Reported by @payayas.

  • install.sh no longer falsely reports success when hardware virtualisation is off (#220). Reported by @pnogaret2019-code on Linux Mint LMDE 7. The install loop treated qemu-system-x86 already being present as "all dependencies installed successfully" while /dev/kvm stayed absent because the BIOS had VT-x disabled. install.sh now (a) emits a much more prominent pre-install warning that walks the user through the three real causes (BIOS, kernel module, kvm group) with diagnostic commands, and (b) re-verifies /dev/kvm after the install loop and aborts with live lscpu / lsmod / id output when it's still missing. README + Korean mirror gain a new "Minimum requirements" section with the same three-row check table, so users see the gating requirements before they ever copy-paste the curl-install line. Catches the largest single source of "install ran fine but Windows never boots" bug reports.

  • install.sh Ubuntu 24.04+ / Debian 13 picks the real qemu-system-* package instead of the now-virtual qemu-kvm (#200). Reported by @n-osennij on xubuntu 26.04 inside VirtualBox. apt errored with E: Package 'qemu-kvm' has no installation candidate because newer Ubuntu / Debian made qemu-kvm a virtual package whose providers are qemu-system-x86 (or qemu-system-x86-hwe). pkg_name() now uses the same apt-cache show probe pattern as the freerdp2 → freerdp3 selector from PR #198: probes qemu-system-x86 first (or qemu-system-arm on aarch64), falls back to qemu-system-x86-hwe, then qemu-kvm for older distros. The VirtualBox-side nested-virtualisation requirement is host-environment and outside winpodx's scope — covered by the new "Minimum requirements" README section.

  • dockur/windows image pin bumped to v5.15 (#223, #224). DOCKUR_IMAGE_PIN / DOCKUR_IMAGE_ARM_PIN + config/oem/VERSIONS.txt track the new digests (sha256:32abe0836aee... x86_64, sha256:5775bcfd335b... aarch64). Picks up upstream's accumulated fixes since v5.14 without changing the winpodx-side compose surface.

  • install.sh aborts cleanly on Ctrl+C instead of marching past Pod is starting, not running. Start the pod now and apply? [Y/n]. The old install loop used a single EXIT INT TERM trap that only cleaned up the install marker; SIGINT then fell through to the next step. Split into three traps (EXIT cleanup-only, INT cleanup + exit 130, TERM cleanup + exit 143) and wrapped the wait-ready | tee pipeline with set +e so PIPESTATUS[0] survives the || true rescue and the SIGINT-vs-real-failure branch can act on the real wait-ready rc.

  • winpodx setup --non-interactive flag for install.sh-driven migration. install.sh calls setup migration after the initial install; without --non-interactive the migration would block on the storage-path prompt and stall the curl-install. Setup gains the flag and treats every interactive prompt as "accept default" when set.

  • uninstall.sh tray no longer respawns mid-uninstall. Section 0a pkill'd the tray, then section 0b ran winpodx host-open stop-listener / unregister-guest to tear down reverse-open. cli/main.py auto-spawns the tray for every subcommand outside the {setup, gui, tray} skiplist, so host-open triggered a fresh tray spawn and section 0a's work was immediately undone. uninstall.sh now exports WINPODX_NO_TRAY_SPAWN=1; maybe_spawn_tray() short-circuits when set, so every winpodx CLI subprocess uninstall.sh runs inherits the env and skips the spawn.

[0.5.4] - 2026-05-19

User-report follow-up release. Fixes slow-link compose timeout (#212), eliminates the silent "launched but no window" failure mode that #213 / #214 surfaced, lands Ubuntu 26.04 packaging in the CI matrix (#206), Windows language / region / keyboard configurability (#201), and the dynamic desktop resolution feature (#197).

Note: v0.5.3 was tagged earlier the same day but its .deb build failed on Debian/Ubuntu hosts due to a Trove-classifier whitelist mismatch in older hatchling. v0.5.4 supersedes it with the same feature set plus a packaging fix; the v0.5.3 release page was removed to avoid two releases on the same day. The v0.5.3 tag stays for history.

Highlights

Pull-friendly compose timeout, visible FreeRDP launch errors, Ubuntu 26.04 in CI, and a packaging fix that makes the .deb build green on every supported distro.

  • podman-compose up -d / down default timeout 120s → 1800s; honors WINPODX_COMPOSE_TIMEOUT_SECS (0 = no cap). Fixes #212 reported by @jimed-rand.
  • launch_app polls FreeRDP 500 ms after spawn and raises FreeRDP exited immediately with rc=... with the stderr log path. Fixes #213 reported by @mozjacksson + #214 reported by @ismikes.
  • Ubuntu 26.04 packaging + Python 3.14 in the CI matrix. Closes the untested-platform gap behind #213 / #214 (by @juampe, #206).
  • Windows [pod] language / region / keyboard keys; same YAML-injection hardening as user / password / home (by @juampe, #201).
  • Desktop sessions now resize dynamically to the FreeRDP client window size (by @Zeik0s, #197).
  • LICENSE / README ship to /usr/share/winpodx/ in the .deb; GUI License tab works again on Debian/Ubuntu (by @juampe, #201).
  • Qt stylesheet parser warnings at GUI startup silenced (by @juampe, #203).
  • Packaging fix: minor-version Python Trove classifiers (:: 3.9:: 3.13) dropped from pyproject.toml. Older hatchling (Debian 12 ships 1.12.0, Ubuntu 24.04 ships 1.18.0) bundles its own classifier whitelist and rejects any minor-version classifier added after that release; that's what broke the v0.5.3 .deb build. Replaced with the version-agnostic :: 3 :: Only. Supported-version coverage still comes from requires-python = ">=3.9" and the CI matrix.

Added

  • Windows language / region / keyboard configuration. The pod's installation language, regional format, and keyboard layout are now configurable via [pod] language / region / keyboard in winpodx.toml. Defaults to English / en-001 / en-US (backward compatible with existing configs); set to e.g. Spanish / es-ES / es-ES for a Spanish Windows install. All three fields run through _DANGEROUS_YAML_CHARS sanitisation in PodConfig.__post_init__ and _yaml_escape at compose render — same defense-in-depth as the existing user / password / home fields. docs/INSTALL.md and docs/INSTALL.ko.md document the 10 most common language tuples. Applies only to fresh Windows installations; existing pods need a volume reset + winpodx setup re-run. (by @juampe, #201)

Changed

  • CI matrix now tests Python 3.14 and packages for Ubuntu 26.04 (#206). Closes the untested-platform gap that surfaced in #213 / #214. (by @juampe)

Fixed

  • Compose up/down timeout is now pull-friendly. Default compose timeout bumped 120s → 1800s; honors WINPODX_COMPOSE_TIMEOUT_SECS env var (0 = no cap). Fixes #212 reported by @jimed-rand.

  • LICENSE and README.md missing from .deb packages. pyproject.toml now ships LICENSE and README.md to /usr/share/winpodx/ via the [tool.hatch.build.targets.wheel.shared-data] table. The GUI's License tab no longer hits FileNotFoundError on Debian/Ubuntu installs. (by @juampe, #201)

  • Qt stylesheet parser warnings at GUI startup. main_window.py combined a bare property declaration (background: ...) with full QSS selector rules (QPushButton { ... }), which Qt's stylesheet engine rejects. The central widget now carries an objectName="centralRoot" and the background rule is scoped to QWidget#centralRoot { ... }. No visual change; just silences the Could not parse stylesheet of object QWidget(...) warnings in the logs. Library page Launch/Edit buttons also got their inline QSS converted to multi-line blocks for readability while tracking parser warnings. (by @juampe, #203)

  • Dynamic Desktop Window Resolution — creating a desktop session now resizes the resolution dynamically to the size of the FreeRDP client window. Flag added as default behavior for desktop sessions. Covered by tests. (by @Zeik0s, #197)

  • Launch surfaces FreeRDP stderr on quick-exit; eliminates silent "launched but no window" failure mode. Helps diagnose Plasma 6 / Wayland and Python 3.14 edge cases. Fixes #213 reported by @mozjacksson + #214 reported by @ismikes.

  • Debian/Ubuntu .deb build fails with Unknown classifier: Programming Language :: Python :: 3.X. Older hatchling shipped by Debian/Ubuntu maintains its own Trove-classifier whitelist and rejects fine-grained Python version classifiers added after that hatchling release. The minor-version classifiers were cosmetic — actual Python version coverage comes from requires-python and the CI matrix. Removed in favour of Programming Language :: Python :: 3 :: Only, which every hatchling release accepts.

[0.5.2] - 2026-05-14

Same-day install-path hot-fix release. Two install.sh regressions that blocked new installs on common 2025+ distros (Debian 13 Trixie, Ubuntu 24.10+) are fixed, and Atomic Fedora support lands as a single-transaction OBS-layered install path.

Highlights

  • install.sh works again on Debian 13 / Ubuntu 24.10+ — the hardcoded freerdp2-x11 dependency was unresolvable on those distros (only freerdp3-x11 is in stock repos). The Debian/Ubuntu branch in pkg_name() now probes apt-cache show and picks freerdp3-x11 when available, falling back to freerdp2-x11 only on older systems. New users on Debian 13 / Ubuntu 24.10+ could not previously install at all. (reported by @basti189, #198)
  • Atomic Fedora install path (Silverblue / Kinoite / Sericea / Bluefin / Bazzite) via OBS rpm-ostree install --apply-live — single transaction, no per-package reboot loop. winpodx's RPM Requires: pulls FreeRDP / podman / python3 / tomli transitively, so layering just winpodx is enough. Falls back to staged install + reboot prompt if live apply is rejected by the booted deployment. (by @Zeik0s, #163)

Added

  • Atomic Fedora install support (Silverblue / Kinoite / Sericea / Bluefin / Bazzite). install.sh autodetects rpm-ostree and switches to a single-transaction OBS-layered install instead of the per-package dnf loop: probes the OBS repo for the host's Fedora VERSION_ID (currently published: Fedora_42 / Fedora_43 / Fedora_44), drops the .repo file into /etc/yum.repos.d/, and runs rpm-ostree install --apply-live --idempotent winpodx to land the layer in the booted deployment without a reboot. If the running deployment can't accept the live apply (kernel/init-touching layers), staging falls back to rpm-ostree install --idempotent winpodx with a reboot prompt. winpodx's RPM Requires: freerdp >= 3.0 + python3-tomli and Recommends: podman + python3-PySide6 so layering just winpodx pulls everything transitively — no separate dependency list to maintain. README "Supported distros" gains an Atomic Fedora row and a Fedora Atomic Desktops "Works on" badge; docs/INSTALL.md documents the hand-install steps for users who'd rather skip install.sh. Korean mirrors updated. (by @Zeik0s, #163)

Fixed

  • install.sh no longer fails with freerdp2-x11 not found on Debian 13 (Trixie) and recent Ubuntu (24.10 / 25.04 / 25.10) where stock apt repos only ship freerdp3-x11. The Debian/Ubuntu branch in pkg_name() now probes apt-cache show and prefers freerdp3-x11 when available, falling back to freerdp2-x11 only on older systems (Debian <=12, Ubuntu 22.04 stock). FreeRDP 3 is the recommended target anyway — the v0.5.1 launcher detects the major version at startup and emits the matching /app: syntax for either. (reported by @basti189, #198)

[0.5.1] - 2026-05-14

Maintenance + ergonomics release. Wider curated Windows edition support, headless install ergonomics, FreeRDP 2 compatibility fix for Ubuntu 22.04 LTS users, and an internal Qt refactor that future-proofs the GUI codebase. Nine PRs land between v0.5.0 and this tag.

Highlights

Pick LTSC IoT, Win10 LTSC, Tiny, Server, or your own ISO — and winpodx app run <app> now works on Ubuntu 22.04's default FreeRDP 2.11. This release is mostly a follow-up to user-reported pain points on v0.5.0: more Windows editions selectable from one place, a real --win-version flag on install.sh and winpodx setup, and a RemoteApp launch bug that opened Microsoft Store instead of the real app on FreeRDP 2.x hosts.

  • Windows edition picker (#178, #183, #185, #186) — cfg.pod.win_version now accepts the full Win10+ curated set (11, 10, ltsc11, ltsc10, iot11, tiny11, tiny10, 2025, 2022, 2019, 2016) from the GUI Settings → Container/VM card, winpodx setup --win-version, or install.sh --win-version. Custom ISOs documented as a manual workaround in docs/ARCHITECTURE.md. (reported by @gabe39, #178)
  • FreeRDP 2 / Ubuntu 22.04 LTS compatibility (#189) — winpodx app run <app> no longer opens Microsoft Store on FreeRDP 2.11.x. Win32 RemoteApp launches now use FreeRDP's separate /app: + /app-name: + /app-cmd: flags instead of FreeRDP 3's combined /app:program:X,name:Y,cmd:Z syntax. (reported by @poetman, #158)
  • ARM64 (aarch64) host support — Pi 5 and Ampere systems install cleanly (#176, #177). Host arch is detected at install / setup time; aarch64 hosts default to dockurr/windows-arm and the compose template emits -cpu host without the x86-only arch_capabilities=off sub-option that previously crashed QEMU on host-arm-cpu with Property 'host-arm-cpu.arch_capabilities' not found. New aarch64 CI job catches future regressions. Remaining Phase 4 work — Windows-on-ARM RDP listener stabilisation — tracked under #141 / #140. (reported by @tslpre, #140)
  • Discovery cleanup (#182) — winpodx app refresh no longer re-imports the reverse-open Linux-app shims as Windows apps, and a self-heal pass purges any existing polluted entries on the next refresh.
  • Weekly Docker Hub digest watcher (#180) — picks up silent dockur rebuilds (security patches that don't get a release tag) that the release-tag-only watcher misses.
  • Internal: WinpodxWindow Qt class decomposed from 2745 lines into a 148-line orchestrator + 10 single-responsibility mixins (#181). Zero behaviour change; maps cleanly to a future Rust port.
  • Always-on bottom log bar + live log-level picker. Two new pieces working together:
    • A persistent 2-line log ticker at the very bottom of the main window — visible regardless of which page is active. Latest two log_signal lines (winpodx logger + pod tail when cfg.logging.level == "RAW") flash there in real time.
    • Terminal tab gets a DEBUG / INFO / WARNING / ERROR / CRITICAL / RAW dropdown that retargets the running logger AND persists to cfg.logging.level. RAW also enables the parallel podman logs -f stream (with [pod] prefix), so dockur / QEMU / Windows-side messages flow through both the Terminal full view and the bottom bar — useful for "Windows isn't booting" / "ISO download stuck" / agent-down triage.
    • Tails are started once at GUI launch and stay running, so the bottom bar updates regardless of which page you're on. The Terminal tab's Live (app) / Live (pod) / Stop tail buttons were removed (now redundant with the always-on design).
  • Settings save with container recreate now auto-runs the full bring-up (wait Windows boot → agent settle → apply runtime fixes → discover apps → push reverse-open manifest) with a progress dialog. No more "I changed the edition and now my apps don't work" workflow.

Added

  • winpodx setup --win-version EDITION and install.sh --win-version VER flags for headless edition selection on fresh installs. New WINPODX_WIN_VERSION env var equivalent. Documented in docs/INSTALL.md "Choosing the Windows edition" section. (#178, #185, #186)
  • GUI Settings → Container/VM card gains a "Windows Edition" combo populated from _KNOWN_WIN_VERSIONS. Read-only dropdown; custom dockur tags outside the curated list are settable via direct winpodx.toml edit (see docs/ARCHITECTURE.md "Advanced: Custom Windows ISO"). (#178, #183)
  • _KNOWN_WIN_VERSIONS constant in core/config.py (12 curated editions). PodConfig.__post_init__ normalises whitespace + case, warns on unknown values without rejecting them. (#178, #183)
  • ARM64 (aarch64) Linux host support — Phase 1+2+3 of #141. install.sh and core/config.py:_default_pod_image detect host architecture via uname -m and route aarch64 hosts to the dockurr/windows-arm image (matching the dockur project's separate ARM build). core/pod/compose.py drops arch_capabilities=off from the -cpu host flag on aarch64 — that sub-option is x86-only KVM CPUID hardening and QEMU's host-arm-cpu machine model rejected it with Property 'host-arm-cpu.arch_capabilities' not found, the exact symptom Pi 5 users hit at winpodx pod start. The new aarch64 CI matrix entry (#177) runs the test suite on ubuntu-24.04-arm so architecture-aware codepaths don't regress silently. Remaining Phase 4 work — Windows-on-ARM RDP listener stabilisation, rdprrap compatibility on xtajit x86 emulation — tracked separately under #141 + #140. (reported by @tslpre, #140 — Pi 5 / RaspiOS Trixie repro with full QEMU error)
  • check-windows-updates workflow gains a :latest digest drift detector — opens a "silent rebuild" tracking issue when digest changes without a release-tag bump. Two new fields in config/oem/VERSIONS.txt: dockur-digest= and dockur-arm-digest=. (#180)
  • docs/ARCHITECTURE.md "Advanced: Custom Windows ISO" section documents the manual win_version = "custom" + compose.yaml mount workaround for users with their own pre-loaded ISOs. Korean mirror updated. (#178, #184)
  • CONTRIBUTING.md "Crediting contributors in Highlights" section codifies the inline (by @user, #PR) / (reported by @user, #issue) convention used in CHANGELOG Highlights. Korean mirror updated. (#187)
  • New [logging] config section with level field (DEBUG | INFO | WARNING | ERROR | CRITICAL | RAW). setup_logging() reads it on startup so all winpodx CLI / GUI runs pick up the chosen level. Default INFO; unknown values fall back to INFO with no rejection. Terminal tab dropdown changes the live logger AND persists to TOML. RAW is a winpodx-only meta-level: pins the Python logger to DEBUG AND turns on a parallel podman logs -f tail in the Terminal so dockur / QEMU / Windows-side output interleaves with winpodx's own log lines (pod-log lines are prefixed [pod] and dimmed for distinguishability).
  • New License GUI tab. Shows the MIT license text inline + a "Third-party components" section listing the upstream projects winpodx ships or depends on (dockur, FreeRDP, PySide6/Qt, rdprrap, stascorp/rdpwrap, rcedit, Catppuccin) with their respective licenses. Resolves the LICENSE file via bundle_dir() so it renders correctly across every install mode (source checkout, wheel, FHS, installer drop).
  • Auto bring-up after container recreate. Changing CPU / RAM / port / user / Windows edition in the GUI Settings page used to call _generate_compose + _recreate_container and stop — the freshly-recreated guest had no booted Windows (different ISO if edition changed -> fresh download), no agent, no rdprrap multi-session apply, no app discovery, and no reverse-open manifest, so users had to run several CLI commands manually after every recreate. The save handler now chains the full bring-up automatically on a worker thread: (1) wait for pod_status == RUNNING AND check_rdp_port (bounded by cfg.install.wait_ready_stage2_secs, default 900s), (2) wait for AgentClient.health() + host token ready (bounded by cfg.install.wait_ready_stage3_secs, default 1800s — long because fresh-ISO download + Sysprep dominate this stage), (3) winpodx.core.provisioner.apply_windows_runtime_fixes for rdprrap activation + RDP timeouts + OEM baseline, (4) discovery.scan + persist_discovered, (5) winpodx host-open refresh for the reverse-open manifest. A new BringUpProgressDialog (window-modal, indeterminate progress bar, Cancel button) renders each phase's label + sub-detail; the same lines also feed the always-on bottom log bar via the existing log_signal fan-out. Cancel is best-effort — phase 1/2 polling loops honour the event within their 2 s cadence, but long blocking apply / discover / sync calls in phase 3-5 cannot be interrupted mid-flight (the button disables itself with "Cancelling..." text so the user knows the request landed). Implemented as a new BringUpMixin in src/winpodx/gui/_main_window_bringup.py, three new signals on WinpodxWindow (bringup_phase, bringup_done, bringup_started), and a kick at the end of the existing _recreate worker in _main_window_settings.py. +5 tests (test_main_window_bringup.py) cover happy-path ordering, phase-1 cancellation, phase-3 failure surfacing, non-blocking entry point, and fresh-cancel-event-per-run.

Changed

  • Qt WinpodxWindow decomposed into mixins. src/winpodx/gui/main_window.py shrinks from 2745 lines to 148 (-95%); page builders, page behaviour, chrome construction, pod control, and worker orchestration each move to their own private _main_window_*.py mixin module. Pure structural refactor — zero functional change, all method resolution preserved via MRO. (#181)
  • core/discovery._is_junk_entry now rejects executables under C:\Users\Public\winpodx\reverse-open\bin\ (queried via the new reverse_open.sync.is_guest_shim_path helper) so the host's reverse-open Linux-app shims aren't mistakenly imported as Windows apps. persist_discovered() runs a _purge_reverse_open_entries self-heal pass that drops any existing polluted entries on the next refresh. scripts/windows/discover_apps.ps1 Add-Result filters the same path on the guest side as defense in depth. (#182)
  • docs/USAGE.md win_version TOML example updated to list the new curated set instead of the pre-#183 subset. Korean mirror updated. (#184)
  • License tab third-party acknowledgments completed. Added libvirt-python (LGPL-2.1+), docker / docker-py (Apache-2.0), tomli (MIT), and the getrandom Rust crate (MIT OR Apache-2.0) used in the reverse-open shim. The optional [libvirt] and [docker] extras now have their LGPL/Apache attribution surfaced in the GUI per upstream license terms; the reverse-open shim's dual-licensed Rust dependency is acknowledged for parity with what's statically linked into the vendored .exe.
  • BringUpProgressDialog detail. The auto bring-up modal now shows a 5-phase checklist with per-phase elapsed times, attempt counters during the polling phases (Pod ready / Agent ready), and an expandable live tail of the dockur container's podman logs -f output so users can see what Windows is actually doing during the long ISO-download + Sysprep wait (Phase 2 can take 30+ minutes on a fresh edition). The pod-log expander spawns its own short-lived podman logs -f subscription bounded to the dialog's lifetime so the live tail is visible regardless of cfg.logging.level, and also receives the always-on [pod]-prefixed lines that flow through WinpodxWindow.log_signal (the same fan-out introduced for the always-on bottom log bar). bringup_phase now carries a stable phase-ID slug (phase_1_pod / phase_2_agent / phase_3_fixes / phase_4_discovery / phase_5_refresh) as its first arg so the dialog can route emissions to the correct checklist row independent of presentational copy. +6 dialog-side tests added.

Fixed

  • winpodx app run <slug> no longer opens Microsoft Store on FreeRDP 2.x hosts (Ubuntu 22.04 LTS), AND no longer fails with Unexpected keyword on FreeRDP 3.x hosts (Tumbleweed, Fedora 42/43 with FreeRDP 3 PPA, etc.). Win32 RemoteApp now branches on the detected FreeRDP major version: FreeRDP 3 keeps the combined /app:program:X,name:Y,cmd:Z syntax (FreeRDP 3 parses /app: as <key>:<value>,... and rejects bare paths with Unexpected keyword at the C: drive prefix); FreeRDP 2 uses the separate /app:PATH + /app-name:NAME + /app-cmd:CMD flag form (FreeRDP 2 parses the combined string as a literal program path, fails the launch, and Windows shell handler falls back to Microsoft Store). Version is detected once per process via xfreerdp --version and cached. UWP path is unchanged. (reported by @poetman, #158)
  • __version__ in src/winpodx/__init__.py was stale at 0.4.3 and didn't track the package version. Bumped to 0.5.1 with this release; winpodx --version, the migrate version comparison, and the install marker now match pyproject.toml.
  • YAML-injection hardening on the four [pod] config fields that land inside the compose template's double-quoted scalars. A hand-edited winpodx.toml with win_version = '11"\nEVIL: "x' (or the same shape in container_name / image / disk_size) used to break out of its YAML scalar and inject arbitrary env keys into the dockur service. Two-layer fix: core/config.py:PodConfig.__post_init__ rejects dangerous chars (", \\, \n, \r, $, `) and validates disk_size against a regex; core/pod/compose.py:_build_compose_content now runs all four values through _yaml_escape as defense in depth. cli/setup_cmd.py re-runs __post_init__ after the --win-version flag assignment so direct attribute mutation can't bypass the validation either. Five new tests cover dangerous-char reject, disk_size regex, and the YAML escape boundary.
  • License tab acknowledgments table corrected. Smoke + legal review found three mistakes: rdprrap was listed as GPL-3.0 (it's MIT, same maintainer); Catppuccin Mocha was credited as the GUI palette (theme is actually GitHub Primer Dark — see src/winpodx/gui/theme.py); PySide6 license string was imprecise. Catppuccin removed; rdprrap moved to MIT; GitHub Primer Dark added with the GitHub Inc. 2013 copyright; PySide6 tightened to LGPL-3.0-only WITH Qt-LGPL-exception-1.1. Three additional rdprrap-NOTICE upstreams surfaced in the GUI for parity with what the OEM zip ships: stascorp/rdpwrap (Apache-2.0), llccd/TermWrap (MIT), llccd/RDPWrapOffsetFinder (MIT). Optional-extras dependencies (Pillow, cairosvg, pyxdg) also added with their licenses.

Changed

  • .gitignore extended for AI-tooling-footprint hygiene per CLAUDE.md Section 8 (.mcp.json, AGENTS.md, CLAUDE.local.md, demo.png) plus standard scratch / editor / OS patterns and a defensive secret-shape denylist (*.env, *.pem, *.key, id_rsa*, etc. — currently no such files in tree).
  • Arch Linux status accuracy. The README.md "Supported distros" row claimed pacman / AUR | Supported, and docs/INSTALL.md instructed yay -S winpodx. Neither is currently true — the AUR publish workflow is wired but secret-gated on AUR_SSH_PRIVATE_KEY, which has not been provisioned, so no winpodx AUR package has shipped. Updated the README row to "Source install supported · AUR package pending maintainer onboarding", replaced the INSTALL.md "Arch Linux (AUR)" section with install.sh guidance, and clarified the native-package-manager intro. Korean mirrors updated.

[0.5.0] - 2026-05-13

Reverse-open (#48) ships end-to-end and is the headline feature of this release. Linux apps now appear in the Windows guest's right-click "Open with…" menu by default — no opt-in flag, no Settings toggle required — with correct per-app icons in both the short menu and the long "Choose another app" dialog. Phase 2 series complete (a / b / c / d), plus the full fix-forward stack: per-app .cmd wrappers, Rust .exe shim, short-menu visibility, Firefox handoff, uninstall --purge scope, multi-resolution ICOs, Desktop folder shortcut + Quick Access pin, and the embedded-EXE-icon chooser fix.

The chooser icon problem deserves a callout: Win10/Win11 Explorer reads chooser entry icons from the .exe's embedded PE resource, not from Applications\<exe>\DefaultIcon nor from a per-slug ProgID's \DefaultIcon. PR #171 ships the only path that actually works — embed the per-slug .ico directly into each per-slug .exe via vendored rcedit (electron/rcedit v2.0.0, MIT). PR #165's hard-link inode-sharing optimisation is intentionally dropped (~500 KB × N apps on disk) since there is no chooser-icon path that also preserves shared inodes on stock Windows.

Highlights

Reverse-open ships end-to-end (#48). Linux apps now appear in the Windows guest's right-click "Open with…" menu by default, with correct per-app icons in both chooser surfaces. Selecting one round-trips the file open back to the host's xdg-open.

  • Reverse-open Phase 2 series complete (#157, #159, #160, #161, #162) — host-side discovery, listener daemon, Qt Settings card, guest handlers + sync transport, default-on.
  • Reverse-open fix-forward stack (#164#171) from real-Windows smoke — culminating in #171's embedded EXE icons via vendored rcedit (the working chooser-icon fix).
  • PR #165's hard-link inode-sharing is intentionally dropped (~500 KB × N apps on disk); no chooser-icon path also preserves shared inodes on Win10/Win11.
  • cfg.reverse_open.enabled defaults to True; turn off with winpodx host-open disable or the GUI Settings panel.

Added

  • Reverse-open Phase 2 chooser icon fix-forward (#164 → #171). The Phase 2c registration landed in #161 but the real-Windows smoke uncovered three classes of issues — entries collapsing to a single chooser item because Windows dedupes by EXE path, entries missing from the short menu, and entries rendering with the generic .exe glyph. The fix-forward stack addresses each:

    • #171 — Embed the per-slug icon into the per-slug .exe via vendored rcedit. Each winpodx-<slug>.exe is now an independent copy of the shim whose PE resource section carries the matching <slug>.ico. The earlier ProgID DefaultIcon surface from #170 is ignored by the chooser; the embedded EXE icon is the only surface Win10/Win11 honours. rcedit.exe (1.36 MB) is committed under config/oem/reverse-open/shim/bin/ and pushed to the guest alongside the shim via base64 in the sync payload. register-apps.ps1 gains a -RcEditExe parameter; staging a slug is now Copy-Item + rcedit --set-icon. unregister-apps.ps1 mirrors the rcedit cleanup.
    • #170 — Scaffolding (superseded by #171). Adds the per-slug ProgID at HKCU\Software\Classes\winpodx-<slug> (anchoring the <ext>\OpenWithProgids\winpodx-<slug> value link in the long dialog), a SHChangeNotify(SHCNE_ASSOCCHANGED) P/Invoke call to invalidate the shell icon cache, and a one-line INFO log at the listener's spawn site capturing slug=… + the substituted argv=… for post-hoc debuggability of misbehaving spawns. The ProgID DefaultIcon writes from this PR were dropped in #171 (redundant once the EXE carries the icon); the ProgID itself, the SHChangeNotify call, and the spawn-argv log line remain.
    • #169 — Desktop folder shortcut + Quick Access pin. Drops a Desktop\Linux Apps.lnk folder shortcut pointing at the Start Menu Linux Apps folder, plus a Quick Access pin via the Shell.Application "pintohome" verb. Both surface in Win11's "Choose another app → Look for another app on this PC" file-browse dialog so the user doesn't have to navigate to %APPDATA%\Microsoft\Windows\Start Menu\Programs\Linux Apps\ by hand.
    • #168 — Multi-resolution ICOs + full freedesktop mimeapps walk. Icon conversion produces ICOs containing 16/24/32/48/64/128/256 px frames so the chooser, taskbar, and Settings → Default apps all pick a non-blurry image. Discovery now walks every mimeapps.list per the freedesktop spec (XDG_CONFIG_HOME, then XDG_CONFIG_DIRS, then $XDG_DATA_HOME/applications, then $XDG_DATA_DIRS/applications), not just the user's primary mimeapps.list, so default-handler detection matches what xdg-mime query default resolves.
    • #167 — uninstall.sh --purge skips guest reverse-open scrub. --purge is for "nuke the host, container is also going away" cases; reaching into the guest with unregister-apps.ps1 is pointless and slow if the pod is about to be deleted anyway.
    • #166 — Short-menu visibility + chooser cache + Firefox handoff. Fixes the OpenWithList write so the entry surfaces in the short Open With menu (sub-key, not value — Windows treats values under OpenWithList as the MRU list and sub-key names as the population list). Calls ie4uinit.exe -show after registry writes to invalidate the chooser cache without a logoff. Routes the Firefox open-file handoff through a dedicated path so multi-instance launches dispatch to the existing instance correctly.
    • #165 — Rust .exe shim + NTFS hard links. Replaces the original PowerShell/.cmd shim with a windows_subsystem = "windows" Rust binary so launching a chooser entry doesn't flash a console window. Per-slug entries originally landed as NTFS hard links of the source shim binary — same inode, flat on-disk footprint (this optimisation is undone in #171; see above).
    • #164 — Per-app .cmd wrappers + Linux-default scope + Start Menu shortcuts. Earlier ProgID-based registration collapsed multiple apps to a single chooser entry because Windows dedupes by EXE path; the per-app .cmd shape gives each slug a distinct path. Registration scope narrowed to "apps the user has actually set as their Linux default" (via is_default_for) — registering every MIME-handling app flooded the chooser. install.sh auto-triggers reverse-open sync; uninstall.sh includes a host-open unregister-guest step. Start Menu shortcuts created for every discovered Linux app (regardless of default-handler status) so non-default apps remain reachable via "Choose another app → Look for another app on this PC".
  • Reverse-open Phase 2c — guest-side handlers + sync transport + default-on. Closes #48's end-to-end loop: Linux apps now appear in the Windows guest's right-click "Open with…" menu without any user opt-in. cfg.reverse_open.enabled defaults to True. Three new guest-side scripts (config/oem/reverse-open/register-apps.ps1, unregister-apps.ps1, and the PowerShell shim under shim/winpodx-reverse-open-shim.ps1) plus a new host-side push module (src/winpodx/reverse_open/sync.py). The shim is base64-embedded into the sync payload alongside the apps manifest and ICO blobs, so the feature works on existing pods that were created before this PR shipped — no dependence on dockur having staged the OEM bundle. The sync transport rides over the existing bearer-authed /exec endpoint with a 180s budget; a successful sync persists cfg.reverse_open.last_synced_at. winpodx pod start now auto-spawns the host-side listener daemon (idempotent — no-op if already running), winpodx pod stop tears it down before stopping the pod. winpodx host-open refresh now also pushes to the guest when the agent is reachable and reports pushed N app(s) + M icon(s) → registered in its summary. The PowerShell shim writes an atomic <uuid>.json.tmp to \\tsclient\home\.local\share\winpodx\reverse-open\incoming\ then renames to <uuid>.json so the host listener never picks up a partial request. registry shape on the guest: per-app HKCU\Software\Classes\winpodx-<slug> ProgID with shim invocation in shell\open\command, plus HKCU\Software\Classes\<ext>\OpenWithProgids\winpodx-<slug> for every extension the app's MIME types map to (built-in curated table covers the top ~25 unambiguous mappings; extras fall back to a per-MIME guess from the type string). +13 tests (test_reverse_open_sync.py). Reverse-open Phase 2 series is complete; remaining follow-ups are a Go port of the shim (faster cold-start than PowerShell's ~300-500 ms; tracked but not blocking). References #48.

  • Reverse-open Phase 2d — Settings-page card in the Qt GUI. Surfaces every Phase 2a/2b CLI affordance — enable toggle, allowlist + denylist (with + Add / Remove buttons that route through the same validate_slug grammar the CLI uses), live daemon status (running / pid, cached app count, generated-at timestamp), and four action buttons that dispatch the existing host_open handlers (refresh & sync, start daemon, stop daemon, refresh status). The widget builder (winpodx.gui.reverse_open_panel.build_panel) imports Qt lazily so unit-testable pure-Python helpers (PanelStatus, build_panel_status, validate_slug, add_slug, remove_slug, format_status_line) can be exercised in a headless test environment; the slug + list mutation helpers share semantics with the CLI's add / remove so a slug round-trips between GUI and winpodx host-open without surprises. Settings page wires the panel into the existing column layout next to the RDP / Pod cards and folds save into the existing _save_settings flow — no separate "Save" button for reverse-open, the panel writes to the shared cfg.reverse_open and the page-wide save persists everything atomically. Panel construction failures don't block the Settings page (logged + skipped), so a Qt regression in this module can't take down the rest of Settings. +17 tests (test_reverse_open_panel.py). Phase 2c (Go shim + guest register-apps.ps1 / unregister-apps.ps1 + agent /reverse-open/sync endpoint) is the only remaining piece and stays gated on the real-Windows smoke gate. References #48.

  • Reverse-open Phase 2b — inotify-equivalent polling listener, daemon lifecycle, app-DB hot-reload, and the four new host-open subcommands. Phase 2a (PR #157) staged the apps.json manifest + per-app .ico; Phase 2b lights up the host-side daemon that will eventually consume guest-written request files. New modules: (1) reverse_open/apps_db.py — :class:AppsDatabase + :class:AppEntry with schema-validated load from apps.json + :func:substitute_path argv builder (first %f/%u/%F/%U wins, subsequent placeholders dropped, never re-shelled). (2) reverse_open/listener.py — :class:Listener with a 500 ms polling loop over the incoming dir, pre-flight refusing group/world-writable dirs and dirs owned by another uid, 64 KB size cap + 8-level JSON depth cap to defend against parser-exhaustion attacks, replay defence via Phase 1's SeenUUIDs ring buffer, schema validation (version=1, slug-shape app, NUL-free path, \\\\tsclient\\ prefix), safe_open_unc TOCTOU-safe path resolution before spawn, 200-request in-flight cap with oldest-first triage, 300 s janitor cycle for stale files, and 9 counter stats surfaced via stats_snapshot(). Subprocess spawning is parameterised so tests capture without forking. (3) reverse_open/lifecycle.py — fork-fork-pipe daemon spawn with 5 s ready-sentinel, atomic 0o600 pid file under $XDG_RUNTIME_DIR/winpodx/reverse-open.pid, SIGTERM/SIGINT graceful shutdown, SIGHUP triggers apps.json re-load (so host-open refresh doesn't restart the daemon), stale-pid recovery (process gone but file lingering → cleanup on next status query), idempotent start_listener, os._exit on the daemon body to avoid leaking SystemExit back through the fork-without-exec parent's stack. Four new winpodx host-open subcommands: start-listener, stop-listener, daemon-status, all with --json; plus refresh now sends SIGHUP automatically when a daemon is running and reports daemon_reloaded in the JSON summary. +60 tests across test_reverse_open_apps_db.py (12), test_reverse_open_listener.py (27 including a real fork-and-process integration test through Phase 1's safe_open_unc), test_reverse_open_lifecycle.py (14 including a live daemon-spawn-and-process round-trip), test_cli_host_open.py (+5). Phase 2c (Go shim + guest register-apps.ps1 / unregister-apps.ps1 + agent /reverse-open/sync endpoint — the one that needs the real-Windows smoke gate) and Phase 2d (GUI Settings card) remain deferred. References #48.

  • Reverse-open Phase 2a — host-side .desktop discovery, ICO conversion, and the winpodx host-open CLI. Lifts the dormant Phase 1 foundations into a usable host-only flow: a new winpodx host-open refresh command walks $XDG_DATA_HOME/applications + $XDG_DATA_DIRS like xdg-open does, applies the existing allow / denylist filter from cfg.reverse_open, rasterises each app's freedesktop icon to a 16/24/32/48/64/128/256 px Windows .ico (Pillow for raster, cairosvg → svglib fallback for SVG, generic placeholder if neither backend is installed), and stages an apps.json manifest under ~/.local/share/winpodx/reverse-open/ ready for the Phase 2b sync layer to push to the guest. Discovery hardens the input heavily — rejects entries whose Exec= line contains shell metacharacters, drops Wine / winapps / winpodx wrappers (recursion defence), respects Hidden / NoDisplay / OnlyShowIn / NotShowIn, validates TryExec=, marks the user's mimeapps.list defaults — so a malicious .desktop in ~/.local/share/applications/ can't smuggle a shell command through the staged manifest into Phase 2b's spawn path. The CLI also exposes status, list (live-scan or --cached), enable / disable, and add / remove for allow / denylist management (with --deny toggle and slug-shape validation). New optional extra winpodx[reverse-open] pulls in Pillow, cairosvg, and pyxdg; without it host-open refresh still produces the manifest but writes a placeholder ICO per app and logs a warning. +60 tests across test_reverse_open_discovery.py (25), test_reverse_open_icons.py (14), test_cli_host_open.py (21). Phase 2b (inotify listener + lifecycle daemon + agent /reverse-open/sync endpoint), Phase 2c (Go shim + guest-side register-apps.ps1 / unregister-apps.ps1), and Phase 2d (GUI Settings card) are deferred. References #48.

[0.4.4] - 2026-05-10

Patch release. Restores real-time pod wait-ready --logs output that was inadvertently buffered in v0.4.3, plus the half-uninstalled-state self-heal, the cachyos rotation/sync-password agent-first fix, the codec flag allowlist correction, the --extra-args escape hatch, the btrfs warning suppression, and dormant Phase 1 foundations for the agent-first install + reverse-open initiatives.

Fixed

  • install.sh's pod wait-ready --logs output flows live again. v0.4.3 piped the wait-ready invocation through tee to capture stderr for the half-uninstalled-state diagnostic, which had the unrelated side effect of flipping Python's stdout from line-buffered (terminal) to 4 KB-block-buffered (pipe). All [1/3] container ..., [container] ..., [2/3] RDP, [3/3] activation lines silently accumulated in the buffer and flushed in one batch when wait-ready exited — a fresh-install user saw nothing for the entire ISO download, then a wall of text at the end. install.sh now prepends PYTHONUNBUFFERED=1 to the wait-ready call, which forces line-buffered stdout regardless of pipe presence. The tee-captured file contents are unchanged, so the no such container discrimination keeps working. Reported during smoke testing on 2026-05-10.

  • Half-uninstalled state where config exists but the container is gone now self-heals on winpodx setup --non-interactive (called by install.sh). Previously a non-purge uninstall.sh (which strips winpodx files but should keep the container) followed by a re-run of install.sh would print Existing config found, skipping setup. and proceed straight to pod wait-ready, which then exploded with Error: no container with name or ID "winpodx-windows" found: no such container because the container had also been wiped (either by the user picking --purge interactively without realising, or by some external cleanup that took winpodx's container alongside the rest). The only recovery path was a full --purge reinstall, which a user shouldn't have to figure out from a stack trace. Setup now detects the missing-container state in the non-interactive existing-config branch — when cfg.pod.backend in ("podman","docker"), pod_status reports anything other than RUNNING/PAUSED/STARTING, AND a direct <backend> ps -a probe doesn't find cfg.pod.container_name in its output — and runs ensure_ready(cfg) to recreate the container from the existing compose.yaml idempotently. install.sh's pod wait-ready failure path now also discriminates on stderr: if the captured output contains no such container it prints a clear "container is missing — likely from a partial uninstall" hint with the exact --purge recovery commands instead of the generic 60-minute-timeout message that previously masked the real cause.

  • winpodx rotate-password and winpodx pod sync-password now try the in-guest agent first and fall back to FreeRDP RemoteApp on their own. On cachyos, xfreerdp3's bidirectional drive redirect is broken for the headless RAIL session winpodx uses for run_in_windows: the script runs on the guest, but the result file written to \tsclient\home\...\windows-exec\ never surfaces back to the host, so both _change_windows_password (auto-rotation + the explicit rotate-password command) and the sync-password recovery path would block until their 45 s timeout and report a channel failure. Same code paths work on openSUSE Tumbleweed where the redirect is healthy, which is why this was missed for so long. sync-password is the more critical of the two — it's the rescue command users reach for when rotation has already failed and cfg.rdp.password no longer matches Windows; if it also hangs, there's no exit. Both core/rotation/_change_windows_password and cli/pod/_sync_password now call dispatch(cfg, prefer="agent") first; if the agent isn't installed yet (TransportUnavailable) they fall back to the FreeRDP path exactly as before, and an agent auth failure (TransportAuthError) is reported without a fallback because that signals config drift, not a transient channel state. As a bonus, when the agent is reachable sync-password no longer needs to prompt for a recovery password at all — the agent uses bearer-token auth, so a user who can't remember their original install.bat password is no longer locked out. The pre-rotation _ROTATION_PENDING_MARKER recovery contract is unchanged — it still triggers on any mid-rotation disconnect regardless of which transport ran. Supersedes docs/TRANSPORT_ABC.md rule #6 ("NEVER use Transport for password rotation"); the rule's two original arguments — needing the OLD password to authenticate FreeRDP, and not exposing the NEW password to the agent process memory — both no longer hold (AgentTransport authenticates via bearer token; both transports expose the new password equally via PowerShell argv and net user argv on the guest, with one in-memory HTTP buffer on the agent path vs one on-disk script file on the FreeRDP path).

  • Removed invalid OPTIONAL-typed codec flags from the _BARE_FLAGS allowlist (+/-gfx-h264, +/-rfx, +/-nsc, +/-jpeg, +/-avc444). Hours after the v0.4.3 stack landed, xiyeming's first test of the new escape hatch (winpodx app run notepad-exe --extra-args="-gfx-h264") failed at FreeRDP's own cmdline parser with [ERROR][com.winpr.commandline]: Failed at index 15: Unexpected keyword. Root cause: FreeRDP 3.x splits flags into COMMAND_LINE_VALUE_BOOL (genuine +/-name toggles) vs COMMAND_LINE_VALUE_OPTIONAL (only /name:value accepted) — the codec flags above are all OPTIONAL, not BOOL, so bare-toggle syntax was always going to fail. The original allowlist patch shipped them as bare anyway, which let unsafe-looking input pass winpodx's filter only for FreeRDP itself to reject with a confusing log line. Now those forms are dropped at the filter layer with a clear Blocked unsafe extra_flag warning, and the GUI tooltip / CLI help text recommend the correct workaround syntax: --extra-args="/gfx:RFX" (force RemoteFX, skips H.264 negotiation entirely — already accepted by the existing /gfx value-regex). Genuine BOOL toggles (+/-gfx-progressive, +/-gfx-thin-client, +/-gfx-small-cache, +/-wallpaper, etc.) keep working as before.

Added

  • Agent-first install Phase 1 foundations — host-side state model, config schema, redactor, JSON Schema, CLI stubs. First of five phases rolling out the agent-first install architecture described in docs/design/AGENT_FIRST_INSTALL_DESIGN.md (PR #144). All foundational code; zero behaviour change while cfg.install.agent_first = False (default). New modules: (1) core/install_state.pyGuestInstallStep / GuestInstallState dataclasses and fetch_install_state(cfg) that reads C:\winpodx\install-state\*.done markers via the agent and falls back to a $XDG_STATE_HOME/winpodx/last_install_state.json cache when the agent is unreachable, never raises. (2) core/agent_install_state.py — atomic marker file primitives (atomic_write_marker, read_marker, list_completed_steps), a RetryCounter class wrapping retry_counts.json with corruption-tolerant ACID updates, and the install-failure redactor (redact_log_line / redact_payload) that strips net user <user> <pw> argv, Authorization: Bearer ... headers, password= / token= / apikey= query-style patterns, and base64 blobs > 40 chars before they ever reach disk via write_install_failure. (3) core/config.py extended with InstallConfig (agent_first, wait_ready_stage2_secs, wait_ready_stage3_secs, auto_resume, watchdog_max_respawns, watchdog_probe_debounce_count, watchdog_probe_debounce_secs) — defensive __post_init__ clamps every numeric range and coerces malformed TOML to safe defaults without raising. (4) cli/pod_install_status.py + cli/pod_install_resume.py — argparse stubs wired into winpodx pod's subcommand router with the full Phase-3 flag surface (--json, --no-color, --logs, --non-interactive for status; --non-interactive, --yes/-y, --force for resume), each printing "Phase 3 implementation pending" and exiting 0. (5) docs/design/install_failure.schema.json — JSON Schema draft-07 with additionalProperties: false, slug-shape failed_step / error_class, capped string lengths, and a 50-line last_log_lines cap. +95 new tests across test_install_config.py (17), test_install_state.py (22), test_agent_install_state.py (32 incl. a Hypothesis property test asserting the redactor's round-trip invariant — for any random string input, the output never contains a substring matching the redaction patterns), test_install_cli_stubs.py (23). Test count: 937 passed, 1 skipped (was 842 / 2). Phases 2 (install.bat refactor as state machine), 3 (host-side wait-ready 5-stage + install-status / install-resume implementations + GUI integration), 4 (default flip to agent_first = True), and 5 (legacy code path removal) are deferred.

  • Reverse-open Phase 1 foundations — src/winpodx/reverse_open/ module skeleton (paths, config, mime, seen_uuids). First of four phases rolling out the guest→host file-open path described in docs/design/REVERSE_OPEN_DESIGN.md (#48). Four foundation modules land with no behaviour change for existing users: (1) paths.py translates UNC paths from the guest's \\tsclient\... namespace into POSIX paths under the configured share roots, with a TOCTOU-safe safe_open_unc atomic context manager (rewritten from the original two-function validate-then-open API after security review caught a swap window where a symlink could be substituted between the validate call and the open call — the new shape opens with O_NOFOLLOW and validates the realpath against the allowed roots while still holding the fd, so there is no window an attacker can race); (2) config.py defines the ReverseOpenConfig schema with a baked-in dangerous-app denylist (cmd/powershell/wscript/etc.) and cfg.reverse_open.enabled = False default — this PR adds zero user-visible behaviour change; (3) mime.py ships a curated 86-entry MIME→extension mapping covering the file types the guest is most likely to hand back, with a runtime fallback to xdg.Mime for anything outside the curated table; (4) seen_uuids.py is a persistent ring buffer of recently-handled request UUIDs for replay defence — capped size with LRU eviction, atomic write via tmp+rename so a crash mid-update can't corrupt the file. +154 tests across 4 new test modules, including a hypothesis property test covering paths.translate_unc_to_posix traversal invariants (random component sequences, mixed separators, encoded .., percent-encoded null bytes — all must either resolve inside an allowed root or raise). Test count: 814 passed, 2 skipped (was 660). Phase 2 (transport), Phase 3 (host dispatcher), and Phase 4 (guest agent integration) are deferred. References #48.

  • winpodx app run --extra-args per-launch FreeRDP override + GUI Settings → Extra FreeRDP args. Diagnostic / workaround escape hatch for distro builds that ship a broken xfreerdp3. Per-launch --extra-args is appended AFTER cfg.rdp.extra_flags so a one-shot debug flag wins over the global default; both paths run through the same _filter_extra_flags allowlist so the new entry point can't smuggle anything unsafe. The Settings page in the GUI now has an "Extra FreeRDP args" input bound to cfg.rdp.extra_flags, with placeholder + tooltip listing the most useful toggles. The allowlist itself was expanded with the codec / cache / RAIL toggle pack — +/-gfx-h264, +/-gfx-progressive, +/-gfx-thin-client, +/-gfx-small-cache, +/-nsc, +/-jpeg, +/-avc444, +/-wallpaper, +/-themes, +/-decorations, +/-grab-keyboard, +/-grab-mouse, +/-mouse-relative, +/-async-update, +/-async-channels, +/-auto-reconnect, +/-bitmap-cache, +/-offscreen-cache, +/-glyph-cache — without these in the allowlist they were silently dropped before reaching xfreerdp3. Critical near-term use case: -gfx-h264 is the workaround for cachyos's xfreerdp3 build (WITH_VAAPI_H264_ENCODING=ON flagged experimental by FreeRDP itself), where xiyeming hit silent RemoteApp failure during RAIL post_connect — now they can set extra_flags = "-gfx-h264" in winpodx.toml or via the GUI to force RemoteFX fallback while we wait for winpodx app run notepad-exe --extra-args="-gfx-h264" to confirm the hypothesis.

Changed

  • Suppress dockur's cosmetic "you are using the BTRFS filesystem for /storage" warning in winpodx pod wait-ready --logs. dockur's proc.sh fires this warning unconditionally on every btrfs host based purely on df --output=fstype, without checking whether NoCoW (chattr +C) has been applied. v0.4.3 ships per-user bind-mount + auto chattr +C migration, so on a post-migration host the warning is a false positive — the actual fragmentation signal (COW (copy on write) is not disabled for disk image file ...) is gone. The streamed log line is now rewritten to (btrfs warning suppressed: NoCoW bind mount in use) when cfg.pod.storage_path is set, so install.sh output stops looking alarming. Legacy users still on the named volume (storage_path = "") keep seeing the original warning so they're prompted to run winpodx setup --migrate-storage.

[0.4.3] - 2026-05-06

The btrfs Copy-on-Write fragmentation fix. The 0.4.x line's headline storage rework: a per-user bind mount with automatic NoCoW so Windows VM disk image writes (every pagefile / boot / swap byte) stop forking new btrfs extents on every overwrite. On btrfs hosts that means pod recreates that previously took many minutes — and frequently timed out on the 300 s budget — finish in 30 s like they always did on ext4. Reported by @xiyeming in #121, #122 (and indirectly the actual-failure cause behind #126's "Unable to open apps and desktop" cascade on cachyos).

Highlights — five PRs landing the fix end-to-end. The migration design started at "chattr +C the entire podman graph root" (PR #124 — closed without merging because it would have flipped every container/volume/image on the host to NoCoW, which surprises btrfs snapshot users), pivoted to a per-user bind mount with chattr +C applied only to that one directory, and then took four follow-up patches as the smoke tests revealed each successive silent failure:

  1. PR #125 — bind mount + chattr +C + named-volume migration tool. Fresh installs default to ~/.local/share/winpodx/storage (chattr'd before compose-up so dockur's first raw-disk write inherits NoCoW). Existing users keep the named volume; winpodx setup --migrate-storage (or auto-trigger via winpodx migrate) moves it to the bind mount path without reinstalling Windows.
  2. PR #127 — compose-prefixed volume name resolution. podman-compose up materialises named volumes with the project name prefix (winpodx_winpodx-data, not winpodx-data); the migration's volume probe now tries the prefixed form first and falls back to bare.
  3. PR #128 — chattr +C outcome surfacing + post-rsync NoCoW verification. print() the apply / already-off / failed outcome so the user sees it during install.sh; re-lsattr both the dir and a sample .img after rsync to catch the silent-no-op case where chattr returned 0 but the flag never persisted.
  4. PR #129 — auto-migration refuses to re-run when the bind-mount target is already populated. Defends against the install.sh-runs-twice destructive scenario where a stale cfg.pod.storage_path would let _maybe_auto_migrate_storage rsync 64 GiB over the already-migrated NoCoW disk image with a fresh CoW copy.
  5. PR #130 — the actual root cause for "chattr never applies on fresh installs." plan_migration calls detect_path_fs(target) BEFORE the dir is mkdir'd. On openSUSE Tumbleweed findmnt --target <nonexistent_path> returns rc=1 with empty stdout (the docs lie) — detect_path_fs returned "unknown", chattr_will_run evaluated False, the entire chattr branch silently no-op'd, and PR #128's surfacing print never fired because the branch wasn't entered. Fix walks up to the nearest existing ancestor (max 64 steps; / always exists) before invoking findmnt.

Smoke-tested end-to-end on openSUSE Tumbleweed btrfs: clean wipe → v0.4.2 fresh install (creates winpodx_winpodx-data named volume) → upgrade to main → install.sh prints chattr +C applied to /home/.../winpodx/storage (NoCoW for new files) between Migrating storage... and OK: migrated 71 GiB; afterwards lsattr -d and lsattr data.img both show the C flag, dockur's Warning: COW (copy on write) is not disabled for disk image file /storage/data.img no longer appears in the journal, the named volume is removed, and the next podman-compose up mounts the bind mount and Windows boots without any CoW warning.

Added

  • Per-user storage bind mount with automatic btrfs NoCoW. winpodx setup on a fresh install now mounts the Windows VM disk image from a user-owned host directory (~/.local/share/winpodx/storage by default) instead of the legacy winpodx-data named volume. When the path lands on btrfs, setup runs chattr +C on the empty directory before compose-up — so dockur's first write of the Windows raw disk image inherits NoCoW from the parent and bypasses Copy-on-Write fragmentation entirely. The flag affects only this specific directory; other podman containers/volumes/images on the host are completely untouched (a deliberate design choice over a graph-root-wide flag, see PR #124's closed comment for the rationale). Existing users keep the named volume and see a one-line note pointing them at the migration command if their volume happens to be on btrfs. Reported by @xiyeming in #121, #122.
  • winpodx setup --migrate-storage. Move an existing winpodx-data named volume to the per-user bind mount path without reinstalling Windows. Pre-flight plan surfaces the source/target paths, source size, target filesystem, whether chattr will run, and free space on the target. Cost: ~5-10 min on NVMe (rsync -aS of ~60 GiB). The Windows install is preserved — no Sysprep redo, no ISO redownload. Idempotent: pod is stopped, btrfs chattr +C applied to the empty target so the copy lands NoCoW, contents rsync'd, cfg.pod.storage_path persisted, compose regenerated, old named volume removed only on success, pod restarted. On copy failure the source volume stays intact and the target is wiped so a retry has a clean slate. Override the destination with --migrate-storage-target PATH; skip the confirmation prompt with --yes.
  • Auto-migrate on upgrade. winpodx migrate (which install.sh calls on every upgrade) now detects the same condition — winpodx-data named volume, mountpoint on btrfs, cfg.pod.storage_path empty — and runs the migration automatically. Interactive runs prompt for confirmation (default Yes); non-interactive runs (install.sh post-upgrade path) just go ahead because the user is already in the degraded state and the whole point of running migrate is to apply fixes. On failure the original named volume is preserved and the user is pointed at winpodx setup --migrate-storage for manual retry.
  • cfg.pod.storage_path config field. Empty string preserves the legacy named-volume mode; an absolute path switches compose to a host bind mount at that path. Config.load validates the field and falls back to empty on bad values; _render_storage_blocks defends against unsafe values (newlines, quotes, brace characters) by reverting to the named volume so a hand-edited winpodx.toml can't corrupt compose YAML.

Fixed

  • Auto-migration was silently never running on compose-managed installs. storage_migration.named_volume_exists("podman", "winpodx-data") always returned False on installs created via podman-compose up because the actual volume on disk is winpodx_winpodx-data — podman-compose namespaces volumes by the compose project name (we declare name: "winpodx" in the template). Auto-migration in winpodx migrate and the Case-2 warning in winpodx setup both keyed off the bare name and therefore quietly classified every compose-managed btrfs install as "fresh-install" / "nothing to migrate". kernalix7 caught this on opensuse Tumbleweed (2026-05-06): the named volume was sitting at winpodx_winpodx-data, the auto-migrate hook had been dormant for hours. New resolve_named_volume(backend) probes the prefixed form first and falls back to the bare form, so both compose-managed and hand-created volumes resolve correctly. MigrationPlan.source_volume carries the resolved name through so the volume rm step at end of migration targets the same volume that was migrated.
  • detect_path_fs walks up to an existing ancestor before invoking findmnt. kernalix7's 2026-05-06 third smoke test (clean wipe → install v0.4.2 → upgrade to main) revealed the SILENT root cause of "chattr +C never applies": plan_migration calls detect_path_fs(~/.local/share/winpodx/storage) BEFORE the target directory is mkdir'd. On openSUSE Tumbleweed findmnt --target <nonexistent_path> returns rc=1 with empty stdout (despite docs claiming it falls back to the nearest existing parent), so the helper returned "unknown". That made chattr_will_run = (target_fs == "btrfs") evaluate to False, the entire chattr branch in execute_migration silently no-op'd, the post-rsync NoCoW verification (PR #128) had nothing to flag because chattr_will_run was False there too, AND PR #128's surfacing print() never fired because the branch wasn't entered. Three migrations in a row landed CoW-fragmented disk images while reporting "OK: migrated N GiB" with zero indication that NoCoW had been silently skipped. Fix walks up to 64 steps from the input path to the nearest existing ancestor (terminates at / which always exists) before passing the path to findmnt, so a not-yet-mkdir'd target still resolves to the fs that will contain it. chattr_will_run is now True for fresh-install migration plans on btrfs targets, which means the chattr +C apply, the inline print() of the outcome, and the post-rsync lsattr verification all actually run.
  • Auto-migration refuses to re-run when the bind-mount target is already populated. kernalix7's 2026-05-06 second smoke test on opensuse Tumbleweed exposed a confusing reproducer: install.sh ran a second time after PR #127 + #128 had merged, found cfg.pod.storage_path empty (root cause still under investigation — one possibility is install.sh's git-update step leaving a stale install dir whose PodConfig dataclass lacked the storage_path field, so Config.load silently dropped the value when round-tripping), AND found the named volume winpodx_winpodx-data recreated by podman-compose up with a 64 GiB raw disk. The auto-migration then ran a SECOND rsync -aS of 64 GiB into ~/.local/share/winpodx/storage — which was already populated with the FIRST migration's output (including the chattr +C-applied data.img from the manual recovery cp --reflink=never). The second rsync silently overwrote the NoCoW disk image with a fresh CoW-fragmented copy, undoing the entire perf benefit AND any data divergence between the named volume and the bind mount that had accumulated since the first migration. New defence: _maybe_auto_migrate_storage now checks default_target_path() (~/.local/share/winpodx/storage) BEFORE the named-volume probe; if that path exists and is non-empty, the hook bails out with a notice telling the user how to either restore storage_path in winpodx.toml or move the populated dir aside before retrying. The bind-mount data on disk is the source of truth — if a migration already happened, re-running cannot be safe.
  • chattr +C outcome is now visible inline AND verified post-migration. kernalix7's 2026-05-06 smoke test on opensuse Tumbleweed succeeded the auto-migration ("OK: migrated 64 GiB"), but lsattr -d afterwards showed no C flag on either the bind-mount directory or the freshly rsync'd 64 GiB data.img — the entire NoCoW benefit of the migration was silently missing because the chattr outcome only flowed through log.warning (filtered out of the install.sh foreground) and there was no post-rsync sanity check. execute_migration now (a) print()s every chattr outcome — disabled / already_off / failed — so the user sees inline whether NoCoW was applied, and (b) re-runs is_cow_disabled after rsync against both the target directory AND a sample *.img file from inside it; any discrepancy ("dir has +C but new file did not inherit", "chattr returned 0 but flag never persisted") is surfaced as a warning in MigrationResult.detail with a recovery recipe (cp --reflink=never of the affected file with the pod stopped). cp --reflink=never reads + rewrites the file inside the now-+C parent, so the new file is born NoCoW from inception and inherits the flag — the only safe way to retroactively fix a 64 GiB CoW disk image without reinstalling Windows.

[0.4.2] - 2026-05-05

Patch release. Two small fixes on top of 0.4.1.

Fixed

  • wait-ready --logs rewrites dockur's container-internal VNC URL to the host's mapped port. dockur prints visit http://127.0.0.1:8006/ to view the screen from inside its container, where 8006 IS the listening port. The host maps that to cfg.pod.vnc_port (default 8007, see core/pod/compose.py's 127.0.0.1:{vnc_port}:8006 spec), so the URL streamed to the user's terminal pointed at a port only reachable inside the container. _drain in _wait_ready now substitutes 127.0.0.1:8006127.0.0.1:{cfg.pod.vnc_port} per streamed line, using the user's actual configured port (so a custom vnc_port in winpodx.toml is honored). Reported by @xiyeming. Closes #118.
  • Packaging workflows tolerate the create-release race. debs-publish and rhel-publish both ran an Ensure release exists step that did view || create. On v0.4.1 tag push both jobs hit the view-failed branch in parallel and both successfully called gh release create; GitHub's release-create POST isn't atomic on tag uniqueness, so two GitHub Releases appeared at the same tag and release.yml's subsequent softprops/action-gh-release failed with tag_name: already_exists, leaving the release page without sdist/wheel attached. The create call is now idempotent: try create, swallow any already_exists error, then verify the release is reachable via view. Whichever workflow wins the race creates the release; the loser's create call no-ops; both proceed to upload assets.

[0.4.1] - 2026-05-05

The 0.4.x line's first stable release. 0.4.0rc1 (2026-05-05) was the soak preview; 0.4.1 is the same code path plus the polish that landed during smoke-test triage. Functionally a continuation of 0.4.0's stability + UX focus, all of it on the install / migrate path that fresh users actually exercise.

Highlights — agent never installs regression closed for good. kernalix7 spent ~3 days reproducing a fresh-install failure on openSUSE Tumbleweed where setup.log was missing entirely, C:\OEM\agent.ps1 was never copied, and qwinsta showed disconnected User sessions piling up — agent never came up, menu never populated. Three independent failure modes converged on the same symptom: (1) Windows Defender real-time scan locking files in C:\OEM\ during install.bat's first PowerShell engine load (deadlocking PS Expand-Archive on the rdprrap zip); (2) host-side migrate apply chain falling back to FreeRDP RemoteApp when the guest agent wasn't up yet, kicking install.bat's autologon session via Windows session-takeover; (3) a transient agent-respawn window after the apply chain that made the inline app refresh race on every smoke test. All three are fixed, with a layered defence so any single layer regressing doesn't bring back the deadlock.

Smoke-tested end-to-end on openSUSE Tumbleweed: install.bat completes cleanly, Password sync OK via agent transport (no ERRINFO_LOGOFF_BY_USER cascade), apply chain via agent transport, discovery completes inline (Discovered N app(s) with retry-on-respawn-race) — menu populated before install.sh exits. Upgrade from v0.3.0-RTM1 produces the same clean result: image-pin migration runs, apply chain succeeds via agent, interactive discovery prompt populates the menu inline.

Changed

  • Release trigger switched from *RTM* glob to a separate REL- marker tag. "RTM" was the original release marker (v0.3.0-RTM1) but the project moved to PEP 440 / SemVer-hyphen versioning (0.4.0, 0.4.0rc1) and no longer ships any RTM-suffixed tag. Mixing the marker INTO the version (e.g., v0.4.0-REL) would have polluted the version scheme since that's not a valid PEP 440 version, so the new design splits the two: the version tag stays clean and a SECOND tag at the same commit (REL-v0.4.0, REL-v0.4.0rc1) acts purely as the release-trigger marker. release.yml now matches REL-* and strips the prefix when extracting the version, so the public Release page is titled v0.4.0 (not REL-v0.4.0) and the changelog lookup hits ## [0.4.0] correctly. Prerelease detection covers both PEP 440 (0.4.0rc1) and SemVer (0.4.0-rc1) forms. Workflow: git tag v0.4.1 && git tag REL-v0.4.1 && git push origin v0.4.1 REL-v0.4.1. Plain version tags still trigger the four packaging workflows (.deb / RPM / OBS / AUR) via their v*.*.* globs; the REL marker is opt-in for the GitHub Release page only. Existing v0.3.0-RTM1-era releases stay published; the trigger change only affects future tags.
  • Release notes now include unreleased intermediate sections automatically. v0.4.0rc1's release page initially missed all of @Mic92's contributions (PRs #62-#65) because they were credited under ## [0.3.1] — a CHANGELOG section that was written but never tagged and released, so the awk extractor (which matched exactly one ## [<ver>] and stopped at the next ## [) skipped it entirely. The workflow now finds the most recent CHANGELOG section that corresponds to a real git tag (= a published release) and emits everything from the current section down to but not including that previous-published one, so credits in unreleased intermediate sections automatically surface in the next release page. The v0.4.0rc1 release page itself was patched manually via gh release edit to backfill the missing credits.

Fixed

  • install.bat no longer hangs at PS Expand-Archive on first boot (the agent never installs root cause). Add-MpPreference -ExclusionPath C:\OEM,C:\winpodx -ExclusionProcess rdprrap-installer.exe is now the very first step of install.bat (before DNS / RDP / firewall etc.), so nothing under our staging dirs ever gets real-time scanned. PS Expand-Archive replaced with Windows-native tar -xf (System32\tar.exe, ships in Win10 1803+) — bypasses the PowerShell engine entirely (no module load, no .NET assembly resolution, no AMSI hookpoints), so the PS-script analysis layer can't intercept extraction even on machines with stricter security tools. _probe_password_sync now honors WINPODX_REQUIRE_AGENT=1 (set by install.sh around its post-install block) and skips the probe entirely when the agent is down — the one host-side FreeRDP path PR #104 missed. Diff against v0.3.0 (last known-working baseline): install.bat's pre-extract code is essentially unchanged and the rdprrap zip blob hash is identical; what changed was the OEM bundle growing from 6 files to 13+ alongside the dockur image pin to a Windows 11 build with stricter Defender real-time policy — combination triggered the deadlock. OEM bundle 22 → 23.
  • Fresh install no longer kicks install.bat's autologon session via host-side FreeRDP fallback. _apply_runtime_fixes_to_existing_guest now requires the agent's /health to respond before invoking the apply chain; if it doesn't, migrate prints "Agent not yet up — Skipping runtime apply" and skips entirely. The OEM bundle on freshly-created pods already applies the same registry / service state at first boot, so this is a no-op for fresh installs. discover_apps honors WINPODX_REQUIRE_AGENT=1 and raises DiscoveryError(kind="agent_unavailable") instead of falling back to FreeRDP. wait_for_windows_responsive bumps its agent /health budget from 60 s → 180 s — install.bat OEM v22+ regularly takes longer than 60 s to reach its agent-spawn step on first boot, and the previous cap was missing /health by 30-60 s every smoke test. Same regression PR #82 (release/0.3.1) introduced when it removed the inst_cmp < (0, 1, 9) <= cur_cmp gate around the apply chain — pre-0.3.1 the chain only ran on a 0.1.x → 0.1.9 boundary crossing, so most upgrades didn't fire FreeRDP at all and the race never surfaced.
  • install.bat no longer dies mid-script when rdprrap activates TermService. In dockur's autologon flow, the Sysprep first-logon User session is itself managed through TermService — net stop TermService /y at the end of rdprrap-activate.ps1 killed the autologon session and install.bat's cmd.exe died with it, silently, with no error. The cycle is now SKIPPED in rdprrap-activate.ps1's OEM mode; install.bat does the cycle as its LAST action after every other step has committed (launchers, HKCU\Run, agent spawn, setup_done.txt). When the cycle then kills install.bat's session, all the setup work is already on disk; autologon retry creates a fresh user session with the new TermService loaded (termwrap.dll), HKCU\Run fires, agent starts cleanly. OEM bundle 21 → 22.
  • app refresh and migrate's interactive discovery prompt now retry on agent-respawn race. _apply_vbs_launchers (part of the apply chain) ends by spawning agent-respawn.ps1 detached — the respawn waits ~3 s, kills the running agent, then starts a new one. If app refresh (or the user accepting the migrate Run app discovery now? prompt) hit the agent during that kill-then-restart window, /exec returned Remote end closed connection without response and discovery deferred to pending. Both call sites now retry up to 3 times with 10 s spacing; the menu populates before install.sh exits and migrate's interactive path completes inline.
  • install.sh waits for the agent to settle between migrate and app refresh. A curl /health poll between the two steps gives the agent up to 60 s to come back after migrate's _apply_multi_session cycles TermService. Combined with the 3-attempt retry above, the menu reliably populates before install.sh exits instead of needing a follow-up CLI / GUI launch to fire pending-resume.
  • setup.log no longer self-locks on Add-Content from inside redirected PowerShell blocks. The HKCU\Run reg-add and agent-spawn PowerShell blocks redirect stderr to setup.log via 2>>"%SETUP_LOG%", and used to also call Add-Content -LiteralPath '%SETUP_LOG%' from inside. Windows holds the redirection handle exclusively → Add-Content's second open raised a sharing violation → the .NET exception then logged via the same stderr redirect → into setup.log itself, in a loop. Replaced every inner Add-Content with Write-Output and switched cmd-level redirect from 2>> to >>"%SETUP_LOG%" 2>&1 so PS stdout is the single writer.
  • Info screen no longer flashes "Overall: FAIL" during fresh-install warm-up. probe_agent_health, probe_guest_exec, and probe_guest_summary returned FAIL when the agent's /health or /exec timed out. Right after install the agent is up but slow (Sysprep just finished, cold caches), and these probes regularly hit timeout for the first 30-60 s. Now timeout-shaped errors return WARN("agent warming up or busy: ..."), and run_all() does dependency-aware skipping: if agent_health isn't OK, downstream probes (auth_ready, guest_exec, guest_summary) are returned with status="skip" rather than each one re-running and reporting its own variation of "agent unreachable". Single warm-up signal instead of a fan-out of cascade FAILs.
  • AgentTransport selection respects host-side token availability. /health is intentionally unauthenticated (the host needs to know if the agent is up before it has a token). The agent transport's /exec path however requires the host token; if the token file isn't readable yet (race during first install.sh run), dispatch() would pick AgentTransport based on green /health and then fail on the first /exec with token-missing. AgentClient.auth_ready() now returns (bool, detail) for whether _token() resolves; AgentTransport.health() consults it after the /health call and reports available=False if the token isn't ready. Default dispatch then falls back to FreerdpTransport instead of selecting agent and exploding later. Token itself is never exposed (bool + detail string only).
  • OEM bind mount no longer copies to ~/.config/winpodx/oem/ when the bundle is already user-writable. PR #95 made _find_oem_dir() always copy the OEM tree to a user-owned path; on systems where the bundle is already user-owned (curl install, source checkout, Nix profile — the majority case), dockur's in-container cp then failed with Permission denied because ~/.config/winpodx/oem/ inherited a restrictive parent mode. Branch on os.access(bundle_oem, R_OK | W_OK): if the user owns the bundle, return it directly (no copy — Podman's :Z relabel works fine on user-owned paths). Otherwise fall back to the user-space copy with explicit chmod 0644 / 0755 so dockur can traverse + read regardless of host umask.
  • Defense-in-depth hardening sweep. Config TOML revalidates on load (Config.load() re-runs __post_init__() so a tampered winpodx.toml with out-of-range values gets clamped); _ensure_config() generates a password + UTC timestamp at create time (no empty-credential window during install.sh's first run); PasswordFilter redacts FreeRDP's /p:<password> switch in addition to key=value patterns; password rotation reorders generate_compose before cfg.save so a compose-generation failure leaves disk on the OLD password (no half-state). install.bat is now ASCII-only end-to-end (em-dash → hyphen across all comments) and uses absolute path "%SystemRoot%\System32\tar.exe" for tar (no PATH-hijack vector). New static checks under tests/test_oem_install_bat.py pin these invariants.

Added

  • Star History chart in README (light/dark variants via <picture>). Click goes to the interactive chart on star-history.com.

Contributors

Thanks to @Mic92 (Nix flake packaging, default-image switch, FreeRDP argv[0] match, Wayland XWayland gate — PRs #62-#65) and @pgarciaq (SELinux OEM bind mount fix — PR #95).

[0.4.0] - 2026-05-03

A significant stability + UX release focused on the install / migrate paths. Container recreates are no longer triggered by dockur's :latest push cadence (image is SHA-pinned), every PowerShell console flash on app launch and agent autostart has been eliminated, fresh installs honestly wait for Windows to be ready, and multi-session activation is now hands-free via apply-fixes / migrate. SELinux-enforcing systems (Fedora) work out of the box. RTM-suffixed pods (0.3.0-RTM1) migrate cleanly. The winpodx app refresh discovery race is closed in three layers. Contributing policy + lifecycle docs added.

Fixed

  • Discovery error classification + GUI dialog no longer mislabels session-disconnect failures as "Pod Not Running". Pre-this-fix the classifier mapped any error message containing "no result file" or "auth" to DiscoveryError(kind="pod_not_running"). That over-fired on session-side errors where the pod was very much running -- specifically ERRINFO_LOGOFF_BY_USER (0x0001000C) and ERRINFO_RPC_INITIATED_DISCONNECT (0x00010001). Both surface a "no result file written" outer message because the script never finished writing -- but they mean the FreeRDP RemoteApp session was terminated mid-call by the guest, common during multi-session activation's TermService cycle. The GUI then offered a "Start Pod" button on a perfectly running pod (kernalix7 hit this 2026-05-03 right after a fresh install). Fix: _classify_channel_error() now distinguishes three states explicitly -- transport-layer failure (connection refused / connect_transport_failed / agent unavailable) → pod_not_running; session-layer disconnect (LOGOFF / RPC_INITIATED_DISCONNECT) → new session_disconnected kind; everything else → script_failed. The GUI handler shows a "Discovery Session Disconnected" dialog with a Retry button for the new kind, instead of a misleading "Pod Not Running" / "Start Pod" prompt.
  • winpodx pod start no longer fails with lsetxattr: operation not permitted on Fedora / SELinux-enforcing systems. Three bugs chained: (1) bundle_dir() used any() for its marker check (scripts/, config/, data/) — only one had to exist for a directory to qualify as a winpodx bundle, so a stale /usr/share/winpodx/config/ skeleton (left behind after an RPM uninstall because agent_token.txt was untracked) hijacked path resolution even on a curl-only install. (2) _find_oem_dir() returned the bundle path directly; on RPM/wheel installs that's /usr/share/winpodx/config/oem/ (root-owned), and the compose template mounted it with :Z for SELinux relabeling — which fails with lsetxattr: operation not permitted because rootless Podman can't relabel root-owned files. (3) stage_token_to_oem() wrote agent_token.txt into the bundle OEM dir, so on RPM installs the runtime token landed under /usr/share/winpodx/ (untracked by RPM, survives rpm -e) — feeding back into bug 1. Fixes: _find_oem_dir() now copies the bundle OEM tree into ~/.config/winpodx/oem/ (user-owned, :Z-relabel-able) and returns that path; the agent token writes there too via the same indirection. bundle_dir() marker check changed from any() to all() so partial leftovers no longer match. (Thanks @pgarciaq — PR #95, fixes #93.)
  • install.bat now spawns the agent directly at end-of-script, not just registers it for future logons. HKCU\Run only fires once per user logon, and the autologon User session has already logged in by the time install.bat (FirstLogonCommands) executes -- so all our HKCU\Run registration was setting up was the next session's agent. The current session that install.bat runs in had no agent at all until the user (or some host RDP probe) opened a new session. install.sh's wait-ready phase 3 timed out at /health on every fresh install (kernalix7 reported OK Windows ready (17:57) with the agent-missing warning, then migrate's apply chain failed with FreeRDP rc=12 LOGOFF_BY_USER and rc=1 RPC_INITIATED_DISCONNECT errors -- consistent with running against a Windows that was healthy but had no agent). Fix: install.bat ends with a PowerShell Start-Process block that spawns the agent in this session via wscript.exe + hidden-launcher.vbs (or direct PS fallback if the wrapper didn't survive copy, with the same setup.log diagnostic as the HKCU\Run registration). Agent /health is up by the time install.bat returns -> phase 3 succeeds within seconds -> migrate's apply chain runs against a healthy agent -> no FreeRDP-fallback cascades. The HKCU\Run registration stays in place so future user logons (re-RDP after host restart, multi-session app launches) also get the agent. OEM bundle 20 -> 21.
  • check_rdp_port now does a real RDP handshake instead of just TCP-accept. dockur's QEMU slirp accepts TCP forwards on the host's mapped RDP port the moment the QEMU process launches -- before the Windows guest's TermService is anywhere near up. Pre-this-fix the helper just did socket.create_connection, so a fresh install in mid-ISO-download (or any pod that booted QEMU but hadn't brought Windows to the RDP listener yet) would report "RDP port open" the instant the container started. install.sh's wait-ready skipped past phase 2 in 0 seconds, hit phase 3 (which after PR #91 returns True with a warning when /health misses), and then ran migrate's apply chain against a Windows that wasn't there -- surfacing as a cascade of FreeRDP rc=147 / "Connection reset by peer" failures. User caught this 2026-05-02 21:53 on a fresh install where the [container] log showed Windows ISO at 7% downloaded while the host had already moved on. Fix: check_rdp_port now sends a minimal X.224 Connection Request (TPKT-wrapped) and reads back 2 bytes. A real RDP server responds with a TPKT (first byte 0x03, second byte 0x00); QEMU-forwarding-with-no-guest gets us the SYN-ACK from slirp's TCP stack but the recv times out / returns EOF. Distinguishable in ~1 second. New helper check_tcp_port exposes the old TCP-accept-only behavior for the one place that legitimately wants it (the VNC liveness check inside recover_rdp_if_needed -- VNC doesn't speak RDP, so the X.224 probe would naturally fail there).
  • wait_for_windows_responsive no longer deadlocks install.sh phase 3 when agent /health doesn't come up. Pre-this-fix the helper required BOTH an open RDP port AND agent.ps1 /health to respond before returning. Any path that left the agent dead -- HKCU\Run mis-registered, autologon mid-cycle, agent token mismatch, port-mapping blip, install.bat staging hiccup -- caused the helper to spin out the full caller-supplied timeout (often 3600 s on install.sh's winpodx pod wait-ready --timeout 3600). User reported sitting at [3/3] Waiting for Windows activation... for 30+ minutes 2026-05-02 on a fresh install where the desktop was visible via VNC. Now: stage 1 (RDP port) is required as before; stage 2 (agent /health) is best-effort, capped at min(timeout, 60s). If /health responds, return True with the agent path live. If it doesn't, return True anyway (Windows IS responsive -- host code falls back to FreeRDP RemoteApp via transport.dispatch) plus a warning log pointing at C:\winpodx\setup.log for diagnostics. Only RDP-port-never-opens returns False. Same transport.dispatch agent-first preference everywhere else, just no longer blocking on it during boot probe.
  • install.bat staging gained per-file verification + structured fallback so the "Cannot find script file" wscript dialog can no longer block fresh install. Pre-OEM-v20 install.bat copied the 5 launcher files to C:\Users\Public\winpodx\launchers\ with 2>nul suppressing any error and registered HKCU\Run\WinpodxAgent / WinpodxMedia via reg add pointing at those paths unconditionally. If the copy silently failed (network share blip mid-Sysprep, AV interference, anything), the registry entries pointed at non-existent files; on the next user logon, wscript.exe popped a "Cannot find script file" dialog that blocked the session indefinitely (kernalix7 hit this on a fresh install 2026-05-02 ~19:58). The reg add itself was also fragile: cmd-parsing of \"escaped quotes\" inside /d "..." survives but reg.exe stores literal backslash-quote pairs, and CommandLineToArgvW at logon evaluates them differently than a real quoted argument. Three-way fix: (1) per-file copy verification — each copy /Y is followed by an if exist check with the result logged to C:\winpodx\setup.log (launcher OK: / launcher FAILED: per file). (2) HKCU\Run registration moved to a single PowerShell block via Set-ItemProperty — clean .NET string storage, no cmd-quoting hell. (3) Existence-gated fallback — if hidden-launcher.vbs didn't survive copy (LAUNCHERS_OK unset), HKCU\Run is registered with the legacy direct powershell.exe -WindowStyle Hidden -File ... form. That fallback flashes a brief PS console (~50 ms) but at least starts the agent; without it the user got a blocking dialog and never saw the desktop. The same setup.log records which path was chosen so apply-fixes can probe it later. OEM bundle 19 → 20.
  • VBS files now ASCII-only — Windows Script Host no longer fails to parse them on non-en-US Windows installs. config/oem/hidden-launcher.vbs and config/oem/launch_uwp.vbs had em-dash characters in their leading comments. wscript.exe reads .vbs files with the system default codepage (e.g., CP-949 on Korean Windows). The em-dash UTF-8 byte sequence (0xE2 0x80 0x94) decodes as a multi-byte sequence ending mid-statement → "Windows Script Host" error dialog at HKCU\Run firing time → install hangs at Sysprep. Same root cause as PR #88 fixed for .ps1 files; this PR completes the audit by ASCII-ifying the .vbs files. The full set of script files (5 PS + 2 VBS) is now strict ASCII.
  • agent-respawn.ps1 legacy fallback removed. The old code had a Test-Path hidden-launcher.vbs check with an else-branch that fell back to Start-Process powershell.exe -WindowStyle Hidden -File agent.ps1 — i.e., the same broken pattern PR #58 fixed for HKCU\Run\WinpodxAgent. Post-OEM-v13 the wrapper is always staged (install.bat + _apply_vbs_launchers both push it), so the fallback only fires on filesystem corruption or a manual delete — but when it did fire, it surfaced a console flash on every apply-fixes cycle. Replaced with exit 1 if the wrapper is missing: HKCU\Run will refire (via wscript+hidden-launcher.vbs) on the next user logon and bring the agent back without the flash. No path that produces a flash remains.
  • **apply-fixes no longer cycles TermService
  • First-boot app discovery is now consistent — no more "menu populated last install, missing UWPs this install" stochasticism. Three race conditions converged on a flaky winpodx app refresh at install.sh time, and they fired independently so any one of them produced an empty / partial result while the others might pass: (1) Sysprep-just-finished AppX deployment was still running when discovery enumerated UWPs, returning a partial Get-AppxPackage view; (2) Start Menu indexer hadn't propagated all .lnk files yet; (3) install.sh runs migrate (which can cycle TermService for multi-session activation) immediately before app refresh, so the agent could be mid-respawn when discovery's /exec fired. Three layers now collapse the variance:
    • Guest-side readiness gate (scripts/windows/discover_apps.ps1): waits for AppXSvc.Status -eq 'Running' AND ProgramData Start Menu .lnk count > 0 across 3 consecutive 1 s samples (catches the StartPending → Running blip), bounded at 60 s. Logs "stable, proceeding" / "budget exceeded, proceeding with potentially partial state" to stderr so guest-side timing is visible in the apply-fixes / refresh output.
    • Host-side transport readiness (core.discovery.discover_apps): before invoking the script, polls agent /health and RDP port up to 30 s. Without it, discovery firing into a freshly-killed agent (post-multi-session-activation TermService cycle) bails with channel-failure cascade on a perfectly-good pod.
    • Host-side retry-on-empty: if the first pass returns suspiciously few apps (< 5 total OR 0 UWP — both impossible on a stock Win11 install), waits 8 s and retries once. Picks the larger result so retry never regresses.
    • Default discovery timeout bumped 120 s → 180 s to absorb the new readiness gate. Retry-on-empty is bounded (one retry max) so a stripped-down image with genuinely few apps doesn't loop.
  • apply-fixes no longer cycles TermService (and kills the agent) when rdprrap is already patched but the activation marker is stale. PR #81 made _apply_multi_session self-heal by spawning rdprrap-activate.ps1 whenever the .activation_status marker said anything other than enabled. That correctly triggered for pods with no marker (pre-OEM-v15) or a not-activated / installer-failed marker. But it also triggered for pods like kernalix7's where the marker said installer-failed from a partial OEM-time apply — yet ServiceDll had successfully been flipped to termwrap.dll anyway and multi-session was working. Re-firing activation in that state cycled TermService unnecessarily, killed the agent's RDP session, and (because HKCU\Run only fires at user logon) left the agent dead until the user opened an app. Repeat apply-fixes calls (each install.sh --main upgrade re-runs migrate's apply chain) re-killed the agent each time. Fix: before spawning the detached activator, also probe HKLM\SYSTEM\CurrentControlSet\Services\TermService\Parameters\ServiceDll. If it already matches termwrap, write enabled to the marker and return — this reconciles the stale marker so subsequent apply-fixes calls hit the fast path, and importantly does not cycle TermService.
  • HKCU\Run\WinpodxMedia no longer flashes a black PS console on every app launch. Pre-OEM-v19 install.bat registered media_monitor.ps1 (USB auto-mapping background process) with bare powershell.exe -WindowStyle Hidden ...-WindowStyle Hidden is honored after conhost briefly allocates a console for the child, so a ~50ms black flash slipped through. With multi-session enabled, every app launch creates a new RDP session that re-fires HKCU\Run from scratch, so the user saw the flash on every single launch — visible-but-unreadable, classic Hidden-flag race. Fix: install.bat now registers the entry as wscript.exe hidden-launcher.vbs powershell.exe ... media_monitor.ps1 (same wscript+SW_HIDE wrapper that PR #58 applied to WinpodxAgent). Migrate path: _apply_vbs_launchers rewrites HKCU\Run\WinpodxMedia if an unwrapped entry exists, so existing pods get the fix on next winpodx pod apply-fixes (or winpodx migrate). Takes effect from the next RDP session/app launch — the current session's already-running media_monitor stays alive but doesn't re-spawn, so doesn't flash again. OEM bundle 18 → 19.
  • cfg.pod.image now defaults to a SHA-pinned dockur image; migrate aligns existing pods. Before this fix, cfg.pod.image defaulted to docker.io/dockurr/windows:latest (or ghcr.io/dockur/windows:latest for installs ≤ v0.3.0). Every podman-compose up re-resolved the tag against whatever dockur had pushed since. When the resolved digest changed (frequent — dockur's release cadence is daily-ish), podman-compose treated the spec as different and recreated the container. kernalix7 caught this on 2026-05-02 mid-bug: dockur had pushed a :latest containing a proc.sh substring failure (proc.sh: line 137: -1: substring expression < 0), and a routine install.sh --main upgrade triggered a full container rebuild + 7.5GB ISO redownload + Sysprep reset on a working pod that had nothing wrong with it. Pin: cfg.pod.image defaults to docker.io/dockurr/windows@sha256:20b398ab935465f97ec8ab06489f7a85a5ad58e74e036ce66cc3c9172e7dbea8 (queried from Docker Hub registry at release time and stored as DOCKUR_IMAGE_PIN in core.config). Migrate's "already current" + cross-version paths both call a new _ensure_canonical_image_pin step that rewrites cfg.pod.image + regenerates compose.yaml so existing pods land on the same canonical pin a fresh main install would write — one container recreate on the next pod start (~30 s, storage volume preserved → no ISO redownload, no Sysprep), then immune to dockur :latest churn going forward. Idempotent: re-running migrate on an already-pinned config short-circuits before any rewrite.
  • winpodx setup --update-image opt-in dockur version bump. New flag on the existing setup subcommand. Pulls docker.io/dockurr/windows:latest via the user's container backend, resolves the now-local image's repo-digest, writes it back to cfg.pod.image, and regenerates compose.yaml. Surfaces the new pin in the output so users can verify what they're locking to. Same recreate cost as the migrate path on next pod start (~30 s, volume preserved). The only path that ever pulls a fresh :latest — every other code path uses the bundled or persisted pin.

[0.3.1] - 2026-05-02

Maintenance release focused on making the v0.3.0-RTM1 → main migration path actually heal the multi-session activation gap, without forcing a container recreate. Also collapses the OEM-time and runtime rdprrap activation paths onto a single script.

Added

  • Nix flake. nix run github:kernalix7/winpodx, nix profile install github:kernalix7/winpodx, or 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; docker and libvirt remain opt-in. A devShell exposes the same runtime tools plus ruff and mypy with src/ on PYTHONPATH. README (en + ko) gains a Nix install section. (Thanks @Mic92 — PR #65.)

Changed

  • paths.bundle_dir() — single resolver for shipped resource trees. Seven call sites previously hand-rolled __file__.parent.parent… walks with inconsistent fallbacks: discovery script lookup, OEM bundle version, OEM directory for compose mounts, VBS launcher migration, debloat script (CLI + GUI), data assets, and rdprrap version pin. Each one drifted independently — discovery already broke once with an off-by-one parent count (see #?), the OEM dir resolver missed wheel installs, the data-asset lookup couldn't find the icon under Nix. Replaced with a single bundle_dir() resolver in winpodx.utils.paths that searches $WINPODX_BUNDLE_DIR env var → source checkout → sys.prefix/share/winpodx~/.local/bin/winpodx-app. Each candidate must contain at least one of scripts/, config/, data/ to qualify, falling back to the source-checkout guess for stable error messages. Tests for affected helpers now monkeypatch bundle_dir directly rather than juggling HOME + sys.prefix + __file__. (Thanks @Mic92 — PR #65.)

Fixed

  • winpodx migrate no longer silently drops apply-fixes on RTM-suffixed pods (and on every cross-version upgrade past 0.1.9). Two bugs converged: (1) _version_tuple("0.3.0-RTM1") returned (0, 3) instead of (0, 3, 0) because the parser stopped at the first non-int segment (split('.') produced ['0', '3', '0-RTM1']; int('0-RTM1') raised). (2) The cross-version branch gated apply-fixes on inst_cmp < (0, 1, 9) <= cur_cmp — a 2026-Q1 back-port window that no longer makes sense now that the apply chain has grown to cover multi-session auto-activate / vbs_launchers / oem_runtime_fixes. Combined effect: a v0.3.0-RTM1 user running install.sh --main hit migrate, took the cross-version branch (because the broken tuple compare made (0,3) >= (0,3,0) false), then skipped apply-fixes (because (0,3) < (0,1,9) was also false). Their pod stayed on whatever runtime fixes RTM1 originally applied — multi-session activation included — until they ran winpodx pod apply-fixes manually. Fix: parser now extracts leading digits per segment so 0.3.0-RTM1, 0.3.0rc1, 0.3.0+dev all parse to (0, 3, 0). Cross-version gate removed; the chain is idempotent so running on a pod that's already current is a marker probe + no-op return per helper.
  • winpodx pod apply-fixes auto-activates rdprrap multi-session if it isn't already on. Multi-session is core winpodx functionality — without it the "Select a session to reconnect to" dialog appears on every multi-app launch. Pre-this-fix, the apply chain's multi_session step was a status probe only (PR #77, defensive against the inline-activation hang where mid-apply rdprrap-conf killed the agent's session and timed out /exec). PR #80 made activation safe by spawning rdprrap-activate.ps1 detached — the /exec response returns before TermService cycles — so the apply chain can now self-heal: if .activation_status reads enabled, no-op (no /exec round-trip beyond the one we're in, no disconnect, no churn); otherwise queue the detached activator. Apply chain reorders so vbs_launchers (which stages rdprrap-activate.ps1 + hidden-launcher.vbs) runs before multi_session. Cost when activation is needed: RDP sessions briefly disconnect (~10 s) while TermService cycles, then reconnect with multi-session active. This is the same one-time cost the OEM-time path pays, just deferred to migration time for pre-OEM-v17 pods.
  • winpodx pod multi-session on activates rdprrap on existing pods without a container recreate. Activation needs rdprrap-installer install plus a net stop/start TermService cycle so the patched ServiceDll is loaded — but the cycle kills every active RDP session, including the agent's own user session, so an inline /exec couldn't drive it (the agent died before the response could return). Pre-this-fix the only way an existing v0.3.0-RTM1 pod with a failed OEM-time activation could pick up the patch was to podman rm -f the container and re-Sysprep — a couple hundred MB of guest-disk churn for what is, on a working install, a 30 s registry tweak. Now: a new rdprrap-activate.ps1 script (idempotent — falls back to extracting the bundled rdprrap-*.zip from C:\OEM\ if the installer binary isn't staged, retries up to 3×, verifies the ServiceDll flip post-restart, writes the same .activation_status marker install.bat OEM v15+ writes) is staged into C:\Users\Public\winpodx\launchers\ and winpodx pod multi-session on spawns it detached via wscript+hidden-launcher.vbs (mirrors agent-respawn pattern). The /exec call returns immediately with "OK: activation queued"; the user reconnects after the brief disconnect; the agent auto-starts via HKCU\Run; subsequent winpodx pod multi-session status (now a marker probe identical to the apply-fixes status surface) confirms enabled. winpodx pod apply-fixes pushes rdprrap-activate.ps1 alongside the other VBS launchers, so existing v0.3.0-RTM1 pods get it on the next migrate without recreate. (status no longer shells out to rdprrap-conf.exe; disable still does, since disable just clears the registry patch and doesn't need TermService cycled.) install.bat's inline ~80 lines of installer-retry / TermService-cycle / ServiceDll-verify / marker logic also collapse onto the same script — install.bat now extracts the SHA-pinned bundle and delegates to rdprrap-activate.ps1 (called synchronously, without -Detached, since at OEM time the cmd.exe parent is in the local console session and TermService cycle doesn't tear it down). Single source of truth: a fix to activation behavior now benefits both OEM-time and runtime paths without drift. OEM bundle 16 → 18.
  • winpodx pod apply-fixes (and the migrate apply chain) now auto-respawns the running agent under the new wscript wrapper. The previous version of the vbs_launchers migration step staged the new wrapper into HKCU\Run but the change only took effect on the next user-session logon, so users running apply-fixes against a live pod kept seeing the autostart PS-console flash from the OLD agent instance until they manually restarted. The migration now ships a fourth helper (agent-respawn.ps1) and detaches a Start-Process wscript.exe ... agent-respawn.ps1 at the end of the apply payload. The respawn waits 3 s (giving the /exec response time to land at the host), kills the running agent.ps1 process via Win32_Process cmdline match, waits 800 ms for port 8765 to release, then starts a fresh agent under wscript+hidden-launcher.vbs. /health drops for ~3-4 s and recovers under the new wrapper. OEM bundle 13 → 14.
  • UWP launches via the new wscript / launch_uwp.ps1 path no longer silently fail. The first draft of launch_uwp.ps1 (shipped in OEM v13) tried two PowerShell forms that don't work against IApplicationActivationManager: a PS-level [InterfaceType]([Impl]::new()) cast, and dynamic method dispatch on the COM object. Both fail because the interface inherits IUnknown only, not IDispatch — PS can't auto-discover the methods, and a PS-level cast on a COM RCW doesn't carry the QueryInterface the CLR needs. Calculator, Settings, Terminal, Notepad UWP and friends were exiting 1 with the actual error logged only to %LOCALAPPDATA%\winpodx\uwp-launcher.log. Moved the activation into a small Add-Type'd C# helper class — the cast happens inside compiled C# scope where the managed cast resolves to a real COM QueryInterface. Existing pods get the fixed script via winpodx pod apply-fixes.
  • App launches no longer silently die after the CLI parent exits. Two paths could spawn a doomed FreeRDP that vanished without explanation: (1) stderr=subprocess.PIPE left the parent process holding the read end, so once the CLI exited the next stderr write SIGPIPE-killed the detached client. The launcher now writes stderr to $XDG_RUNTIME_DIR/<app>.stderr so the session outlives the parent and the tail stays inspectable; RDPSession.stderr_tail reads the last 2KB of that file lazily so existing callers keep their interface. (2) On a pure Wayland session without $DISPLAY, xfreerdp (the only client with working RAIL — sdl-freerdp lacks RAIL per FreeRDP #9078, wlfreerdp is deprecated with broken RAIL repaint) failed post-detach with "failed to open display". launch_app now refuses to spawn that combination and raises an actionable error pointing at XWayland (compositor built-in or xwayland-satellite for niri / river). (Thanks @Mic92 — PR #64.)
  • is_freerdp_pid() no longer adopts unrelated processes as live RDP sessions. The check used to look for b"freerdp" or b"xfreerdp" anywhere in /proc/<pid>/cmdline, which falsely matched scripts under paths like ~/freerdp-notes/run.sh, pytest invocations passing --deselect=test_freerdp_pid, or any tool whose arguments mentioned freerdp in passing. Stale .cproc markers tied to those PIDs never got reaped because winpodx kept thinking the unrelated process was the FreeRDP it spawned. Now parses cmdline by null bytes and compares argv[0]'s basename against the binaries find_freerdp() actually launches (xfreerdp{,3}, sdl-freerdp{,3}, plus the flatpak run com.freerdp.FreeRDP fallback). Two regression tests cover the substring-match cases that used to slip through. The downstream PID-reuse test was rewritten without bash -c "exec -a … sleep 30" so it stops failing on systems with multi-call coreutils. (Thanks @Mic92 — PR #63.)

Changed

  • Default container image switched to docker.io/dockurr/windows:latest (was ghcr.io/dockur/windows:latest). Aligns with upstream's official compose / docker run reference (the upstream README and example compose.yml both use dockurr/windows). Same image — verified by digest. Some users hit token / 4xx errors on the GitHub Container Registry path; Docker Hub serves the canonical artifact reliably. Existing installs are not auto-migrated: ~/.config/winpodx/winpodx.toml persists the resolved value, so users who already ran winpodx setup keep the old reference. To pick up the new default, either drop the image = "ghcr.io/..." line from winpodx.toml and re-run winpodx setup (regenerates compose.yaml), or edit ~/.config/winpodx/compose.yaml directly. (Thanks @Mic92 — PR #62.)
  • Zero PowerShell-window flashes — guest path goes through hidden VBS launchers and the agent transport everywhere a flash was still possible. Three fixes converge:
    • Agent autostart via hidden-launcher.vbs. HKCU\Run used to register powershell.exe -WindowStyle Hidden -File C:\OEM\agent.ps1; the Hidden flag is honored AFTER PowerShell allocates its conhost so a brief PS console flashed for ~50ms on every user logon. The new VBS wrapper is GUI-subsystem (no console of its own) and WshShell.Run with intWindowStyle=0 propagates SW_HIDE to CreateProcess so the spawned PowerShell starts windowless.
    • UWP launches via IApplicationActivationManager. The legacy /app:program:explorer.exe,cmd:shell:AppsFolder\<AUMID> invocation showed an explorer.exe RemoteApp window for ~300ms before the UWP frame appeared — that's the "PowerShell-like flash" users saw on Calculator / Settings / Terminal launches. RemoteApp now invokes wscript.exe launch_uwp.vbs <AUMID>, which calls IApplicationActivationManager::ActivateApplication directly. The UWP frame becomes the visible RemoteApp window with no transition; ~300ms faster too.
    • Remaining run_in_windows callers routed through the agent transport. core.updates, core.daemon.sync_windows_time, cli.pod.multi-session, cli.main.debloat, and the GUI Tools-page debloat handler now go through winpodx.core.windows_exec.run_via_transport, which prefers the v0.3.0 agent's /exec (CreateNoWindow=$true) and only falls back to FreeRDP RemoteApp when /health doesn't answer. Password rotation (rule #6) and the winpodx pod sync-password rescue path stay on FreeRDP intentionally — they need direct credential auth.

OEM bundle bumps to 13 (new VBS files staged into C:\Users\Public\winpodx\launchers\ so the agent can rewrite them later without admin). Migration for existing 0.3.0-RTM1 pods is automatic: the new _apply_vbs_launchers apply step pushes all three files via the agent's /exec and rewrites HKCU\Run\WinpodxAgent in one round-trip, then apply_windows_runtime_fixes chains it after multi_session. Trigger with winpodx pod apply-fixes (or winpodx migrate after upgrading) — no container recreate needed. Autostart change takes effect on the next user-session logon (or winpodx pod restart); UWP launch fix is picked up immediately by the host's next launch.

Added

  • Hybrid discovery filter — essentials always shown, system shims hidden by default. Auto-discovery on a stock Windows 11 install used to surface ~45 entries with both classes of noise: OS staples like File Explorer / Calculator / Settings missing because they aren't enumerated as Start Menu .lnk files, and system shims (LicenseManagerShellExt, WindowsPackageManagerServer, DesktopPackageMetadata, microsoft-store-server, …) cluttering the grid. The filter now ships a curated essentials allowlist (synthesizes a stub when the scan misses an essential) and a noise denylist (auto-stamps hidden = true in app.toml so the GUI grid filters them). User overrides win — toggling Hide / Show on a tile records the preference in the same TOML and survives the next discovery sweep. discover_apps.ps1 also emits the three essentials explicitly with their real Windows icons (File Explorer's icon comes from C:\Windows\explorer.exe, Calculator + Settings from their AppxManifest Square logos) so users see authentic Windows iconography rather than a generic letter avatar.
  • Win32 launch args. RDP RemoteApp builder honors a per-app args string from app.toml and forwards it as the FreeRDP cmd: field. Solves the longstanding "explorer.exe RemoteApp shows nothing" gotcha — the File Explorer essential ships args = "shell:MyComputerFolder" so launching it opens the This PC view in a normal window instead of trying to take over as the user shell. Existing apps with args = "" are unaffected.
  • Per-app .desktop descriptions. Discovery now extracts a one-line description from the guest (.lnk Comment field, exe ProductName, or UWP <VisualElements Description>) and lands it as the Comment= key in each app's .desktop file. Replaces the blanket Comment=Windows application via winpodx stamp every entry shared, so users see the actual app description in their menu / file manager tooltip. Apps without a discovered description still get the generic stamp.
  • Known-good UWP allowlist. UWP packages whose DisplayName resolves to an ms-resource: indirection that PowerShell can't expand in a non-interactive session (Calculator, Terminal, Paint, Snipping Tool, Camera, Alarms, Maps, Sound Recorder, Notepad UWP, Sticky Notes, Get Help, Your Phone, To Do, Settings) used to fall back to their dotted PackageFamilyName and get filtered out as junk by the host's UWP-dot-only check. They're now allowlisted explicitly so legitimate Microsoft.* packages aren't dropped along with the AAD/BrokerPlugin-style shims.
  • GUI Apps page "Hidden (N)" toggle. Hidden entries collapse by default; the count chip in the toolbar tells the user how many got filtered. Clicking the chip expands the grid to include them so the user can promote any that the denylist over-classified.
  • Demo screenshot in README hero. docs/images/demo.png (Windows About / Performance Monitor / PowerShell each in their own Linux window alongside the winpodx Apps grid) now ships at the top of the README so first-time visitors see what the integration looks like.

[0.3.0] - 2026-04-30

Major release — modular core, HTTP guest agent, and a comprehensive health-check surface. Replaces the FreeRDP RemoteApp pipeline as the default host→guest command channel.

Background

v0.2.2 / v0.2.2.1 were an earlier attempt at these features that landed broken on real installs (PS-window storms, "Another user is signed in" dialogs, install timeouts, RST from /exec because compose was missing the 8765 port mapping). main was rolled back to v0.2.1 on 2026-04-29 and the agent + transport feature redesigned from scratch with explicit anti-goals (see docs/AGENT_V2_DESIGN.md). v0.3.0 is that redesigned implementation; the 0.2.2.x tags have been deleted to avoid confusion — go directly from v0.2.1 to v0.3.0-RTM1.

Added

  • HTTP guest agent (rev4). agent.ps1 runs inside Windows on 127.0.0.1:8765, bound via +:8765 so QEMU's user-mode NAT can reach it. Bearer-authed /exec (base64-encoded PowerShell payloads) replaces FreeRDP RemoteApp as the default host→guest command channel; /health stays unauthenticated for the readiness probe. Child PS spawned via [Diagnostics.Process] + CreateNoWindow=$true + async ReadToEndAsync — no PS-window flashes, no pipe-buffer deadlocks. Token delivered via the OEM bind mount (mode 0600 on host, gitignored).
  • Transport ABC v1 (core/transport/{base,agent,freerdp,dispatch}). dispatch() prefers the agent and falls back to FreeRDP when /health doesn't answer. Password rotation explicitly never routes through Transport (rule #6 in docs/TRANSPORT_ABC.md) — rotation owns its own credentials and calls run_in_windows directly to avoid a bootstrap loop on stale-password recovery.
  • winpodx check health probes. New CLI command runs a fast multi-source health audit and prints one line per probe with OK / WARN / FAIL / SKIP and per-probe duration. --json emits machine-readable output for scripting. Exit code is 0 unless any probe is FAIL. Probes:
    • pod_running, rdp_port, agent_health — surface bring-up state
    • guest_exec — POSTs /exec with a trivial Write-Output ok and verifies rc=0 + stdout="ok". Proves the host→guest channel actually round-trips, not just that /health answers
    • guest_summary — single /exec returning Windows version + uptime + current user + active session count + C: free space
    • oem_version, password_age, apps_discovered, disk_free — host-side state
  • GUI Info page Health card with auto-refresh. The Info page gains a top "Health" section that renders each probe with a coloured status badge plus the overall verdict. While the page is visible the card auto-refreshes every 30 s; off-page the timer is paused so the guest isn't polled while idle.
  • Sidebar transport indicators. The pod chip in the top bar now shows two extra letter dots — A (guest agent) and R (RDP port) — that turn green when reachable and red when not. Hover surfaces "agent OK (version)" / "host→guest commands fall back to FreeRDP RemoteApp" tooltips so the user sees at a glance which channel will service the next launch. Updated by the existing 15 s pod-status timer.
  • install.sh ref selection. --main installs from origin/main (development), --ref TAG installs from a specific git ref / release tag. Default with no flag uses the latest GitHub Release. Added together with the RTM-only release gate so rapid-iteration tags between RTM points don't trigger AUR / OBS / Debian publishes that overwrite working installs.

Fixed

  • Discovery script path off by one. _ps_script_path walked four .parents and resolved to <root>/src/scripts/windows/discover_apps.ps1, which never exists in any layout. Walked five now so the resolution lands on the actual <root>/scripts/windows/ directory; clicking GUI Refresh stops popping the "Pod Not Running" dialog when the pod is fine.
  • GUI misclassified script_missing as pod_not_running. _looks_like_pod_down matched the substring "pod" inside install paths like winpodx-app/..., so any DiscoveryError with the install path in its message was routed to the wrong dialog. The RefreshWorker now reads the explicit DiscoveryError.kind first and only falls back to the substring heuristic when no kind is set.
  • Agent /exec returned rc:null after clean child exit. PowerShell's Start-Process -PassThru + WaitForExit(timeout) can leave $proc.ExitCode as $null even when the child exited normally. The agent (rev4) now coerces the null to 0 at the source and the host AgentClient tolerates rc:null as 0 so existing pods on rev2 / rev3 stay functional.
  • Agent flashed a PowerShell window for every /exec. Start-Process -NoNewWindow from a hidden parent (-WindowStyle Hidden via HKCU\Run) re-creates a console for fast-exiting children. agent.ps1 (rev4) now spawns via [Diagnostics.Process] + ProcessStartInfo with CreateNoWindow=$true and UseShellExecute=$false; stdio is drained via async ReadToEndAsync to avoid pipe-buffer deadlocks. WINPODX_OEM_VERSION 11 → 12 so the install path picks up the new agent on the next pod recreate.

[0.2.1] - 2026-04-28

Minor bump (0.2.0.x → 0.2.1) — bundled UX work: install never abandons partial state, GUI logs surface winpodx's own log live, GUI greets first-time users with a system check.

Added

  • utils.pending resume system. New ~/.config/winpodx/.pending_setup marker tracks which install.sh steps couldn't complete (wait_ready / migrate / discovery). The next CLI invocation (any subcommand other than version/help/uninstall/config/info) and every GUI startup picks up the marker and runs the missing steps in canonical order. Each step removes itself from the marker on success; the file is deleted once empty. 10 unit tests cover ordering, idempotency, partial completion, and the "guest still booting → don't waste retries on later steps" guard.
  • GUI Quick Start dialog on first launch. A one-shot welcome modal shows a 5-bullet snapshot — backend / FreeRDP / pod state / RDP listener / discovered apps count — and notes whether resume is running in the background. Dismiss writes ~/.config/winpodx/.welcomed so returning users aren't pestered.
  • GUI Logs page auto-tails the winpodx app log. Navigating to Tools/Terminal now starts a tail -F ~/.config/winpodx/winpodx.log stream by default so users see internal program logs (apply / probe / refresh / pod state transitions) alongside the existing on-demand container log buttons. The streamer is killed automatically when leaving the page.

Changed

  • install.sh wait-ready timeout 1800s → 3600s. Extends the budget to one hour so a fresh install on slow hardware (Windows ISO download + Sysprep + OEM apply on first run) can finish in-line instead of bailing out and leaving work for the resume hook. The resume hook still picks up anything that exceeds the hour.
  • Default pod.max_sessions 10 → 25 and pod.ram_gb 4 → 6. 10 was tight for a real-world setup (Office + Teams + Edge + a couple side apps simultaneously). The new RAM default keeps the session-budget warning silent at 25 sessions (2.0 base + 25 × 0.1 ≈ 4.5 GB needed). Setup wizard's tier auto-detect (below) further tunes both per machine.

Added (additional)

  • Host-spec auto-tier in setup. New utils.specs.detect_host_specs reads /proc/meminfo + os.cpu_count() and recommend_tier maps to one of three presets:

    Host RAM      Host CPU      Tier   VM CPU   VM RAM
    >=32 GB       >=12 thr      high     8       12 GB
    16-32 GB       6-12 thr     mid      4        6 GB
    <16 GB         <6 thr       low      2        4 GB
    

    Both axes must clear the threshold to move up — a 64 GB / 4-core host still gets "low" since CPU is the bottleneck for the VM workload. Interactive setup pre-fills the suggested values; non-interactive applies them directly. 10 unit tests cover both-axis-clear, single-axis-poor, threshold edges.

Fixed (additional)

  • _apply_max_sessions wrote to the wrong registry key. The runtime apply targeted HKLM\...\Terminal Server\MaxInstanceCount but Windows actually reads HKLM\...\Terminal Server\WinStations\RDP-Tcp\MaxInstanceCount. Result: every release since session-cap shipping silently no-op'd cfg changes — only install.bat's OEM-time value was authoritative. v0.2.1 writes the correct subkey (with fSingleSessionPerUser still at the Terminal Server root, where it actually lives) and bumps the OEM-time install.bat ceiling 10 → 50 so cfg values up to the [1, 50] clamp aren't silently capped at install time.
  • Zombie disconnected sessions caused "Select a session to reconnect to" dialog every launch. MaxDisconnectionTime was set to 0 in both install.bat and _apply_rdp_timeouts. In RDP semantics that means no timeout — disconnected sessions stay alive forever. Each FreeRDP window the user closed left a session in Disc state, so the next launch triggered Windows' built-in reconnect prompt with all prior sessions listed. rdprrap multi-session lets sessions coexist but doesn't suppress that prompt. v0.2.1 changes the value to 30000 (30 seconds) — disconnected sessions auto-logoff after 30 s, so the user can close and reopen apps freely without accumulating zombies. Patched in install.bat (for fresh containers) and _apply_rdp_timeouts (for existing containers via the runtime apply).

Fixed

  • Second SEGV path on GUI Refresh — Python ref / Qt deleteLater race. v0.2.0.10 fixed the QImage-on-worker-thread crash, but a second SEGV remained: _on_refresh_succeeded and _on_refresh_failed slots both did self._refresh_worker = None immediately. Python's reference drop raced with Qt's queued worker.deleteLater() event — whichever ran second hit a freed/being-freed QObject and crashed in ~QObject() on the worker thread. Coredump on 2026-04-28 confirmed: top frame QObject::~QObject on worker thread 2282062, while main thread 2281803 was inside the slot's PySide6 callPythonMetaMethod dispatch. Fix: drop _refresh_worker / _refresh_thread Python refs only via _cleanup_refresh_worker, bound to thread.finished which fires after both Qt objects are fully torn down. Worker deleteLater keeps running on the worker thread's own event loop as Qt intends — no Python GC interference.

Fixed

  • GUI Refresh button SEGV. _DiscoveryWorker.run() (Qt worker thread) calls persist_discovered which calls _validate_png_bytes, which used QImage.loadFromData — but Qt + libgallium / Mesa state on Wayland race when QImage is touched off the main thread, dropping a Signal: 11 (SEGV) core. v0.2.0.10 has _validate_png_bytes short-circuit to the stdlib chunk walker when threading.current_thread() is not threading.main_thread(). The walker still enforces CRC + dimension caps + IEND terminator, so off-main-thread callers get a slightly slower but crash-free path.
  • install.sh wait-ready 600s → 1800s. A fresh install (uninstall --purge then re-install) downloads the ~7.5 GB Windows ISO + extracts + Sysprep + OEM apply + final reboot — that's 1530 min on first run, way past 600s. The previous timeout fired before the Windows VM had even booted, leaving the user with [FAIL] Timeout waiting for Windows ready (09:56). The 1800s budget covers a fresh install on typical hardware; subsequent installs reuse the cached ISO and finish in 25 min.
  • GUI Refresh now installs .desktop entries (parity with winpodx app refresh CLI). Previously only the CLI path inline-registered entries — clicking Refresh in the GUI updated the discovered tree but left ~/.local/share/applications/ untouched. v0.2.0.10's _DiscoveryWorker runs _sync_desktop_entries which is the worker-thread-safe sibling of cli/app._register_desktop_entries.

Added

  • GUI auto-discovery on first boot. When the pod transitions to running AND the app list is empty, the main window auto-fires the Refresh worker after a 2s settle. Solves the case where install.sh's wait-ready timed out before Sysprep finished — the user opens the GUI later, sees the pod running, and discovery just happens.
  • GUI live log streaming. The Tools/Terminal page gained four new buttons: Live (pod) and Live (app) start a tail -F against the container or ~/.config/winpodx/winpodx.log and stream new lines into the panel as they appear; App log shows the last 200 lines of winpodx's own application log; Stop tail kills the active streamer. Previously the page only showed the last 100 pod logs as a one-shot snapshot.

Fixed

  • 2nd app launch triggered Windows "Select a session to reconnect to" dialog instead of showing an independent app window. Default Windows refuses concurrent FreeRDP RemoteApp sessions per user, so every app launched after the first one was either embedded in the existing session or popped a reconnect dialog. v0.2.0.9 adds _apply_multi_session to the self-heal apply chain — it shells out to rdprrap-conf --enable inside the guest so termsrv.dll allows independent per-launch sessions. Idempotent (no-op when already enabled), tolerates rdprrap-conf missing on older OEM bundles by treating it as a best-effort skip.
  • Stale .desktop entries lingered in the user's DE menu after apps were removed from the Windows guest. v0.2.0.8 added auto-install on refresh but never removed entries that no longer matched a discovered app. v0.2.0.9 makes refresh truly bidirectional: any winpodx-*.desktop not corresponding to an entry in list_available_apps() is removed (along with its icons), so uninstalling Office on the Windows side actually drops Word/Excel/PowerPoint from the launcher on next refresh. User-authored entries under ~/.local/share/winpodx/data/apps/ are preserved.

Changed

  • README is more informative. Big "Status: Beta" + "Latest release" badges at the top in for-the-badge style. Standard shields row (license, Python, backend, language, tests, CI) below. Social row (stars, forks, watchers, unique visitors). Activity row (issues, PRs, last commit, code size). EN + KO mirrored.

Fixed

  • winpodx app refresh discovered apps but never registered them in the desktop menu. The refresh path persisted app.toml + icons under ~/.local/share/winpodx/discovered/ but the actual .desktop entries were only created by the separate winpodx app install-all command — so users saw "Discovered N app(s)" then had no apps in their DE menu. v0.2.0.8 has refresh auto-install entries for the discovered set inline (best-effort: failures are warned but don't abort the refresh) and refresh the icon cache afterwards.
  • PowerShell window flashed on every app launch. ensure_ready's self-heal apply path fired three FreeRDP RemoteApp PowerShell payloads on every single app launch — even though -WindowStyle Hidden makes them tiny, they still flashed visibly each time, which got annoying fast. The applies are idempotent on the registry side, so re-running them on warm pods accomplished nothing visible. v0.2.0.8 stamps ~/.config/winpodx/.applies_stamp with <winpodx_version>:<container_StartedAt> after a successful self-heal — subsequent launches short-circuit until the pod restarts (so TermService / NIC settings re-apply after a Windows reboot) or winpodx upgrades.

Fixed

  • pod wait-ready --logs showed no [container] lines on a fast container. Two issues: (1) the tail was started with --tail 0 which means "show only logs emitted from now onwards", but dockur often prints Windows ISO download progress + boot stage transitions before wait-ready runs — so the user saw nothing. (2) Only stdout was being drained; dockur splits progress across stdout (download bytes/sec) and stderr (boot phase), so half the messages were silently dropped. v0.2.0.7 bumps --tail 100 so the user sees recent context immediately, and drains both streams in parallel threads.

Fixed

  • wait_for_windows_responsive collapsed in <1s on a still-booting guest, defeating the entire pod wait-ready UX. The helper waited correctly for the RDP TCP port to open, then fired exactly one FreeRDP RemoteApp probe — a single failure (which is what every still-booting guest produces, rc=147 connection-reset) returned False immediately and the caller's 600s timeout was effectively ignored. v0.2.0.6 turns the probe into a retry loop that fires repeated 5-20s probes until either one succeeds or the overall timeout expires (paced 3s between attempts so we don't pin a CPU spinning FreeRDP). Now pod wait-ready --timeout 600 actually waits up to 10 minutes — observable in the elapsed-time stamp incrementing during phase 3.

Added

  • winpodx pod wait-ready [--timeout SEC] [--logs] — multi-phase wait gate for Windows VM first-boot. Polls three checkpoints with elapsed-time stamps so the user actually sees progress instead of a silent multi-minute hang:
    • [1/3] Container running (~5s)
    • [2/3] RDP port open (typically 30-90s)
    • [3/3] Windows ready (RemoteApp probes OK) (typically 2-8 min on first boot) With --logs, container stdout is tailed in a background thread and surfaced as [container] ... lines so the user can see Windows actually doing work (Sysprep, OEM apply, etc.) instead of a black box.

Changed

  • install.sh is now single-shot — install.sh exits when the install is actually finished, not when the container started. The flow is now setuppod wait-ready --logs (up to 10 min with progress + container logs) → migrate (apply runs cleanly since guest is now ready) → app refresh (discovery runs cleanly). Previously the user saw Installation complete! while Windows was still silently booting and had to wait again on first app launch. Skip the wait with WINPODX_NO_WAIT=1 for CI / non-interactive setups; skip discovery with WINPODX_NO_DISCOVERY=1.
  • Removed the redundant winpodx pod apply-fixes call from install.sh — migrate's "always-apply" path (since v0.1.9.3) already runs the apply, so calling it again just doubled the wait.

Fixed

  • Bogus "cfg.password does not match Windows" warning on every fresh --purge install. v0.1.9.5 added _probe_password_sync to detect cfg/Windows password drift before apply, but its error classifier matched on "no result file" OR "auth" in the FreeRDP error string. On a still-booting guest (which is exactly what every fresh install hits), FreeRDP returns rc=147 ERRCONNECT_CONNECT_TRANSPORT_FAILED (connection reset by peer) wrapped in the host's "No result file written" envelope — and the classifier saw "no result file" and yelled drift. v0.2.0.4 fixes this two ways:
    1. The probe now waits on wait_for_windows_responsive(timeout=180) first; if the guest isn't ready, it skips with (probe deferred — guest still booting; will retry on next ensure_ready) instead of misfiring.
    2. The classifier now distinguishes transport-level failures (rc=131, rc=147, transport_failed, connection reset) from genuine auth failures (logon_failure, STATUS_LOGON_FAILURE, etc.) — only the latter trigger the "run sync-password" warning.

Fixed

  • Discovery hit the same boot race the apply path used to. v0.2.0.1 gated _apply_* and pod apply-fixes on wait_for_windows_responsive, but winpodx migrate's "Run app discovery now?" prompt and provisioner._auto_discover_if_empty (fired by ensure_ready on first pod boot) still launched the FreeRDP RemoteApp channel without a probe. On a fresh --purge reinstall the Windows VM was still booting inside QEMU when discovery fired, so the scan collapsed with ERRCONNECT_CONNECT_TRANSPORT_FAILED [0x0002000D] (rc=147, connection reset by peer) and the user ended up with an empty app menu. v0.2.0.3 wires the same probe into both discovery call sites — discovery now waits, then either scans or skips with a "Re-run later with: winpodx app refresh" pointer.
  • First-boot timeout 90s → 180s. Real-world fresh installs on slower hardware can take more than 90s for Windows + RDP + activation handshake. Bumped the wait budget on all three apply / discovery probes to 180s so a one-shot install actually completes the apply round on first try.

Fixed

  • Fresh --purge reinstall reported a bogus "0.1.7 -> X detected" upgrade. winpodx setup saved winpodx.toml but never stamped installed_version.txt, so the follow-up winpodx migrate (which install.sh chains automatically) saw the config + missing marker and hit the pre-tracker fallback that assumes baseline 0.1.7. The fallback is correct for genuine upgrades from before the marker existed, but for a fresh install it ran every migration step needlessly and printed a confusing "What's new in 0.1.8 / 0.1.9 / …" wall. v0.2.0.2 has setup write the current version to installed_version.txt if it doesn't already exist, so a fresh install reports as the current version (no migration steps fire) while a real upgrade flow still works as before.

Fixed

  • Apply cascade collapsed on cold container. v0.2.0 fired the three idempotent runtime applies (max_sessions, rdp_timeouts, oem_runtime_fixes) the moment pod_status reported RUNNING. The dockur Linux container reaches RUNNING in seconds, but the Windows VM inside QEMU needs another 3090s before its RDP listener can accept FreeRDP RemoteApp activation. Within that window every apply collapsed with either ERRCONNECT_CONNECT_TRANSPORT_FAILED [0x0002000D] (rc=147, RDP socket open but server not initialized — connection reset by peer) on a fresh install, or ERRCONNECT_ACTIVATION_TIMEOUT [0x0002001C] (rc=131, FreeRDP connected but activation phase didn't complete) on winpodx pod restart. Each apply waited the full 60s timeout, so the cascade ran 3min before surfacing as a Launch Error dialog or "3 of 3 applies failed" panic message during winpodx setupwinpodx migrate.
  • New wait_for_windows_responsive(cfg, timeout=90) helper: polls check_rdp_port, then fires a 20s no-op Write-Output 'ping' probe to confirm the FreeRDP RemoteApp channel is actually live. Used as a precondition by:
    • ensure_ready() warm-pod path — skips the self-heal apply block entirely if the guest isn't responsive yet.
    • winpodx pod apply-fixes CLI — explicit "Waiting for Windows guest to finish booting (up to 90s)…" message so users know it's not hung.
    • winpodx migrate apply step — same wait, with a clear "guest still booting; run apply-fixes later, or just launch any app" message instead of three channel-failure stack traces.
  • _self_heal_apply() (new) — wraps the warm-pod ensure_ready apply block in WindowsExecError swallow so a transient channel failure logs a warning and stops further attempts in the same call instead of cascading. The next ensure_ready picks up where this one left off.

Fixed

  • oem_runtime_fixes failed on first apply with AllowComputerToTurnOffDevice parameter error. v0.1.9.5 shipped the runtime apply through FreeRDP RemoteApp PowerShell, but the payload still passed Set-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false. The cmdlet expects the enum string 'Disabled' / 'Enabled', and on virtual NICs (virtio inside QEMU) the parameter often isn't exposed at all. v0.2.0 wraps the call in try/catch, switches to the enum form, and skips adapters that don't support it — apply now passes regardless of NIC topology.
  • migrate password-drift probe timed out at 20s. FreeRDP's first-contact handshake on a cold pod regularly exceeds 20s (TLS + auth + RemoteApp launch). v0.2.0 bumps the probe budget to 60s so genuine drift detection isn't masked by cold-start latency.

Added

  • Streaming refresh progress. winpodx app refresh previously hung silently for 3090s while the guest enumerator walked Registry App Paths, Start Menu, UWP packages, and choco/scoop shims. v0.2.0 adds a streaming progress channel: windows_exec.run_in_windows accepts a progress_callback, the wrapper exports $Global:WinpodxProgressFile + Write-WinpodxProgress, and discover_apps.ps1 emits one line per source. Host CLI surfaces them as ... Scanning Registry App Paths... etc to stderr (JSON output stays clean).
  • winpodx pod multi-session {on|off|status} — runtime toggle for the bundled rdprrap multi-session RDP patch. Shells out to rdprrap-conf.exe inside the Windows guest via FreeRDP RemoteApp, so users no longer need to recreate the container to enable / disable / inspect the patch. Tries C:\OEM\rdprrap\rdprrap-conf.exe, C:\OEM\rdprrap-conf.exe, C:\Program Files\rdprrap\rdprrap-conf.exe in order.
  • Discovery junk filter. Refresh used to surface uninstallers (unins000.exe, "Uninstall …"), redistributables (vc_redist.x64.exe, "Microsoft Visual C++ …"), helpers (crashpad_handler.exe), inbox accessibility tools (narrator.exe, magnify.exe, osk.exe), system plumbing (ApplicationFrameHost.exe, RuntimeBroker.exe), and unresolved UWP entries whose DisplayName fell back to PackageFamilyName (e.g. Microsoft.AAD.BrokerPlugin). v0.2.0 drops these via host-side denylist patterns + executable basename matching + UWP fallback detection. Set WINPODX_DISCOVERY_INCLUDE_ALL=1 to bypass for debugging.
  • GUI app icons. Discovered apps now render their actual Windows icon (PNG / SVG) in the launcher's grid cards and list tiles, instead of the colored single-letter avatar. Icons are stored next to app.toml under ~/.local/share/winpodx/data/discovered/<slug>/icon.{png,svg} (already populated since v0.1.8); the GUI now reads them via QPixmap (PNG, smooth scaled) and QSvgRenderer (SVG, crisp at any size). The letter avatar remains as the fallback when no icon is available.

Tests

  • Streaming progress wrapper: Popen-based test simulates the 3-poll lifecycle with progress-file writes interleaved.
  • Junk filter: 11 garbage cases dropped, 4 real apps preserved, env-bypass honored.

Fixed

  • BOM in result file caused fake "fail" reports. v0.1.9.4 routed runtime applies through FreeRDP RemoteApp PowerShell, but the wrapper used Out-File -Encoding utf8 which (in Windows PowerShell 5.1) writes a UTF-8 BOM. The host then json.loads'd the file with the default utf-8 codec which rejected the BOM and reported "result file unparseable: Unexpected UTF-8 BOM". The registry changes from rdp_timeouts and oem_runtime_fixes had actually applied successfully — only the parse step failed, leaving the user thinking nothing worked. windows_exec.run_in_windows now reads with utf-8-sig so the BOM is consumed transparently.
  • _apply_max_sessions killed its own RDP session. The payload included Restart-Service -Force TermService to make the new MaxInstanceCount take effect immediately — but TermService is exactly what's hosting the FreeRDP RemoteApp session running the script. The restart killed the session before the wrapper could write its result file, the host saw ERRINFO_RPC_INITIATED_DISCONNECT [0x00010001], and the apply was incorrectly classified as a channel failure even when the registry write itself had landed. v0.1.9.5 removes the in-script Restart-Service; the registry write alone is enough, and TermService picks up the new value on its next natural cycle (next pod boot or winpodx pod restart).

Changed (architectural)

  • Migrated every remaining host-to-Windows command path off the broken podman exec ... powershell.exe channel onto windows_exec.run_in_windows. Six functions had been silently no-op'ing for releases 0.1.0 through 0.1.9.4 — podman exec only reaches the Linux container that hosts QEMU, not the Windows VM running inside, so any call to powershell.exe returned rc=127 executable file not found in $PATH and the helpers logged a warning then returned. v0.1.9.5 ports them all:
    • provisioner._change_windows_password (password rotation — silent for years)
    • pod.recover_rdp_if_needed (Bug B TermService restart — never worked; replaced with a container restart since FreeRDP can't authenticate against a dead RDP listener anyway)
    • daemon.sync_windows_time (w32tm)
    • core.updates._exec_toggle (Windows Update enable/disable/status)
    • cli/main._cmd_debloat and gui/main_window._on_debloat (debloat.ps1 — was double-broken: podman cp to copy the script + podman exec to run it)
    • core/discovery.discover_apps (Bug A's "fix" via stdin pipe was on the same broken path; now actually goes via FreeRDP RemoteApp)

Added

  • winpodx pod sync-password CLI command to recover from password drift accumulated under prior releases. Prompts for the "last known working" password (typically the one from initial setup, or the value still in compose.yml's PASSWORD env var), authenticates FreeRDP with it, then runs net user inside Windows to set the account password to the current cfg.password value. Once the sync completes, password rotation works normally going forward.
  • migrate auto-detects password drift. When winpodx migrate runs and the user is on the "already current" path, it now fires a no-op Write-Output 'sync-check' payload through the FreeRDP channel first. If FreeRDP fails with auth/no-result-file, migrate prints a clear "run winpodx pod sync-password" pointer instead of letting all three subsequent applies fail with confusing channel errors.
  • Lint test tests/test_no_broken_podman_exec.py — fails CI if any future code under src/winpodx/ (other than windows_exec.py itself) reintroduces the podman exec ... powershell.exe pattern. Single canonical channel for Windows-side commands going forward.

Tests

  • tests/test_provisioner.py updated to mock windows_exec.run_in_windows for _apply_max_sessions and assert that Restart-Service is no longer in the payload.
  • tests/test_security.py::TestPowerShellEscape rewritten — _change_windows_password now goes through windows_exec, so the test inspects the payload string instead of subprocess argv.
  • tests/test_pod.py::test_recover_rdp_* updated — recover-rdp now restarts the container instead of attempting an exec-based TermService restart.
  • tests/test_daemon.py::test_sync_windows_time_uses_windows_exec_channel rewritten for the new transport.
  • tests/test_discovery.py — five tests rewritten to mock windows_exec.run_in_windows instead of subprocess.Popen + stdin pipe. The HARD_STDOUT_CAP flooding test was removed; the cap was specific to the _run_bounded path that discovery no longer uses.

[0.1.9.4] - 2026-04-26

Fixed

  • Runtime apply finally actually applies. kernalix7 reported on 2026-04-26 that v0.1.9.1 / v0.1.9.2 / v0.1.9.3 runtime apply paths were silently failing — podman exec winpodx-windows ...\powershell.exe returned rc=127 executable file not found in $PATH. Root cause: podman exec runs commands in the Linux container that hosts QEMU, not in the Windows VM running inside QEMU; the Linux container has no powershell.exe. The helpers (_apply_max_sessions, _apply_rdp_timeouts, _apply_oem_runtime_fixes, _change_windows_password) all logged a warning and returned, while the public-facing apply_windows_runtime_fixes reported per-helper "ok" because the helpers didn't raise. So three previous releases shipped silent no-ops. Three changes:

    1. New core/windows_exec.pyrun_in_windows(cfg, ps_payload) launches PowerShell as a FreeRDP RemoteApp and pipes the script through the existing \\tsclient\home redirection. Wrapper writes {rc, stdout, stderr} JSON back via the same share. The host parses it and returns WindowsExecResult. Channel failures (FreeRDP missing, auth fail, timeout, no result file) raise WindowsExecError; non-zero script rc surfaces via WindowsExecResult.rc.
    2. _apply_max_sessions, _apply_rdp_timeouts, _apply_oem_runtime_fixes rewritten — each builds a PS payload, calls run_in_windows, and now raise RuntimeError on rc != 0 so failures actually propagate.
    3. apply_windows_runtime_fixes honest reportingtry/except on each helper still works the same way, but now an actual rc != 0 from inside the Windows VM produces failed: rc=2 ... instead of fake ok.

    Cost: ~510 s per call (RDP handshake + auth + script + disconnect) plus a brief PowerShell window flash that -WindowStyle Hidden minimizes. Trade-off: works on existing pods (no container recreate) and the rc check actually means something.

    Caveat: requires cfg.rdp.password to match the Windows guest's actual password. If password rotation has been silently failing for previous releases (same podman exec root cause), the first call here will fail with auth error and the user has to reset the Windows-side password (open winpodx app run desktop, run net user User <password-from-config>).

Tests

  • 9 new tests in tests/test_windows_exec.py covering the full lifecycle: FreeRDP missing, password missing, timeout, no-result-file (auth fail), happy path with result-file roundtrip, non-zero rc propagation, FreeRDP /app:program: cmd shape verification, flatpak-style binary splitting, unparseable result JSON.
  • tests/test_provisioner.py rewritten to mock windows_exec.run_in_windows instead of subprocess.run. New tests assert each helper raises RuntimeError on rc != 0 and WindowsExecError on channel failure.

[0.1.9.3] - 2026-04-26

Fixed

  • Patch-version migrate skipped Windows-side apply ("already current" trap). kernalix7 upgraded from 0.1.9.x to 0.1.9.2 and got winpodx 0.1.9.2: already current. Nothing to migrate. — but the actual Windows guest never received the v0.1.9.1 RDP-timeout / v0.1.9.2 OEM v7-baseline runtime fixes. Root cause: _version_tuple(...)[:3] truncated 0.1.9.1 and 0.1.9.2 to the same (0, 1, 9) tuple, so inst_cmp >= cur_cmp triggered the early-return BEFORE the runtime apply step ran. Migrate now still runs the idempotent runtime apply on the "already current" path so patch-version users still receive Windows-side fixes shipped after their last successful migrate.

Added

  • winpodx pod apply-fixes standalone CLI command. Idempotent — calls _apply_max_sessions, _apply_rdp_timeouts, _apply_oem_runtime_fixes against the running pod and prints a per-helper OK/FAIL table. Exit code 0 on full success, 2 if pod isn't running / backend unsupported, 3 if any helper failed. Safe to re-run any time.
  • GUI Tools-page "Apply Windows Fixes" button. Same runtime apply triggered from the Qt GUI — fires the helpers on a worker thread, surfaces success / failure via the existing toast/info-label channel. Useful for users who want the fixes applied without dropping to the CLI.
  • install.sh auto-fires winpodx pod apply-fixes at the end of every install, after the migrate wizard. Failure-tolerant (|| true) — silent skip if the pod isn't running. This guarantees a fresh curl | bash always lands the latest Windows-side fixes on existing guests, regardless of whether migrate's version comparison saw a "real" upgrade.
  • Public provisioner.apply_windows_runtime_fixes(cfg) API returning a {helper_name: "ok" | "failed: ..."} map so the CLI / GUI / migrate paths share a single entry point and surface uniform per-helper status.

[0.1.9.2] - 2026-04-26

Fixed

  • Windows-side fixes from v0.1.9 / v0.1.9.1 weren't reaching existing guests. kernalix7 reported "마이그레이션 잘 되는거 맞아? 윈도에 적용 안되는거같은데" — and they were right. install.bat (the OEM script) only runs at dockur's first-boot unattended setup, so users on 0.1.6 / 0.1.7 / 0.1.8 / 0.1.9 / 0.1.9.1 never picked up NIC power-save off (OEM v7), TermService failure-recovery actions (OEM v7), or RDP timeout disable + KeepAlive (OEM v8) without recreating the container. Compounding this, the v0.1.9.1 _apply_rdp_timeouts runtime helper was wired into provisioner.ensure_ready AFTER its check_rdp_port early-return — so the helper never fired against an already-healthy pod.
    • provisioner.ensure_ready: probe pod_status once at the top and run all idempotent runtime applies (_apply_max_sessions, _apply_rdp_timeouts, new _apply_oem_runtime_fixes) BEFORE the RDP early-return. Re-applied after pod-start in the cold-pod path. ~1.5s overhead per call; idempotent so re-runs are no-ops.
    • new provisioner._apply_oem_runtime_fixes(cfg) pipes the OEM v7 baseline (NIC Set-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false, sc.exe failure TermService recovery actions) to existing guests via podman exec powershell — same stdin-pipe transport discover_apps.ps1 uses.
    • winpodx migrate: when crossing the 0.1.9 boundary, proactively call all three apply helpers (with pod-state probe + interactive offer to start a stopped pod). Output reports per-helper success / failure so users can see exactly what landed without recreating their container.

[0.1.9.1] - 2026-04-26

Fixed

  • GUI SEGV when clicking the Apps "Refresh Apps" button on a pod-not-running guest. Reported by kernalix7 against 0.1.9: _on_refresh_failed constructed a QMessageBox(self) directly inside the queued-signal callback frame, and PySide6 + Qt 6.x can SEGV deep in the dialog's font-inheritance path (QApplication::font(parentWidget) -> QMetaObject::className()) when the parent's metaobject is queried mid-callback. The QMessageBox build is now deferred via QTimer.singleShot(0, ...) so the signal handler frame unwinds first. The Info page's first-fetch is also deferred out of __init__ for the same reason. The Info page worker class was hoisted to module level (was redefined every refresh), gains a busy-state reentrancy guard, and now properly deleteLaters both the worker and the QThread on completion.
  • RDP sessions still drop mid-use after host suspend / long idle. v0.1.9 Bug B fix only handled the "RDP unreachable" path; sessions could still be terminated by the Windows-side TermService timeouts (1h MaxIdleTime default). install.bat (OEM v7 -> v8) and a new _apply_rdp_timeouts provisioner step now write MaxIdleTime=0, MaxDisconnectionTime=0, MaxConnectionTime=0, KeepAliveEnable=1 + KeepAliveInterval=1 to both HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services and the RDP-Tcp WinStation, plus KeepAliveTimeout=1 on the WinStation so TCP keep-alive fires every minute. Existing 0.1.x guests get the runtime apply on the next ensure_ready without needing container recreation.

[0.1.9] - 2026-04-25

Changed

  • Discovery-first refactor. The 14 bundled app profiles (word-o365, excel-o365, ..., notepad, cmd, ...) shipped under data/apps/ were removed. The Linux app menu now populates exclusively via winpodx app refresh, which is fired automatically by provisioner.ensure_ready the first time the Windows pod is reachable and the discovered tree is empty. Manual rescan stays the same: winpodx app refresh from the CLI or the "Refresh Apps" button on the GUI Apps page. AppInfo.source drops the "bundled" enum value — only "discovered" and "user" remain. winpodx migrate upgrading from any 0.1.x < 0.1.9 prompts to remove legacy ~/.local/share/applications/winpodx-{14-bundled-slugs}.desktop files (skipped automatically under --non-interactive).

Added

  • Info page (CLI + GUI). New core.info.gather_info(cfg) returns a 5-section snapshot — System (winpodx version, OEM bundle version, rdprrap version, distro, kernel), Display, Dependencies, Pod (state, uptime, RDP/VNC reachability probes, active session count), Config (with the existing budget warning). winpodx info is rewritten to print all five sections. The Qt main window grows a 5th tab ("Info") with one card per section and an explicit "Refresh Info" button that re-runs gather_info on a QThread. All probes are hard-bounded so a sick pod can't block the panel.

Fixed

  • Bug A: winpodx app refresh on Windows. v0.1.8 used podman cp host:discover_apps.ps1 container:C:/winpodx-discover.ps1, which fails because dockur/windows is a Linux container running the actual Windows guest inside QEMU — the C: drive lives in a virtual disk that podman cp cannot write. The script body is now piped via podman exec -i container powershell -NoProfile -ExecutionPolicy Bypass -Command - over stdin, removing the staging step entirely. Stderr containing recognizable runtime strings ("no such container", "is not running", etc.) is reclassified to kind="pod_not_running" so the cli still routes to exit code 2 + the "run winpodx pod start --wait" hint.
  • Bug B: RDP unreachable after host suspend / long idle. Symptom: VNC port 8007 still works but RDP port 3390 doesn't accept connections — Windows TermService stalls and the virtual NIC enters power-save. New core.pod.recover_rdp_if_needed(cfg) detects the asymmetry, runs podman exec powershell Restart-Service -Force TermService; w32tm /resync /force, and re-probes RDP up to three times with backoff. Wired into provisioner.ensure_ready post-_ensure_pod_running. OEM bundle bumps 6 → 7 so install.bat adds preventive Set-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false plus sc.exe failure TermService reset=86400 actions=restart/5000/restart/5000/restart/5000 for Windows-side self-recovery.

[0.1.8] - 2026-04-25

Added

  • Dynamic Windows-app discovery. A new winpodx app refresh CLI subcommand and a "Refresh Apps" button on the Qt GUI's Apps page now enumerate the apps actually installed on the Windows guest and register them alongside the 14 bundled profiles. Inside the container, scripts/windows/discover_apps.ps1 scans Registry App Paths (HKLM + HKCU), Start Menu .lnk recursion, UWP/MSIX packages via Get-AppxPackage + AppxManifest.xml, and Chocolatey / Scoop shims, returning a JSON array with base64-encoded icons extracted from the real binaries / package logos. The host side (winpodx.core.discovery) copies the script via podman cp, executes it with podman exec powershell, and writes the results under ~/.local/share/winpodx/discovered/<slug>/ as TOML + PNG/SVG icon files. Bundled profiles, user-authored entries, and discovered entries live in three separate directories and merge at load time (user > discovered > bundled on slug collision) so a rediscovery run only touches the discovered tree.
  • UWP RemoteApp launching. rdp.build_rdp_command now accepts a launch_uri + strict-regex-validated AUMID (<PackageFamilyName>!<AppId>) and maps UWP apps to /app:program:explorer.exe,cmd:shell:AppsFolder\<AUMID>. Per-slug winpodx-uwp-<aumid-slug> fallback for /wm-class keeps Linux taskbar grouping distinct when two UWP apps share the same hint.
  • PowerShell Core smoke test in CI. A new discover-apps-ps job installs pwsh on the Ubuntu runner and runs discover_apps.ps1 -DryRun on every PR, validating that stdout parses as the JSON array shape core.discovery expects.
  • Post-upgrade migration wizard. A new winpodx migrate CLI subcommand shows per-version release notes for every version the user has skipped over and optionally runs winpodx app refresh so the Windows-app menu populates in one step. install.sh now invokes winpodx migrate automatically at the end of every upgrade (existing ~/.config/winpodx/winpodx.toml detected); opt out with WINPODX_NO_MIGRATE=1. Flags --no-refresh (skip only the refresh prompt) and --non-interactive (disable all prompts) are available for automation. The wizard tracks installed version at ~/.config/winpodx/installed_version.txt; pre-0.1.8 installs without that marker are treated as upgrading from 0.1.7.
  • pod.max_sessions is now configurable. Default stays 10; clamped to [1, 50]. ensure_ready() reads the value, compares against the guest's current HKLM:\...\Terminal Server\MaxInstanceCount, and rewrites + restarts TermService only when they disagree — active RemoteApp sessions aren't dropped every provision. fSingleSessionPerUser=0 is also re-asserted on every apply. A rough memory budget helper (estimate_session_memory, check_session_budget in winpodx.core.config) surfaces a warning via winpodx config show, winpodx config set, winpodx info, and the GUI Settings page only when max_sessions over-subscribes ram_gb — the default config stays silent.
  • install.sh local-path flags for offline / air-gapped installs. --source PATH copies winpodx from a local directory instead of git clone (validates pyproject.toml + src/winpodx/ are present). --image-tar PATH preloads the Windows container image via podman load -i (or docker load -i) so first boot doesn't hit the registry. --skip-deps skips the distro dependency install phase entirely and fails early if required tools aren't already present. Every flag has a matching environment variable (WINPODX_SOURCE, WINPODX_IMAGE_TAR, WINPODX_SKIP_DEPS) so curl | bash callers can compose them too. install.sh --help prints the full usage.

Changed

  • AppInfo gains source: "bundled" | "discovered" | "user", args, wm_class_hint, and launch_uri fields so the GUI can badge discovered entries and so RDP launches can target UWP apps.
  • desktop.entry._install_icon now dispatches between hicolor/scalable/apps/ (SVG) and hicolor/32x32/apps/ (PNG) based on the icon file's extension, so discovered apps' extracted PNG icons install cleanly alongside the bundled SVG ones.

[0.1.7] - 2026-04-23

Changed

  • Bundled rdprrap bumped to v0.1.3 (license-compliance release). Upstream withdrew the 0.1.0, 0.1.1, and 0.1.2 GitHub release assets. 0.1.0 / 0.1.1 were missing the upstream source-level attribution notices required by the three projects rdprrap ports code from: stascorp/rdpwrap (Apache-2.0), llccd/TermWrap (MIT), and llccd/RDPWrapOffsetFinder (MIT). 0.1.2 shipped NOTICE + vendor/licenses/ and closed the legal gap but listed only 9 of the 16 rdpwrap-derived Rust sources and had an internally inconsistent copyright line in the rdprrap-conf About dialog. 0.1.3 expands the NOTICE to all 16 sources (grouped by upstream binary — RDPWInst / RDPConf / RDPCheck), aligns the About-dialog copyright to match LICENSE, and cites CC BY 4.0 for the adapted Contributor Covenant text. It also carries forward the registry-readback fix that avoided the termsrv.dlll corruption in OriginalServiceDll. New bundle SHA256 is pinned in config/oem/rdprrap_version.txt; first-boot OEM version bumped to 6 so existing guests re-run the install path and pick up the compliant bundle.

Documentation

  • Add top-level THIRD_PARTY_LICENSES.md documenting the bundled rdprrap binary and the runtime/optional Python dependencies (PySide6 LGPL, libvirt-python LGPL, docker-py Apache-2.0, tomli MIT).
  • debian/copyright now declares the bundled rdprrap files separately and notes that the in-ZIP NOTICE + vendor/licenses/ texts satisfy the upstream Apache-2.0 / MIT attribution requirements.

Fixed

  • install.sh now works under curl … | bash. When piped, bash reads from stdin and BASH_SOURCE[0] is unset; combined with the set -u guard at the top of the script, that aborted the installer at line 205 with BASH_SOURCE[0]: unbound variable before the repo could even be cloned. The local-vs-remote branch now defaults the source path to empty and falls through to the git-clone path when there is no local repository. Reported on CachyOS with Python 3.14 / fish shell (#3).

Security / Compliance

  • winpodx 0.1.6, which shipped rdprrap 0.1.0, inherited the same missing-attribution defect. The 0.1.6 GitHub release assets have been withdrawn; the git tag is preserved. Users should install 0.1.7, which is the first winpodx release whose Windows guest receives a compliant rdprrap bundle (0.1.3, with full NOTICE + vendor/licenses/).

[0.1.6] - 2026-04-22

Added

  • Multi-session RDP — bundled, fully offline. winpodx now ships rdprrap v0.1.0 inside the package (~1.6 MB zip under config/oem/) and auto-installs it during the Windows unattended setup, so each RemoteApp window gets its own independent session instead of stealing the previous one. The bundle is staged into the Windows guest at C:\OEM\, sha256-verified against a pin file, then extracted — no network access is required. Failures fall back silently to single-session. A guest-side management channel (enable/disable/status after install) is planned for a later release.

[0.1.5] - 2026-04-21

Added

  • Prebuilt RPMs for AlmaLinux 9 / AlmaLinux 10 (also installable on RHEL 9/10 and Rocky 9/10), attached to every GitHub Release.
  • Arch Linux AUR packaging (activation pending a one-time maintainer setup; see packaging/aur/README.md).

Changed

  • Minimum Python lowered from 3.11 to 3.9. This unblocks clean installs on distros whose default python3 is 3.9 (RHEL 9 / AlmaLinux 9 / Rocky 9) without requiring an add-on Python module.

Fixed

  • OBS RPM downloads no longer come up empty when picking up newly-published assets.

[0.1.4] - 2026-04-21

Fixed

  • .deb build no longer aborts with "missing files" during dh_install.
  • OBS publish step tolerates unrelated build-service-side failures on obscure architectures that aren't in our target matrix.

[0.1.3] - 2026-04-21

Fixed

  • OBS publish step no longer loops on authentication errors while waiting for the build.
  • .deb build no longer tries to run the test suite (tests run upstream in GitHub Actions instead).

[0.1.2] - 2026-04-21

Fixed

  • After a tag push, the RPM and .deb publish workflows now actually run and attach their artifacts to the Release.
  • RPM build is resilient to the upstream pyproject.toml version being ahead of the latest git tag.

[0.1.1] - 2026-04-21

Added

  • Prebuilt packages per Release:
    • RPM: openSUSE Tumbleweed, Leap 15.6, Leap 16.0, Slowroll, Fedora 42, Fedora 43.
    • .deb: Debian 12 / 13, Ubuntu 24.04 / 25.04 / 25.10.
    • Source dist + wheel on PyPI-compatible artifacts.
  • README "Install" section now lists distro-specific commands.

Changed

  • AppImage packaging removed: Python + Qt + FreeRDP + Podman dependencies reduce its single-file-distribution value to near zero.

Fixed

  • Weekly upstream update checker creates a tracking Issue instead of failing on permission errors.

[0.1.0] - 2026-04-21

First public release.

Added

  • Zero-config auto-provisioning: first app launch creates config, compose file, container, and desktop entries automatically.
  • 14 bundled app profiles: Word, Excel, PowerPoint, Outlook, OneNote, Access, Notepad, Explorer, CMD, PowerShell, Paint, Calculator, VS Code, Teams.
  • Auto suspend / resume: container pauses on idle, resumes on next app launch; graceful shutdown on exit.
  • Password auto-rotation: 20-char cryptographic password, 7-day cycle (configurable), automatic rollback on failure.
  • Manual password rotation: winpodx rotate-password.
  • Office lock-file cleanup: winpodx cleanup removes ~$*.* lock files from the home directory.
  • Windows time sync: winpodx timesync re-synchronizes the Windows clock after host sleep/wake.
  • Windows debloat: winpodx debloat disables telemetry, ads, Cortana, search indexing.
  • Power management: winpodx power --suspend/--resume manually pauses/resumes the container.
  • System diagnostics: winpodx info reports display, dependency, and configuration status.
  • Desktop notifications (D-Bus / notify-send) surface on app launch.
  • Smart DPI scaling: auto-detects scale from GNOME, KDE Plasma 5/6, Sway, Hyprland, Cinnamon, env vars, and xrdb.
  • Qt system tray: pod controls, app launchers, maintenance tools, idle monitor, auto-refresh.
  • Multi-backend: Podman (default), Docker, libvirt/KVM, manual RDP — unified interface.
  • Auto-generated compose files for Podman/Docker backends (uses the dockur/windows image).
  • Per-app taskbar separation: each app gets a unique WM_CLASS / StartupWMClass.
  • Windows build pinning: TargetReleaseVersion policy blocks feature updates while leaving security updates on.
  • Upstream update monitoring: weekly check for new dockur/windows releases.
  • Concurrency protection: threading locks prevent crashes on simultaneous app launches.
  • GUI Windows Update toggle (services + scheduled tasks + hosts-file triple block).
  • Sound + printer redirection enabled by default.
  • USB drive sharing with hot-plug (reconnect-free sub-folder exposure).
  • USB device redirection via FreeRDP urbdrc when available, graceful fallback to drive sharing.
  • Windows-side USB drive-letter auto-mapping (event-based, no polling).
  • Desktop integration: .desktop entries, hicolor icons, MIME registration, icon-cache refresh.
  • Restricted-permission (0600) TOML configuration file for credential protection.
  • FreeRDP session management with process tracking and zombie reaping.
  • winapps.conf import for migrating existing winapps installs.

Security

  • RDP bound to 127.0.0.1 only — no network exposure.
  • TLS-only RDP channel (SecurityLayer=2); NLA disabled only in the loopback-bound setup.