324 KiB
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.shno 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 plainrm -rf ~/.local/share/winpodx— so a non-purge uninstall (documented as keeping the VM data), especially via the package-managerpostrmre-entry where it runs unattended, recursively wiped the Windows VM out from understorage/. Uninstall now canonicalizes both paths and, when the storage dir isDATA_DIRitself or nested beneath it and--purgewas not given, preserves the storage subtree and removes only the other app-data entries. A customWINPODX_STORAGE_PATHoutside the app-data dir is untouched as before, and--purgestill 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/%uplaceholder is stripped rather than filled), so the shortcuts double as plain app launchers. Requires the updated guest shim (re-provisions on the nextwinpodx guest sync/apply-fixes).
Fixed
- The GUI's
libxcb-cursor0dependency is now auto-installed on a minimal desktop (#712, thanks @numericOverflow). Qt 6.5+ (PySide6) refuses to start itsxcbplatform plugin withoutlibxcb-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.shnow installs it (distro-mapped:libxcb-cursor0on Debian/Ubuntu/openSUSE,xcb-util-cursoron Fedora/Arch) when the GUI is enabled and the runtime lib isn't already present. Presence is detected by probing the real.soon disk across the standard lib dirs rather thanldconfig -p— on openSUSE the library lives in/usr/lib64but is absent from the ld.so cache, so anldconfigprobe 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.desktopnow getsx-scheme-handler/<scheme>entries and theExecaccepts 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$HOMEfile 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-schemeUrlAssociationsdefault, per-appCapabilities\URLAssociations, and the UWP manifest'swindows.protocol), so an app that handlesmailto/https/etc. is registered automatically — no manualapp.tomledit 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/httpshandler (security hardening). Registering a Windows app as a URL-scheme handler ranxdg-mime defaultfor every declared scheme — so an app discovered from the (semi-trusted) guest could, viaapp install --mimeordoctor --fix, become the system default forhttp/httpsand 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.mailtoand 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
wmctrlexit (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
gitif it's missing instead of aborting (#705, thanks @numericOverflow). On a fresh system withoutgit(e.g. Linux Mint 22) thecurl … | bashinstaller failed at the clone step with "git is required for remote install" — even though it auto-installs every other dependency. It now installsgitthrough 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-fusebinary 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, theorg.kde.KIOFuseD-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 startand the app-launch path, so starting justwinpodx 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 thepkill …guiin 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 callsstop_listener()explicitly. - Fast suspend/resume recovery actually subscribes to systemd-logind now (#690, thanks @notnotno). The tray's
PrepareForSleepD-Bus subscription passed a bare Python function toQDBusConnection.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 eachwinpodx gui/tray start. The subscription now uses a QObject receiver with aSLOT("onPrepareForSleep(bool)")signature (held on the tray icon so it can't be garbage-collected out from under the connection), verified live againstorg.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\homeredirect 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-livedwinpodx app runprocess, which exits immediately) so it fires for menu / CLI launches too. Best-effort (X11/XWayland withwmctrl); 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 argumentwarning at every RemoteApp launch is silenced (#680, thanks @mdshahalam3). It came from the quotedcmd:"<UNC>"form winpodx keeps so a file path with spaces isn't split; the FreeRDP subprocess now runs withWLOG_FILTER=com.winpr.commandline:FATAL, which mutes that one WLog tag without changing the delivered command line (the/log-filtersflag 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 underC:\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 fromwinpodx app list. Advertised shortcuts are now resolved to their real installed executable viaMsiGetShortcutTarget+MsiGetComponentPathbefore 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\homeredirect 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.desktopExecuses%f(multi-select opens one window per file instead of a no-op), andnotify-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.menumechanism); 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] keyboardsetting 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-writingrdp.extra_flags. The defaulten-USis left untouched (FreeRDP keeps auto-detecting your host XKB layout, so users who never changed the setting aren't forced onto US), an explicit/kbdinrdp.extra_flagsalways wins, and an unmapped locale falls back to auto-detect. (Passing/kbdmanually viardp.extra_flagswas 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 withwinpodx config set desktop.full_app_scan trueor 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 nextwinpodx 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.dllpatch 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-buildtermsrv.dllstruct-layout shifts. OEM version 27 → 28, so existing installs pick it up on the nextwinpodx 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-enteredQBoxLayout::setGeometry→heightForWidthwithout 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
BusyDialogwas closed withQTimer.singleShot(0, dlg.finish)from inside the worker — but a barethreading.Threadhas 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 queuesaccept()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 owndeleteLateron the worker thread and the Python ref-drop on the main thread. Qt6 emitsQThread.finishedbefore flushing the worker'sdeleteLater, so the cleanup slot dropped the last Python ref while that delete was still in flight, double-freeing the worker across threads (QObject::~QObjectsegfault). Now there is exactly one delete path (the ref-drop, afterthread.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'sgather_info, etc.), which spammedQObject::setParent: ... different threadwarnings and could abort the whole app with__cxa_pure_virtualduring the worker's thread teardown — surfacing as the GUI dying on a refresh. The Qt scale probe now short-circuits toNoneoff the main thread and falls back to the subprocess/env detection. winpodx app refreshnow 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-isonow actually installs from your ISO instead of downloading Windows anyway (#647, thanks @ismikes). The local ISO was staged into<storage>/custom.isoafterwinpodx setuphad already runcompose up— so the container had booted and dockur had started its Microsoft download before the file existed (dockur looks forcustom.isothe moment it boots). The staging now happens insidewinpodx 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 aswinpodx setup --win-iso <path>.- The reverse-open listener now self-heals on app launch instead of staying dead until the next
pod start. Awinpodx 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(everywinpodx app run/ GUI launch) now idempotently ensures the listener is up whenreverse_openis 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>(andwinpodx 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 +Con btrfs so the raw disk doesn't fragment, SSD emulation when the target is non-rotational). Fresh installs only; relocating an existing install stayswinpodx 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_gbduringwinpodx setupbumps 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 withwinpodx 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'scustom.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 acustom.isointo 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
xfreerdpargv, 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 outpodman ps/podman logs/podman inspectregardless ofpod.backend, so they failed (or probed the wrong runtime) whenever podman wasn't the active backend. They now honourcfg.pod.backend—dockerwhen Docker is selected (manualfalls back topodman, 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 hardcodedpodman. - 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\\tsclientUNC path and opened viaStart-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
.desktopfiles used a bareExec=winpodx …, which fails withexec: winpodx: not foundwhen the desktop environment runs the launcher as a systemd transient unit that drops~/.local/binfrom PATH (Deepin'sdde-application-manager). Launching viagio launchfrom a terminal worked because it inherited the shell PATH, which masked the bug. The entry now embeds winpodx's absolute path, resolved withshutil.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.passwordnorrdp.askpasswas 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 opaqueInappropriate ioctl for device/ERRCONNECT_CONNECT_CANCELLED.build_rdp_commandnow raises up front, naming the missing setting. - Password rotation now surfaces a rejected
net userinstead of reporting success (#569). The guest-side PowerShell printedpassword setregardless of thenet userexit 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$LASTEXITCODEand exits non-zero on failure. /kbdis now an allowed FreeRDP extra flag (#657). Pass a keyboard-layout override viardp.extra_flags(e.g./kbd:layout:0x409) to work around FreeRDP keycode-scanning warnings on some layouts.- The Debian/Ubuntu
.debnow pulls inpodman-compose, and a missing compose provider fails loudly instead of cryptically (#644, thanks @paolodongilli). On Debian 13 the.debinstalled winpodx + podman but notpodman-compose, sowinpodx setupcouldn't create the container and died downstream withno such container "winpodx-windows".podman-composeis now a packageRecommends(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. (Thecurl … install.shpath already installed it — this closes the.debgap.) - The maintenance task dialog (Debloat, Grow Disk, Sync Guest, …) no longer opens cramped (#550, thanks @ismikes). The
BusyDialogprogress 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
+multitouchis now an allowed FreeRDP flag — touchscreen / stylus / pen passthrough (#623, thanks @Scratch2xs). Add+multitouchto your extra FreeRDP flags (rdp.extra_flagsinwinpodx.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 onC:failed with a confusing "No such file". The guest'sC: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), soinstall.batis untouched. Requires kio-fuse on the host (KDE);winpodx doctorwarns if it's missing. - Debloat disables more telemetry and ad surfaces (#590, thanks @GameSoul7Eugene). The Ads & suggestions item now clears additional
ContentDeliveryManagersuggestion keys and the rotating lock-screen ads, and the Unnecessary scheduled tasks item disables more pure-telemetry tasks (AitAgent,ProgramInventoryUpdater, CEIPBthSQM,Feedback\Siuf,WindowsAICopilot/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 refreshno 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 withwinpodx app refresh --timeoutfor 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 onlyreset --hardformain), so re-running an update against a feature branch reinstalled whatever was first cloned — a silent no-op. It now checks outorigin/<ref>.- An update now applies compose-template changes and starts a stopped pod, instead of deferring (#616).
install.shregeneratescompose.yamlon 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.ps1tried 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\mediaredirection and the desktop USB shortcut; for a real drive letter or raw block-device access, use USB passthrough. Existing pods have the staleWinpodxMediaautostart entry cleaned up on the nextapply-fixes. - The
usbredirect not foundhint now names the right package per distro (#593, thanks @techabsol). Debian/Ubuntu isusbredirect, Fedora isusbredir-tools(plus an Atomic Fedorarpm-ostreeform), and openSUSE staysusbredir— the previous message pointed at packages that don't carry the binary. install.batno longer throws a syntax error reserving the agent URL (#614, thanks @zephir2008). Thenetsh http add urlacl … sddl=D:(A;;GX;;;WD)line left the SDDL unquoted, socmd.exetreated 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 newwinpodx guest resync-tokenre-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 doctornow auto-runs this when itsguest_execprobe 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
widgetResizablescroll area whose as-needed vertical scrollbar madeQScrollArea.updateScrollBarsre-query the word-wrapped labels'heightForWidthwithout 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 stopkeeps the container instead of removing it. It usedcompose 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 usescompose stop;startjust 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 deviceerror (#569). With no credentials xfreerdp fell back to an interactive prompt that can't run under a GUI launch; winpodx now says to runwinpodx 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-cacheforms that xfreerdp 3 rejects are dropped from the allow-list.-gfxcan 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/-gfxtoggles 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 throughwinpodx 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 falseor 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-extensionUserChoice, per-appCapabilities\FileAssociations,Applications\<exe>\SupportedTypes, and the UWP AppxManifestwindows.fileTypeAssociation), so e.g..pngoffers Paint/Photos/Snipping Tool,.pdfoffers Edge,.txtoffers Notepad. Extensions are mapped to MIME types via the systemshared-mime-infodatabase (no hardcoded tables), and the change auto-applies on the nextinstall.shupdate via the discovery refresh — no manualapp 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 doctorwarns on an old FreeRDP build with broken RemoteApp windows (#546). A nativexfreerdp3.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 guino 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..desktopautostart and the quick launcher already ran it detached;winpodx gui --foregroundkeeps 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--maincould 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.shauto-loads thekvmmodule when/dev/kvmis 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 doctorno longer flags intentionally hidden apps as missing desktop entries (#535).uninstall.shnow removes everything winpodx creates — the consolidated "WinPodX" menu folder (.directory/.menufragments, which otherwise left an empty submenu), stalemimeapps.listhandler entries, and (on--purge) the/etc/modules-load.d/winpodx-kvm.confdrop-in.- A discovered app name can no longer inject extra keys into its
.desktoplauncher. Control characters in the guest-supplied name are stripped from theName=field, matching the existingComment=handling (hardening; a hostile guest is required to trigger it).
Changed
winpodx guidetaches by default for interactive launches, with--foregroundto 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 theWin32_*/CIM_*WMI sensor classes report like real hardware. - Patched-QEMU image (
max/ "Hardened"):winpodx disguise build-imagecompiles QEMU locally (~20–40 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, theWAETtable signature, disk / optical model and INQUIRY vendor — plus an injected thermal-zone SSDT and a WSMT table. Atmax, winpodx also presents a SATA system disk, an e1000 NIC, std VGA, and anec-usb-xhciUSB-3 controller (keeps USB3 while dropping the Red HatVEN_1B36tell), 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_MemoryDeviceclass that is empty on real hardware too, and the Windows-inherent Hyper-V integration objects that share the RDP display driver).
- Per-VM, no rebuild (
- Default Windows guest username is now
WPX-User(wasUser), which avoids al-khaser's exact-match sandbox-username list. Change it withwinpodx config set rdp.user <name>before first install.
Fixed
- Opening a file under a symlinked subdirectory of
$HOME— e.g.~/Documentssymlinked 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/homehandling is kept (#418). - USB hot-plug works again on the new dockur image. dockur v5.16 moved the QEMU monitor from
telnet:7100to 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
:latestsecurity 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.--backendacceptspodman | docker | manual; an existing config withbackend = "libvirt"falls back topodmanon load (with a warning). Thelibvirtpip extra (libvirt-python) and the install.sh/AUR/RPM/DEB libvirt references are gone;winpodx[all]is now libvirt-free (nolibvirt-devbuild dependency). Run Windows in your own libvirt domain? Keep using winpodx ≤ 0.5.x, or point themanualbackend 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
winpodxcommand / 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 ~5–8 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 listshows host USB/PCI devices and which are assigned;device attach <id>/detach <id>assign/release them (persisted incfg.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-qmpsocket and nodevice_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 existingqemu-xhcicontroller. Setusb_live = falseto keep the USB bus out of the container. (USB mass storage also still works via the\\tsclient\mediadrive share.) PCI binds tovfio-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 apkexecprompt 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 tospan). 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/spanto RAIL app launches, sizing the session desktop to the bounding box of all host monitors (one wide rectangle, no per-monitorMonitorDefArray)./multimonwas tried first but sends the full monitor layout, which the guest'srdprrapRAIL helper can't handle — it kills input outright — so the default isspan, notmultimon. Setcfg.rdp.multimon = "off"(orwinpodx setup --multimon off) for non-rectangular layouts where the spanned bounding box leaves dead space;multimonis kept as a diagnosis-only value. --extra-args/cfg.rdp.extra_flagsnow 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). Withcfg.rdp.multimonnow defaulting tospan(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 doctorstays read-only by default (it prints suggested commands and never mutates state), but the new--fixflag 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--fixis safe to run repeatedly. Four remediations ship: (1) dead agent — when the pod is RUNNING but the in-guest agent/healthis down, doctor kicks the in-guestWinpodxAgentKeepAlivescheduled task (over the agent transport, which falls back to FreeRDP when the agent itself is unreachable) and polls/healthuntil the agent is back; (2) stale lock files —.cprocmarkers 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.desktopfile are re-registered through the existing desktop-entry install path (entry + icon + MIME + cache refresh); (4) oem-version drift — when the host'soem_bundlestamp is newer than the guest's recorded version, doctor triggersguest_sync.maybe_autosyncto push the refreshed guest scripts.--fiximplies 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 doctorbehaviour is unchanged. Seedocs/design/ROADMAP-0.6.0.mditem K.- Clean, interactive install progress (and
install.sh --verbosefor the raw firehose). The Windows first-boot wait used to dump hundreds of raw dockur/wget lines (…K …… 78% 4.55M 21m22s) plus UEFIBdsDxe:boot-loader noise. By defaultpod wait-ready --logsnow 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 aWindows is booting…heartbeat for the non-download phases and UEFI/mknodnoise hidden; only real dockur milestones stay on screen. The live line is written straight to/dev/tty, so it animates on the terminal without pollutinginstall.sh'stee-captured log (non-TTY consumers fall back to occasional percentage lines).install.sh --verbose/-v(orwinpodx 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.shv2 — a pre-sudo system check, install modes, a mandatory private venv, and rollback on failure (resolves #271). Thecurl | bashinstaller now scans the host before touchingsudo: it prints distro + version, presence/version of podman / docker / libvirt / freerdp / python3, whether/dev/kvmexists, and whetherpython3 -m venvactually works (Debian/Ubuntu splitpython3-venv/ensurepip out, so it can be missing). It flags podman older than major 4 as too old — dockur/winpodx need rootlessgroup_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_MODEfor 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 feedwinpodx 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— thewinpodx-runlauncher execs the venv's interpreter instead of the systempython3+PYTHONPATH, so there's no--user/--break-system-packagessystem-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+winpodxlauncher/symlink, desktop entry + icon, the in-progress marker), never system packages and never a pre-existing~/.config/winpodxconfig; on an upgrade a failure leaves the working install untouched.--manual,--skip-deps,--source,--ref/--main,--win-version, and--image-tarall behave as before.winpodx-gitAUR package — install the latestmainfrom source (#482, #483, #484). Alongside the stablewinpodxAUR package (tagged release tarballs), a newwinpodx-gitVCS package builds from the GitHubmainbranch:yay -S winpodx-git, with the version derived from git soyay -Syu --develrebuilds it whenevermainmoves. Itprovides/conflictswinpodx, so install one or the other — not both. Recipe + maintainer notes live inpackaging/aur-git/; documented indocs/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 repoFUNDING.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
.directoryfile names the folder, anapplications-merged/winpodx.menufragment maps a customX-winpodxcategory into it, and every generated.desktopcarries 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 onwinpodx 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-runwinpodx app refreshto migrate an existing install. install.shprints 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 evenly —python3, thevenvprobe, 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 orsudo. 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.shalways installed the nativefreerdp3package even when the Flatpakcom.freerdp.FreeRDPwas already there (redundant — #269). Now: (1) the launcher prefers the Flatpakcom.freerdp.FreeRDP(core/rdp.find_freerdpauto 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 bycfg.rdp.multimon = "span", so the Flatpak is viable as the preferred client; the nativexfreerdpis the fallback when the Flatpak is absent, or when explicitly pinned via--freerdp-source native. (2)install.shnever installs a redundant client — if any FreeRDP (native or Flatpak) is already present it installs nothing; when none is presentautoinstalls 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 — andwinpodx setup --freerdp-source <auto|native|flatpak>persists the choice tocfg.rdp.freerdp_source(setnativeto 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 refreshprints aReverse-open (host apps → Windows "Open with")header so itsDiscovered/staged/skippedcounts aren't confused with the opposite-direction Windows-app discovery that runs just above it duringprovision, plus a pointer towinpodx host-open listfor the staged set. Separately, thewinpodx provisionsummary no longer dumps the rawapply_fixesdict repr (it renders a compactN/N fixes OK/k: vline), and the two byte-identicalpod-deprecation-notice helpers (_deprecate_pod/_emit_deprecation) are collapsed to one. install.sh --verbosenow reaches the upgrade path too.install.sh --verbose(andWINPODX_VERBOSE=1) used to forward towinpodx provision --verboseon a fresh install but was silently dropped on the upgrade branch, becauseinstall.shinvokedwinpodx migrate --non-interactivewithout a verbose flag andmigrate's injected_rich_waithardcodedverbose=False.winpodx migratenow accepts--verbose/-v,install.shforwards$WINPODX_VERBOSEto bothprovisionandmigrate, 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--verboseis off (the clean self-erasing line stays the default for both paths).agent_keepaliveno longer fails the migrate apply burst on a post-restart agent flicker. During an upgradewinpodx migrate, the container restarts (OEM reboot pass) and the in-guest agent goes through the TermService cycle that rdprrap (re)activation triggers. A single/healthOK 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) therequire_agentsettle stage waits for 3 consecutive/healthOK 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 aTransportError(closed socket //healthtimeout) 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 genuinerc != 0from 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_provisioninghad 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 injectedwait_fnthat routes the wait-ready stage through the richpod wait-readymachinery. (2) agent-first install protection (#271):require_agent=Truenow exportsWINPODX_REQUIRE_AGENT=1across 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.shnow branches fresh vs upgrade — fresh runsprovision --require-agent, upgrade runsmigrate(which syncs the refreshed guest scripts /agent.ps1into the existing guest, pins the image, then runs the same apply → discovery → reverse-open chain); the first cut ranprovisionfor both and left upgraded guests on stale guest scripts. (4) thepending.resume"migrate" step now runsguest_synctoo, so a deferred upgrade resumed later also refreshes guest scripts.migrateadditionally gained the reverse-open stage (it's now the upgrade path's sole driver). Seedocs/design/PROVISION_UNIFY_FIDELITY_AUDIT.md. - Command taxonomy reorganised —
guest,install, anddoctorare the new canonical homes (0.6.0 item G). Thepodjunk-drawer carried 14 subcommands across three unrelated domains; the diagnostics surface had three partially-overlapping commands (info,check,doctor). As of this release:winpodx podkeeps only the lifecycle subcommands (start,stop,status,restart,recreate,wait-ready).winpodx guestis the new canonical home for guest-side operations:apply-fixes,sync(renamed fromsync-guest),sync-password,multi-session,recover-oem.winpodx installis the new canonical home for install-progress and storage operations:status(frompod install-status),resume(frompod install-resume),grow-disk(frompod grow-disk),disk-usage(frompod disk-usage).winpodx doctoris 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 infoandwinpodx checkkeep 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 forcheck). All oldpod <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-fixes→guest apply-fixes;pod sync-guest→guest sync;pod sync-password→guest sync-password;pod multi-session→guest multi-session;pod recover-oem→guest recover-oem;pod install-status→install status;pod install-resume→install resume;pod grow-disk→install grow-disk;pod disk-usage→install disk-usage;info→doctor;check→doctor. Seedocs/design/ROADMAP-0.6.0.mditem 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 walkedpodman → docker → libvirtand gatedpodman < 4as unusable (#271), butcli/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--backendwins, otherwise walkAUTO_PRIORITY = ("podman", "docker", "libvirt")and the first usable wins (podman gated onpodman_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.mditem 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 ownshutil.which()list for FreeRDP binaries, and they had drifted —deps_quickcheckanddoctormissedsdl-freerdp3and the Flatpak fallback, so a host with only those reported MISSING in the GUI Quick Start and inwinpodx doctor.check_all()now also reports/dev/kvm(via a newcheck_kvm()), sosetup_cmddrops its inline KVM probe anddoctor's_check_kvmdelegates too. Every Python consumer now goes throughcheck_all()/check_freerdp()/check_kvm(); the shell side ofinstall.shkeeps 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 overdeps_quickcheck.pyanddoctor.pyfails CI the moment a hardcodedshutil.which("xfreerdp...")call comes back. Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.mditem D). - Version + Windows-edition lists collapsed to a single source each.
pyproject.tomlis now the only place the project version is declared;src/winpodx/__init__.pyderives__version__viaimportlib.metadata.version("winpodx")(with a clearly-marked0.0.0+sourcefallback 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 aWIN_VERSION_LABELSdict incore/config.py; the CLI help text (winpodx setup --win-version), the interactivesetupprompt, 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__.pyliteral check, picks uppackaging/rpm/winpodx.spec, and adds a round-trip assertion againstimportlib.metadata; the spec's staleVersion: 0.1.5literal 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.mditems F + N). winpodx.tomlnow carries aschema_versionmarker (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. Addingschema_version = 1at the top of every saved file (and a_migrate_config(data, from_version)hook incore/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.mditem J).- Agent port 8765 is a single Python constant now.
core/agent.AGENT_PORTis the single host-side source of truth; the compose template, the urlacl strings pushed into the guest, and theAgentClientURL 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, andinstall.sh's/healthcurl carries a comment pointing back at the constant. Pure internal cleanup; no behaviour change. Part of the 0.6.0 consolidation work (seedocs/design/ROADMAP-0.6.0.mditem 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 aWARNINGtowinpodx.logtaggedFreeRDP-fallback, with the reason (agent/healthdetail) and the operation that fell back. Count them withgrep -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 andgui/reverse_open_panel.pydefined their module logger aslogger, while the other ~50 modules uselog(both vialogging.getLogger(__name__)); the call sites are now standardised onlog. A one-pass audit of log levels confirmed they are already consistent — failures / fallbacks / refusals log atWARNING(the console threshold), routine milestones atINFO, and diagnostics atDEBUG— 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.mditem L). - First-run discovery retries trimmed from 6 to 2 (0.6.0 item M). When the provisioning chain (
winpodx provision, run byinstall.shon a fresh install, and by thesetupauto-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× loopinstall.shhistorically used predated theWinpodxAgentKeepAlivewatchdog (#359); now that the keep-alive keeps the agent reliably up, 6 attempts is overkill and just slows a clean first run. Thewinpodx provision --retriesdefault and thefinish_provisioning(retries=...)default both drop to 2, so discovery does at most one 2 s-backoff retry before reporting. Themigrate/pending.resumeupgrade paths keep their existingretries=3(deliberately set during item B and out of scope here). Override withwinpodx provision --retries Nif a slow guest needs more. Part of the 0.6.0 consolidation work (docs/design/ROADMAP-0.6.0.mditem 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 matchingdocs/*.ko.mdmirror now reflect the 0.6.0 shape: the newwinpodx guest/winpodx install/winpodx doctorcommand surface (withpod <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), andwinpodx provisionas the single post-pod-running chain. TheUSAGE.mdcheat-sheet splits the old "Pod management" block into three sections (podlifecycle /guestops /installops) and the System section gains the newdoctorflags (--json,--quick,--fix),provision, andmigrate. The version blurb in bothREADME.mdfiles 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
ApplicationFrameHostand 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 viawmctrlafter 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
/spanmulti-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/spanatpre_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-doctoron KDE,swaymsg/hyprctlon 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/spanis 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 statswas 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--instats, leaving those gauges empty even with a healthy pod; winpodx now back-fills them by reading the container's cgroup v2 files directly (memory.currentfor RAM, acpu.statusage_usecdelta for CPU%), so CPU and RAM populate on the rootless setups wherestatswon't. Many rootless slices delegate onlycpu/pids(notmemory) to the user, somemory.currentis absent even whencpu.statreads fine — RAM then falls back to summing theVmRSSof 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.currentor summed processVmRSS) 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_OperatingSystemtotal/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/execfor 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 cgroupcpu.statdelta first (an instant file read) and only falls back to the multi-secondpodman statssampling, 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 (viasetsid, 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
/homeis 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
PATHbacked by a dead daemon (e.g. a staleDOCKER_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. manualbackend now reaches the guest agent at the VM's address instead of loopback (#426). The agent client always targeted127.0.0.1:8765, even when the Windows machine lived elsewhere — so on themanualbackend pointed at a VM (e.g. a VMware guest atLTSC11P.local), RDP worked but every agent-backed feature fell back to FreeRDP-only, andwinpodx checkreportedagent_healthunreachable even though the agent answered fine at the VM address.AgentClientnow derives its host fromcfg.rdp.ip— the same address the RDP reachability check uses — so it follows the VM onmanualand stays on127.0.0.1for podman/docker (where the container publishes 8765 to host loopback andcfg.rdp.ipis 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 asTrojan:Win32/Rafvartar!rfn— a false positive.install.batalready excludedC:\OEMandC:\winpodx, butregister-apps.ps1stages the shim and its per-slugwinpodx-<slug>.execopies underC:\Users\Public\winpodx, which wasn't excluded — so Defender quarantined them and reverse-open silently broke. The first-boot Defender-exclusion step now also coversC:\Users\Public\winpodxand thewinpodx-reverse-open-shim.exeprocess. The shim is also now built with an embedded Windows VERSIONINFO resource (CompanyName / ProductName / FileDescription / version via awinresourcebuild 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.enabledand 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-readyon hosts where dockur picks bridge-NAT networking (#269, #387). The agent on guest port 8765 was unreachable from the host after Windows booted —wait-readypolled it indefinitely while noVNC showed a finished desktop. Root cause: the compose setUSER_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 ignoresUSER_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 consultsUSER_PORTSin 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 pinsNETWORK: "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.shno longer blindly proceeds with a too-old podman in Recommended mode (#271). Automatic mode already walked apodman < 4host (Ubuntu 22.04 ships 3.4) down to docker / libvirt, but Recommended mode and an explicit--backend podmanskipped 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=1to force. This completes the "graceful exit" part of #271 (the distro/version check and the runtime selector already shipped in theinstall.shv2 rework).- Upgrades no longer prompt for an install mode or replay the old first-boot log. Two upgrade/re-run UX warts: (1)
install.shshowed the[R]ecommended / [A]utomatic / [C]ustom / [N]omode menu even when a winpodx config already existed — pointless, since an upgrade reuses the existing backend/config and runsmigrate. It now detects the existing install and resolves to Automatic without prompting. (2)pod wait-ready --logs(used by the upgrade's migrate path) tailedpodman logs --tail 100, which on an already-running container replays the original first-boot ISO-download + image-build output — under--verbosethat 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 100only 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.FreeRDPruns 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 asflatpak run --command=xfreerdp … com.freerdp.FreeRDP, forcing the X11xfreerdpbinary (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\mediadrive 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 canonicalcurl … | bashinstall (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/ttyand reads every prompt from it, so acurl … | bashrun in an attended terminal shows the mode menu and lets you choose (the same/dev/ttytrick the live progress line already uses for output). Fully non-interactive runs (CI / cron / stdin and/dev/ttyboth absent) stay non-interactive and default to Recommended, and--mode/WINPODX_MODEstill 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 isRUNNING, theWinpodxAgentKeepAlivewatchdog will bring the agent back, so they poll/healthand 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 inpod 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.shrun where Windows downloaded + booted fine but the agent-first discovery deferred (winpodx provisionexit 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 theERRtrap on a failing pipeline even underset +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 theERRtrap (trap - ERR/ re-arm after) instead of relying onset +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 nextwinpodxrun, orwinpodx 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_entriesprinted onlyRegistered N app(s) in your desktop menu.— duringwinpodx provision/install.sh/winpodx migratethe user never saw which apps landed, even with--verbose. It now lists each registered app name (matchingwinpodx 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-lineNote: N discovered app(s) had no bundled profile and were not added to the menu: …so the gap is explained instead of silent. migrate --verbosereached 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)withoutverbose=verbose, so on a real version-crossing upgrade — exactly the pathinstall.shdrives withmigrate --non-interactive --verbose— the raw container firehose was still dropped (the--verboselocal was dead on that branch). Both call sites now threadverbose=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 (commonly0644/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 now0700and the script0600, matching how the agent token,compose.yaml, config, and the rotation marker are already written. - The
.cprocsession lock is no longer briefly truncated to empty while a session launches.launch_appopened 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 withoutO_TRUNC; the PID is written as a singleftruncate+write+fsyncmutation underflockonce 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 interpolatedapp_executableraw, while the adjacentdefault_argsand the UWP AUMID were already sanitised/validated against exactly this.app_executablenow 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_awakeignoredresume_pod's result, so a pod that didn't actually unpause was handed straight tolaunch_app. It now re-checksis_pod_pausedafter the resume attempt and raisesProvisionError("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
.sodeps) into${APPDIR}/usr/binand${APPDIR}/usr/lib, with the entrypoint prepending both toPATHandLD_LIBRARY_PATH. That broke every host that already had a working podman: #357, Ubuntu 26.04 —podman-composeresolved to the bundled copy, which probed for the bundledpodman(can't run standalone: no host/etc/containersconfig, no subuid/subgid, no systemd integration) and died withit 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 hostsystemd-runfor rootless aardvark-dns, but the prepended${APPDIR}/usr/libforced that host binary to load the AppImage's bundledlibcrypto.so.3→OPENSSL_3.4.0 not found (required by host libsystemd-shared), so aardvark-dns failed and container start died. PR #365's_hostenvhelper 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 (podmanrecommended,docker/libvirtsupported) installed via the distro package manager, same model asinstall.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._hostenvcollapses to a single job — strip${APPDIR}fromLD_LIBRARY_PATHfor 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 ownLD_LIBRARY_PATH(the #363 mitigation that survives because bundled FreeRDP / Python / Qt still need those bundled libs).host_path()andresolve_backend_bin()are gone — nothing in${APPDIR}/usr/binshadows the host container runtime anymore, so standardsubprocessPATH resolution finds the hostpodman/dockerdirectly. A regression test (tests/test_appimage_recipe.py) fails CI the moment the recipe re-introduces any ofpodman/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. Seedocs/design/ROADMAP-0.6.0.mditem A. - The guest agent now survives session churn instead of dying until the next reboot. The in-guest HTTP agent (
agent.ps1, the:8765listener the host uses for windowless/exec) had exactly one autostart: anHKCU\Runentry 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 andHKCU\Rundid not re-fire, so the agent stayed dead until a pod reboot (/healthtimed out on a pod up for hours;pod restartrevived it). A newWinpodxAgentKeepAlivescheduled task is now the persistent watchdogHKCU\Runnever was: it runs an idempotentagent-keepalive.ps1AtLogOn and every 1 minute, which (re)launches the agent via the existinghidden-launcher.vbswrapper (no console flash) only when noagent.ps1process is running — it never kills a healthy agent. The task runs as the interactive autologon user (not SYSTEM/S4U) so the agent's/execkeeps the user's HKCU + Start Menu context that app discovery and per-user reverse-open registration depend on; the World-SID:8765urlacl reservation and the World-readableC:\OEM\agent_token.txtstay 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.shnow installs the reverse-open icon dependencies, so far fewer apps get a placeholder icon. Thecurl | bashinstaller runs winpodx under the systempython3without a venv, so winpodx's declared icon deps —cairosvg(SVG → PNG) andpyxdg(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 missingcairosvg). The installer now installspython3-cairosvg+python3-pyxdgvia 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/execchannel (run_via_transport) like the rest of guest sync; the agent is up for all three (the restart's/execreturns 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_guestnow waits — generously, bounded — for the agent's/healthto 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.xpmat 1770 colours / 2 chars per pixel — raisedKeyErrorand 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 languageor 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 ModuleNotFoundErroron 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(defaultauto) picks the language from the host locale ($LANG) and falls back to English; override it from the CLI (winpodx language ko, orwinpodx languageto 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 atwinpodx/locale/<lang>.jsonas 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_startwas 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 withwinpodx autostart on(or the GUI Settings checkbox): that installs the tray autostart entry and setsauto_start, so the tray — launched on login — starts the pod (or resumes it if suspended).winpodx autostart off/statusmanage 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./oemis a live bind mount of the host'sconfig/oem, so after a host upgrade the container already has the new files; winpodx now delivers them into the running guest (same channel aspod 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/execit'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 bywinpodx migrate(so a package / AppImage / flatpak upgrade — whose update path never runsinstall.sh— gets the full guest refresh from a single command), manual viawinpodx pod sync-guest [--force], and a GUI Tools → Sync Guest action. No agent change. Seedocs/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 restoredisk_autogrow_target_free_pctfree space (default 30%, in wholedisk_autogrow_incrementsteps — not a flat bump), recreates the container so dockur grows the image, then extends C: to fill viaResize-Partition— no manual Disk Management step. The grow is bounded by the host's free space (minus a safety reserve);disk_max_sizeis 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), andwinpodx 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/execpath (no agent change). Default-on; setdisk_autogrow = falseto 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 plainResize-Partitioncan't reach it; the extend step detects this, detaches WinRE, removes the blocking recovery partition, extends C: to fill, and re-enables WinRE (inC:\Windowswhen 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-runninginstall.shover an already-provisioned guest frozepod wait-ready --logsat phase 4. Two causes: (1)--logs --tail 100replayed 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
USBshortcut pointing at\\tsclient\media, but winpodx only redirected that drive when a removable-media base (/run/media/$USERetc.) existed — so clicking USB with nothing plugged in failed with "\\tsclient\mediais not accessible … Attempt to access invalid address." winpodx now always redirects amediadrive: 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
/healthwait was capped at a hard 180s, so it gave up early — leaking a scaryagent didn't answer within 180sWARN 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-readyphase 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/execchannel (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 scaryFreeRDP timed out after 30s/ERRCONNECT_ACTIVATION_TIMEOUTwarning 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
initializedflag, 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 stillfalse(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 appWindowspam — 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.exeup, noLogonUI.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-composeModuleNotFoundError(#322). The fat AppImage bundled Fedora's/usr/bin/podman-composelauncher script but not thepodman_composePython module it imports (it's pure Python, solddcan't pull it in), and the script's shebang fell through to the host'spython3— which lacks the module on atomic distros. Pod creation failed withModuleNotFoundError: No module named 'podman_compose'. Now the build pip-installspodman-composeinto the AppImage's bundled interpreter and ships ausr/bin/podman-composewrapper that runs it viapython3 -m podman_compose(a pip console script can't be used directly — its shebang is the dead build-time path, same reason AppRun usespython3 -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.AppImagebundles Python + Qt + FreeRDP + Podman + podman-compose;winpodx setup-hosthandles the host-side kvm-group / subuid bits via a single pkexec prompt (requested by @leandromqrs). - Dynamic + generous timeouts (#126) --
pod wait-readyauto-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-stagesC:\OEM+ runs install.bat when dockur's first-boot OEM copy fails (driver @ankranidiotis).- Setup wizard completion (#255) --
--customizewalks edition / language / region / keyboard / tuning, and a standalonewinpodx setupnow runs the full provision flow (wait-ready + discovery + reverse-open) instead of stopping at "container created". podman-composerequired up-front (#288) -- no more silentkeep-groupsfailure whenpodman composedelegates 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:\OEMcopy silently fails (soinstall.batnever runs, the agent never installs, and port 8765 RSTs), this command tars/oemout of the container, serves it over a short-lived HTTP server on container port 8766, and prints the exact noVNC PowerShell to download + extract + runinstall.batfrom the full OEM tree. podman/docker only. Driver: @ankranidiotis (Linux Mint 22, Podman 4.9.3).pod wait-readydynamic deadline +winpodx setup-host(#126, #227).pod wait-readyparses 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(alsopython -m winpodx.setup_wizard) detects + pkexec-fixes the host bits an AppImage can't do from user space:kvmgroup membership,/etc/subuid+/etc/subgidfor 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-openextras 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.sodeps 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). Singlewinpodx-fat-x86_64.AppImage(~290 MB squashfs / ~920 MB AppDir) self-contained for everything user-space can carry. Newwinpodx setup-hostsubcommand (pluspython -m winpodx.setup_wizard) wraps the host-side bits that AppImages legitimately can't do from user space --kvmgroup membership,/etc/subuid+/etc/subgidfor rootless podman, persistent kvm module load -- in a singlepkexecpolkit prompt. Target audience: immutable distros (Fedora Silverblue / Kinoite / Aeon, Steam Deck) where layering system packages is heavy or unavailable, plus locked-down environments wherecurl install.sh | bashisn'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-wrapappimagetoolat/usr/local/bin. The bundled third-party license texts (FreeRDP / Podman / podman-compose / ...) are vendored inpackaging/appimage/licenses/so they travel inside the AppImage independent of CI'sdnfnodocs policy, and the build fail-closes if a required dir is absent. README + INSTALL.md (en + ko) updated. Driver: @leandromqrs. - Setup wizard completion --
--customizeprompts + 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 standalonewinpodx setupruns 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-onlyflag keeps install.sh's create-then-orchestrate path intact. - CLI + GUI first-run setup prompt (#255 PR 1). When
cfg.pod.initializedis False (or no config exists), the firstwinpodx <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,initializedflips to True and the prompt stops firing. winpodx setup --customizeflag (#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). Defaultwinpodx setupis now non-interactive (host-detected defaults).install.sh --manualflag (#255 PR 2). Skipswinpodx 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 nextwinpodxinvocation (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=1env var.docs/INSTALL.md+ Korean mirror gain a "Manual install" section.winpodx doctorsubcommand (#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.initializedflag) 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 setupregisters the GUI launcher.desktop(#255 G7). Standalonewinpodx setupinvocations (pip install, dev checkout, or any path where install.sh / packaging didn't drop the GUI entry) now copydata/winpodx.desktopto~/.local/share/applications/winpodx.desktop, mirroring what install.sh does for curl installs. Skips when/usr/share/applications/winpodx.desktopalready exists -- package installs own that path and a user-level copy would shadow the package version on upgrade.install_gui_launcher_desktop()lives next toinstall_winpodx_icon()inwinpodx.desktop.icons.winpodx uninstallconsolidated to a single bash script (#255 follow-up). The pre-this-PR #255 PR 3 landed a ~470-line Python implementation inwinpodx.cli.uninstallalongside the existing ~300-lineuninstall.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.shis now the canonical implementation.winpodx uninstallis a ~50-line Python wrapper that locatesuninstall.sh(in/usr/share/winpodx/,~/.local/bin/winpodx-app/,~/.local/share/winpodx/,sys.prefix/share/winpodx/, or the dev checkout) andos.execvp's it -- so uninstall keeps working even when the Python install is half-broken. The sameuninstall.shnow 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 runsudo apt remove/sudo dnf remove/sudo pacman -Rnsfirst, then re-enters the same script via the package's postrm hook with--from-postrmto do user-side cleanup -- the only ordering that keeps dpkg/rpm db consistent with disk state.postrm-common.shsimplified: 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-promptflag introduced by PR 3 is removed (its behaviour folds into--from-postrm). Tests: deletedtests/test_uninstall_consolidation.py(Python helpers no longer exist); addedtests/test_uninstall_wrapper.py(10 tests: candidate-path coverage, execvp argv forwarding, SystemExit on script-not-found) andtests/uninstall_smoke.sh(sandbox $HOME smoke that asserts every expected path is gone post-run, shellcheck-clean).uninstall.shdetects pip / source installs (#255 G6). Previously fell through tounknown-- pip users saw no hint. Now identifies site-packages or dev-checkout paths and printspip uninstall winpodxafter 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 drivepip uninstalldirectly), 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 viawinpodx host-open stop-listener, so users aren't left with orphan processes afterapt remove/dnf remove/yay -Rns. debian'sapt purgeadditionally runswinpodx uninstall --purge --yes --no-package-promptper user with a winpodx config dir, matchingapt purgesemantics. 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 inpackaging/scripts/postrm-common.sh, installed to/usr/share/winpodx/packaging/postrm-common.shby 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 --versioninstall-source suffix. Output now readswinpodx 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 viadpkg -S/rpm -qf/pacman -Qo(3 s timeout each), with~/.local/bin/winpodx-appcurl-install heuristic +site-packagessource-checkout fallback. ReturnsInstallSource(kind, label, package_name, removal_command)for downstream uninstall / version / info / doctor consumers.- Extended Windows-on-KVM tuning profile (#245).
autoandsafenow 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) plusvirtio-rng-pcibacked 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.autoadditionally exposes nested-virt CPU features (+vmxIntel /+svmAMD) andhv-evmcs(Intel) when/sys/module/kvm_intel/parameters/nested(orkvm_amd) readsY-- enabling Hyper-V / WSL2 / Docker Desktop inside the Windows guest. No-op when the host kernel hasn't opted into nested KVM.safeexcludes 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 liveformat_tuning_summary()panel showing what the resolved profile applies on this host. NewTuningCapability.nested_kvmprobe +TuningProfile.apply_hv_enlightenments/apply_virtio_rng/apply_evmcs/apply_nested_virtflags inwinpodx.utils.specs. performancetuning profile + Settings card relocation (#245 follow-up). New profile that behaves likeautobut bypasses thededicated_hostgate -- 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, andFONT_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 setupdefault 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-interactivebehaviour). Usewinpodx setup --customizefor the wizard. The--non-interactiveflag 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 upis now activity-based (kills only after 5min of silent output, not a 120s wall clock) with a 4h hard cap;wait_for_windows_responsive90 -> 300s default / 180 -> 600s at call sites; agent/health+ FreeRDP RDP-port probes 2 -> 5s;transport.exec/run_in_windowsguest calls bumped 2-3x (sync-password 30 -> 90s, multi-session 20/45 -> 60/120s, etc.);compose down60 -> 180s; daemon suspend/resume/inspect 30/10 -> 90/30s. Tight TCP poll-loop probes (check_rdp_portinsidepod_status) intentionally left short -- the budget there is the poll interval. - CPU sub-flags moved from
ARGUMENTSto dedicatedCPU_FLAGS+VMXenv vars; hv- delegated to dockur.* Three problems collapse into one refactor: (1) PR #289's-msg timestamp=onworkaround for dockurproc.sh:137bash slice on empty post-strip ARGUMENTS, (2) PR #281's hv-* enlightenments duplicating dockur's ownhv_passthrough(defaultHV=Y) causing QEMU 10'sAmbiguous CPU model string. Don't mix both "-hv-evmcs" and "hv-evmcs=on"warning, (3) PR #281's explicit+vmx/+svmnested-virt injection duplicating dockur'sVMXenv 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/+svmper 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=onmarker is removed. The ambiguous-string warning goes away (we don't addhv-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 oldnetsh http add urlacl ... user=Everyone listen=yes >nul 2>&1either created a wrong-owner reservation or hitERROR_ALREADY_EXISTS(183) -- both masked by the>nulredirect. Now: delete all overlapping 8765 reservations first, thennetsh http add urlacl url=http://+:8765/ sddl=D:(A;;GX;;;WD)(World SID directly, locale-proof), with every result logged to setup.log + anetsh http show urlacldump 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:137ARGUMENTS-strip crash, agent never installs (#287, #269). dockur (via the qemus/qemu base image) parses the composeARGUMENTS:env, strips out-cpu host,<sub-flags>for its own CPU_FLAGS pipeline, then runsARGUMENTS="${args::-1}"on the leftover. When the user's tuning profile produces nothing else (tuning_profile = offfor example), the leftover is an empty string and the bash slice fails withproc.sh: line 137: -1: substring expression < 0. proc.sh exits on that line;/oemis never copied into the Windows guest;install.batnever runs; the winpodx agent service is never installed; port 8765 accepts TCP but RSTs every handshake;winpodx pod wait-readytimes 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. Theauto/safe/performancetuning profiles already emit hv-* / virtio-rng extras (#245) and aren't affected; the workaround triggers foroffand any future profile that produces a pure-cpu host,...line. podman-composerequired up-front instead of trustingpodman compose(#288). On Fedora-family systems with the docker-compose CLI plugin installed,podman composedelegates to docker-compose, which doesn't understand thegroup_add: [keep-groups]magic value winpodx uses for rootless/dev/kvmpassthrough -- the container fails to start withUnable to find group keep-groups. install.sh's old presence check accepted that delegation (podman compose versionsucceeds via it). Now install.sh requires the standalonepodman-composebinary, andbackend.podman._compose_cmdraises a clear per-distro install hint instead of silently delegating. Reported by @magicdiablo (Nobara).-no-hpetremoved -- QEMU 10 (dockur v5.15+) rejects it as invalid option. PR #281's hv-enlightenments work appended-no-hpetto 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-hpetflag entirely (replacement is-machine ...,hpet=off, which would override dockur's machine type). Container start now bombs withqemu-system-x86_64: -no-hpet: invalid option. Dropping the flag has no functional impact -- thehv-stimer+hv-stimer-directenlightenments 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 toMIT AND Apache-2.0(#301).packaging/rpm/winpodx.specdeclaredLicense: MITbutconfig/oem/rdprrap-*-windows-x64.zipbundles stascorp/rdpwrap (Apache-2.0). Fedora packaging guidelines require the SPDX expression to enumerate every license that applies to the redistributed binary content.debian/copyrightandTHIRD_PARTY_LICENSES.mdalready 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 bydata/debloat/items.toml(11 items, 4 presets).--list/--preset/--items/--undo/--menuon 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
TZenv var;cfg.pod.timezoneGUI 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_storagefirst when language/edition changed) on dirty fields (#254 P3). winpodx pod recreate [--wipe-storage]-- regeneratescompose.yaml+ destroys/recreates the container (Windows disk preserved by default;--wipe-storagetriggers a fresh Windows install for language/edition changes) (#254 P1).Config.save()now persistspod.language/pod.region/pod.keyboard/pod.timezone/pod.tuning_profile-- pre-this fix those five fields were loaded but never written back, silently droppingwinpodx config seton the next save.
Added
winpodx debloat --menutext-mode picker (#247, phase 4). Headless-friendly alternative to the Qt dialog for users on SSH / TTY-only installs. Pureinput()+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,plists presets,p <name>switches preset,aapplies,qquits,hshows help.run_menuaccepts injectableinput_fn/print_fnso 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 existingrun_via_transportchannel; 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--undopath. winpodx debloat --undo+ per-item undo scripts + state JSON tracking (#247, phase 2). Each catalog entry can carry an optionalundo_scriptfield; reversible items (9 of the 11) now ship anundo/<name>.ps1sibling 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.jsonafter 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-monolithicwinpodx debloat(which always ran the same 70-linedebloat.ps1script) into a per-item catalog + selection resolver. The catalog ships atdata/debloat/items.tomlwith 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.ps1underscripts/windows/debloat/. Four presets (normal,full,performance,speed) seed the most common selections; barewinpodx debloat(no args) keeps the previous behaviour by defaulting tonormal(telemetry + ads).--listprints the full catalog + presets for inspection without touching the guest.--itemsaccepts a comma-separated explicit name list and overrides--presetwhen 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 livedetect_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_storagehelper aswinpodx pod recreate --wipe-storage. winpodx config set pod.<key> --autoshorthand (#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 fromutils.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.--autoand a positional<value>are mutually exclusive.winpodx setupinteractive timezone prompt (#254, phase 2). Wizard now prompts forcfg.pod.timezoneafter the CPU / RAM rows, defaulting to the host-detected IANA zone. Non-interactive setup (install.sh's call path) leavescfg.pod.timezoneat 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 viatimedatectl show --property=Timezone --value, falling back toreadlink /etc/localtimethen/etc/timezonethenUTC. The IANA result is translated to a Windows TZ ID through the CLDR-deriveddata/locale/windows_zones.tomltable (~150 entries from the001wildcard subset). The resolved Windows TZ ID lands in<oem_dir>/timezone.txt; the OEMinstall.batreads it on first boot and runstzutil /s "<id>"instead of the previous unconditionaltzutil /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). Regeneratescompose.yamlfrom the current config, then destroys and re-creates the container -- a cleaner primitive thanwinpodx pod restartfor picking up first-boot env knob changes (timezone, edition, backend).--wipe-storagealso 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 explicitWIPEconfirmation.config/oem/install.batreadsC:\OEM\timezone.txtif present (#254, phase 1). Falls back to the previoustzutil /s "UTC"behaviour when the file is absent.data/locale/windows_zones.toml— IANA -> Windows TZ ID mapping table (#254, phase 1). Shipped undershare/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
TZenv var (#267, refactor of #254 P1). The original P1 design dropped atimezone.txtfile into the OEM bind-mount dir and added atzutilblock toinstall.batto 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~/.configor~/.localancestor, producingcp: cannot stat /oem/./<file>: Permission deniedfor every OEM file at first boot. Switching to dockur'sTZenv var (which already drives the<TimeZone>element in its Sysprep unattend.xml) removes the OEM-file requirement entirely, lets_find_oem_dirgo 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 oldtzutilblock 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 deniedcascade on fresh installs with umask 077 (#267). Symptom: dockur's first-boot OEM-copy step failed withcp: cannot stat '/oem/./<file>': Permission deniedfor 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 persistspod.language/pod.region/pod.keyboard/pod.timezone/pod.tuning_profile(#254, phase 1). Pre-this fix those five fields were loaded fromwinpodx.tomlbut 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.pyno longer wraps FreeRDP inpodman 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 infoVNC reachability probe switched from RDP X.224 handshake to plain TCP accept (VNC speaks RFB, not RDP).uninstall.shadds 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.pypreviously wrapped every FreeRDP invocation inpodman 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, where127.0.0.1:<rdp_port>is reachable via the existing podman publish. Side benefits: removes an implicit dependency on thepodmanbinary 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 infono longer falsely reports "VNC unreachable" while NoVNC is serving (#214). The VNC port (8007 by default) was probed withcheck_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 usescheck_tcp_port(plain TCP accept), which matches what "is the NoVNC endpoint accepting connections" actually means.uninstall.shfinal tray/GUI sweep before exit.WINPODX_NO_TRAY_SPAWN=1+ the section-0apkillcover 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 bypassescli/main.py's spawn check. A quiet finalpkill -f 'python.*winpodx'+pkill -f winpodx-apppass right before the summary catches those.
Internal
packaging/obs/obs-publish.ymlanchors 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 setuprerun on an existing install preserves the working Windows credentials instead of silently overwritingcfg.rdp.password; the lockout that producedLOGON_FAILED_BAD_PASSWORDis 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.+invtsclands in the x86_64 QEMU args on supported CPUs.winpodx info/winpodx setupprint a new[Tuning]block so the auto-applied set is never hidden. Suggested by @ismikes (#215). safe/off/manualescape hatches for the tuning profile; host-side prerequisites (hugepages, CPU pinning, VFIO) stay documented operator tasks rather than wired into winpodx automatically.- New
PodState.UNRESPONSIVEplus 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 theRUNNING → UNRESPONSIVEtransition, fires a desktop notification, and spawns a background worker that cyclesTermServicein-guest via the agent transport. On recovery a "pod recovered" notification fires; on failure the user gets a "needs manual restart" notification pointing atwinpodx pod restart. OEM install.bat also tightens everypowercfgtimeout to0and clearsPlatformAoAcOverrideso 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 newUNRESPONSIVEvalue when the container has been running longer than 10 minutes but the RDP port isn't answering — historically misreported asSTARTINGforever. The tray driver caches the previous state and on aRUNNING → UNRESPONSIVEtransition firesnotify_pod_unresponsive+ a background worker that callstry_recover_rdp(newcore/pod/recovery.py). Recovery asks the agent toRestart-Service -Force TermServiceand re-probes the RDP port for 30 s; success firesnotify_pod_recovered, failure firesnotify_pod_needs_manual_restartwith the failure mode (agent unreachable/rdp still down) so the user knows to runwinpodx pod restart.Backend.uptime_secs()is the new ABC method; Podman + Docker implement it viainspect -f '{{.State.StartedAt}}'; libvirt + manual returnNoneso they fall back to the legacySTARTINGanswer. -
config/oem/install.batdisables 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. Setsstandby/hibernate/monitor/disktimeouts to0on AC + DC, clearsHKLM\SYSTEM\CurrentControlSet\Control\Power\PlatformAoAcOverrideto force Desktop class power behaviour, and forcesSTANDBYIDLE 0on the current scheme as a belt-and-braces guard. Eliminates the root cause behind theRUNNING → UNRESPONSIVEtransition 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 toWin32_PowerManagementEvent(config/oem/power-monitor.ps1) and restartsTermService5 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 toorg.freedesktop.login1'sPrepareForSleep(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 viawinpodx pod apply-fixesin 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 inHKLMbut the running session keeps the old values until next boot. install.bat now writes aC:\winpodx\oem_reboot_pending.txtmarker, registers aRunOncekey that deletes the marker on the second Windows boot, and issuesshutdown /r /t 15as its last action.winpodx pod wait-readygains 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 runswinpodx app runstill gets the UNRESPONSIVE auto-recovery driver. The tray icon now renders properly (bundledwinpodx-icon.svgset beforetray.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 runsstop_pod+pkill -f 'winpodx gui'+app.quitso a stray click can't cycle the pod's ~30 s restart.tray.lockflock under$XDG_RUNTIME_DIR/winpodx/prevents stacked instances when the user manually re-launches the tray. -
ensure_readytwo-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 callstry_recover_rdp(agent-driven TermService cycle, ~5-30 s, keeps Windows uptime), stage 2 falls back torecover_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.desktopvia the XDG autostart spec — portable across KDE / GNOME / XFCE / Cinnamon without per-DE branching. File existence is the source of truth (nocfg.tomlfield, no daemon to manage), and the user can drop the .desktop by hand to opt out.X-GNOME-Autostart-enabled=trueis included so GNOME's session manager honours it after DE-default resets. Toggle applies immediately — no Save Settings click needed. -
uninstall.shcovers the new tray + autostart surfaces. Two new sections: (0a) pre-removalpkill -fagainstwinpodx guiandwinpodx trayplus 1-s grace, so the killed processes release their FDs into the install / runtime / config dirs we're about torm -rfand stop firing auto-recovery notifications against a soon-to-be-gone container; (6b) always-remove~/.config/autostart/winpodx-tray.desktopregardless of--purgebecause the .desktop is useless without the binary and leaving it triggers awinpodx: command not foundat next login. -
Host-adaptive Windows-on-KVM tuning profile (#215).
cfg.pod.tuning_profilecontrols 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+invtscon x86_64 hosts that expose invariant TSC. Other profiles:"safe"(Tier-1 only —+invtsc+ Windowsplatform_tick, no host-side prerequisites),"off"(dockur defaults only),"manual"(safeshape; future per-knob overrides).winpodx infoandwinpodx setupboth 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 indocs/USAGE.mdas user-host tasks rather than wired into winpodx automatically.
Fixed
-
winpodx setuprerun 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 intowinpodx.toml. Because dockur honorsUSERNAME/PASSWORDenv vars only on first boot, the host config silently desynced from the Windows guest account and the next RDP launch failed withLOGON_FAILED_BAD_PASSWORD. The interactive prompt now detects an existing config with a populatedcfg.rdp.user+cfg.rdp.password, preserves them, and prints guidance to usewinpodx rotate-password(which goes through the Windows-side password change mechanism). Non-interactiveinstall.shsetup 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 readssudo dnf config-manager addrepo --from-repofile=<URL>; the dnf4 form is noted below for Fedora ≤40 (EOL). Updated inREADME.md/docs/README.ko.md/docs/INSTALL.md/docs/INSTALL.ko.md/packaging/obs/README.md. Reported by @payayas. -
install.shno longer falsely reports success when hardware virtualisation is off (#220). Reported by @pnogaret2019-code on Linux Mint LMDE 7. The install loop treatedqemu-system-x86already being present as "all dependencies installed successfully" while/dev/kvmstayed 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/kvmafter the install loop and aborts with livelscpu/lsmod/idoutput 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.shUbuntu 24.04+ / Debian 13 picks the realqemu-system-*package instead of the now-virtualqemu-kvm(#200). Reported by @n-osennij on xubuntu 26.04 inside VirtualBox. apt errored withE: Package 'qemu-kvm' has no installation candidatebecause newer Ubuntu / Debian madeqemu-kvma virtual package whose providers areqemu-system-x86(orqemu-system-x86-hwe).pkg_name()now uses the sameapt-cache showprobe pattern as the freerdp2 → freerdp3 selector from PR #198: probesqemu-system-x86first (orqemu-system-armon aarch64), falls back toqemu-system-x86-hwe, thenqemu-kvmfor 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.txttrack 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.shaborts cleanly on Ctrl+C instead of marching pastPod is starting, not running. Start the pod now and apply? [Y/n]. The old install loop used a singleEXIT INT TERMtrap that only cleaned up the install marker; SIGINT then fell through to the next step. Split into three traps (EXITcleanup-only,INTcleanup +exit 130,TERMcleanup +exit 143) and wrapped thewait-ready | teepipeline withset +esoPIPESTATUS[0]survives the|| truerescue and the SIGINT-vs-real-failure branch can act on the real wait-ready rc. -
winpodx setup --non-interactiveflag for install.sh-driven migration. install.sh calls setup migration after the initial install; without--non-interactivethe 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.shtray no longer respawns mid-uninstall. Section 0apkill'd the tray, then section 0b ranwinpodx host-open stop-listener/unregister-guestto tear down reverse-open.cli/main.pyauto-spawns the tray for every subcommand outside the{setup, gui, tray}skiplist, sohost-opentriggered a fresh tray spawn and section 0a's work was immediately undone.uninstall.shnow exportsWINPODX_NO_TRAY_SPAWN=1;maybe_spawn_tray()short-circuits when set, so everywinpodxCLI 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
.debbuild failed on Debian/Ubuntu hosts due to a Trove-classifier whitelist mismatch in olderhatchling. 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/downdefault timeout 120s → 1800s; honorsWINPODX_COMPOSE_TIMEOUT_SECS(0= no cap). Fixes #212 reported by @jimed-rand.launch_apppolls FreeRDP 500 ms after spawn and raisesFreeRDP 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 / keyboardkeys; same YAML-injection hardening asuser/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 frompyproject.toml. Olderhatchling(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.debbuild. Replaced with the version-agnostic:: 3 :: Only. Supported-version coverage still comes fromrequires-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 / keyboardinwinpodx.toml. Defaults toEnglish/en-001/en-US(backward compatible with existing configs); set to e.g.Spanish/es-ES/es-ESfor a Spanish Windows install. All three fields run through_DANGEROUS_YAML_CHARSsanitisation inPodConfig.__post_init__and_yaml_escapeat compose render — same defense-in-depth as the existinguser/password/homefields.docs/INSTALL.mdanddocs/INSTALL.ko.mddocument the 10 most common language tuples. Applies only to fresh Windows installations; existing pods need a volume reset +winpodx setupre-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_SECSenv var (0= no cap). Fixes #212 reported by @jimed-rand. -
LICENSE and README.md missing from
.debpackages.pyproject.tomlnow shipsLICENSEandREADME.mdto/usr/share/winpodx/via the[tool.hatch.build.targets.wheel.shared-data]table. The GUI's License tab no longer hitsFileNotFoundErroron Debian/Ubuntu installs. (by @juampe, #201) -
Qt stylesheet parser warnings at GUI startup.
main_window.pycombined a bare property declaration (background: ...) with full QSS selector rules (QPushButton { ... }), which Qt's stylesheet engine rejects. The central widget now carries anobjectName="centralRoot"and the background rule is scoped toQWidget#centralRoot { ... }. No visual change; just silences theCould 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
.debbuild fails withUnknown classifier: Programming Language :: Python :: 3.X. Olderhatchlingshipped 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 fromrequires-pythonand the CI matrix. Removed in favour ofProgramming 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.shworks again on Debian 13 / Ubuntu 24.10+ — the hardcodedfreerdp2-x11dependency was unresolvable on those distros (onlyfreerdp3-x11is in stock repos). The Debian/Ubuntu branch inpkg_name()now probesapt-cache showand picksfreerdp3-x11when available, falling back tofreerdp2-x11only 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 RPMRequires:pulls FreeRDP / podman / python3 / tomli transitively, so layering justwinpodxis 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.shautodetectsrpm-ostreeand switches to a single-transaction OBS-layered install instead of the per-package dnf loop: probes the OBS repo for the host's FedoraVERSION_ID(currently published: Fedora_42 / Fedora_43 / Fedora_44), drops the.repofile into/etc/yum.repos.d/, and runsrpm-ostree install --apply-live --idempotent winpodxto 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 torpm-ostree install --idempotent winpodxwith a reboot prompt. winpodx's RPMRequires: freerdp >= 3.0+python3-tomliandRecommends: podman+python3-PySide6so layering justwinpodxpulls 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.mddocuments the hand-install steps for users who'd rather skipinstall.sh. Korean mirrors updated. (by @Zeik0s, #163)
Fixed
install.shno longer fails withfreerdp2-x11 not foundon Debian 13 (Trixie) and recent Ubuntu (24.10 / 25.04 / 25.10) where stock apt repos only shipfreerdp3-x11. The Debian/Ubuntu branch inpkg_name()now probesapt-cache showand prefersfreerdp3-x11when available, falling back tofreerdp2-x11only 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_versionnow 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, orinstall.sh --win-version. Custom ISOs documented as a manual workaround indocs/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:Zsyntax. (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-armand the compose template emits-cpu hostwithout the x86-onlyarch_capabilities=offsub-option that previously crashed QEMU onhost-arm-cpuwithProperty '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 refreshno 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:
WinpodxWindowQt 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_signallines (winpodx logger + pod tail whencfg.logging.level == "RAW") flash there in real time. - Terminal tab gets a
DEBUG / INFO / WARNING / ERROR / CRITICAL / RAWdropdown that retargets the running logger AND persists tocfg.logging.level. RAW also enables the parallelpodman logs -fstream (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 tailbuttons were removed (now redundant with the always-on design).
- A persistent 2-line log ticker at the very bottom of the main window — visible regardless of which page is active. Latest two
- 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 EDITIONandinstall.sh --win-version VERflags for headless edition selection on fresh installs. NewWINPODX_WIN_VERSIONenv var equivalent. Documented indocs/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 directwinpodx.tomledit (seedocs/ARCHITECTURE.md"Advanced: Custom Windows ISO"). (#178, #183) _KNOWN_WIN_VERSIONSconstant incore/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.shandcore/config.py:_default_pod_imagedetect host architecture viauname -mand route aarch64 hosts to thedockurr/windows-armimage (matching the dockur project's separate ARM build).core/pod/compose.pydropsarch_capabilities=offfrom the-cpu hostflag on aarch64 — that sub-option is x86-only KVM CPUID hardening and QEMU'shost-arm-cpumachine model rejected it withProperty 'host-arm-cpu.arch_capabilities' not found, the exact symptom Pi 5 users hit atwinpodx pod start. The new aarch64 CI matrix entry (#177) runs the test suite onubuntu-24.04-armso architecture-aware codepaths don't regress silently. Remaining Phase 4 work — Windows-on-ARM RDP listener stabilisation, rdprrap compatibility onxtajitx86 emulation — tracked separately under #141 + #140. (reported by @tslpre, #140 — Pi 5 / RaspiOS Trixie repro with full QEMU error) check-windows-updatesworkflow gains a:latestdigest drift detector — opens a "silent rebuild" tracking issue when digest changes without a release-tag bump. Two new fields inconfig/oem/VERSIONS.txt:dockur-digest=anddockur-arm-digest=. (#180)docs/ARCHITECTURE.md"Advanced: Custom Windows ISO" section documents the manualwin_version = "custom"+compose.yamlmount 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 withlevelfield (DEBUG | INFO | WARNING | ERROR | CRITICAL | RAW).setup_logging()reads it on startup so all winpodx CLI / GUI runs pick up the chosen level. DefaultINFO; unknown values fall back toINFOwith no rejection. Terminal tab dropdown changes the live logger AND persists to TOML.RAWis a winpodx-only meta-level: pins the Python logger toDEBUGAND turns on a parallelpodman logs -ftail 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_containerand 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 forpod_status == RUNNINGANDcheck_rdp_port(bounded bycfg.install.wait_ready_stage2_secs, default 900s), (2) wait forAgentClient.health()+ host token ready (bounded bycfg.install.wait_ready_stage3_secs, default 1800s — long because fresh-ISO download + Sysprep dominate this stage), (3)winpodx.core.provisioner.apply_windows_runtime_fixesfor rdprrap activation + RDP timeouts + OEM baseline, (4)discovery.scan+persist_discovered, (5)winpodx host-open refreshfor the reverse-open manifest. A newBringUpProgressDialog(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 existinglog_signalfan-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 newBringUpMixininsrc/winpodx/gui/_main_window_bringup.py, three new signals onWinpodxWindow(bringup_phase,bringup_done,bringup_started), and a kick at the end of the existing_recreateworker 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
WinpodxWindowdecomposed into mixins.src/winpodx/gui/main_window.pyshrinks 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_*.pymixin module. Pure structural refactor — zero functional change, all method resolution preserved via MRO. (#181) core/discovery._is_junk_entrynow rejects executables underC:\Users\Public\winpodx\reverse-open\bin\(queried via the newreverse_open.sync.is_guest_shim_pathhelper) so the host's reverse-open Linux-app shims aren't mistakenly imported as Windows apps.persist_discovered()runs a_purge_reverse_open_entriesself-heal pass that drops any existing polluted entries on the next refresh.scripts/windows/discover_apps.ps1Add-Resultfilters the same path on the guest side as defense in depth. (#182)docs/USAGE.mdwin_versionTOML 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 thegetrandomRust 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 -foutput 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-livedpodman logs -fsubscription bounded to the dialog's lifetime so the live tail is visible regardless ofcfg.logging.level, and also receives the always-on[pod]-prefixed lines that flow throughWinpodxWindow.log_signal(the same fan-out introduced for the always-on bottom log bar).bringup_phasenow 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 withUnexpected keywordon 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:Zsyntax (FreeRDP 3 parses/app:as<key>:<value>,...and rejects bare paths withUnexpected keywordat theC:drive prefix); FreeRDP 2 uses the separate/app:PATH+/app-name:NAME+/app-cmd:CMDflag 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 viaxfreerdp --versionand cached. UWP path is unchanged. (reported by @poetman, #158)__version__insrc/winpodx/__init__.pywas stale at0.4.3and didn't track the package version. Bumped to0.5.1with this release;winpodx --version, the migrate version comparison, and the install marker now matchpyproject.toml.- YAML-injection hardening on the four
[pod]config fields that land inside the compose template's double-quoted scalars. A hand-editedwinpodx.tomlwithwin_version = '11"\nEVIL: "x'(or the same shape incontainer_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 validatesdisk_sizeagainst a regex;core/pod/compose.py:_build_compose_contentnow runs all four values through_yaml_escapeas defense in depth.cli/setup_cmd.pyre-runs__post_init__after the--win-versionflag 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:
rdprrapwas listed as GPL-3.0 (it's MIT, same maintainer);Catppuccin Mochawas credited as the GUI palette (theme is actually GitHub Primer Dark — seesrc/winpodx/gui/theme.py);PySide6license string was imprecise. Catppuccin removed; rdprrap moved to MIT; GitHub Primer Dark added with the GitHub Inc. 2013 copyright; PySide6 tightened toLGPL-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
.gitignoreextended 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 claimedpacman / AUR | Supported, anddocs/INSTALL.mdinstructedyay -S winpodx. Neither is currently true — the AUR publish workflow is wired but secret-gated onAUR_SSH_PRIVATE_KEY, which has not been provisioned, so nowinpodxAUR package has shipped. Updated the README row to "Source install supported · AUR package pending maintainer onboarding", replaced theINSTALL.md"Arch Linux (AUR)" section withinstall.shguidance, 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.enableddefaults toTrue; turn off withwinpodx host-open disableor 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
.exevia vendored rcedit. Eachwinpodx-<slug>.exeis now an independent copy of the shim whose PE resource section carries the matching<slug>.ico. The earlier ProgIDDefaultIconsurface 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 underconfig/oem/reverse-open/shim/bin/and pushed to the guest alongside the shim via base64 in the sync payload.register-apps.ps1gains a-RcEditExeparameter; staging a slug is nowCopy-Item+rcedit --set-icon.unregister-apps.ps1mirrors 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), aSHChangeNotify(SHCNE_ASSOCCHANGED)P/Invoke call to invalidate the shell icon cache, and a one-line INFO log at the listener's spawn site capturingslug=…+ the substitutedargv=…for post-hoc debuggability of misbehaving spawns. The ProgIDDefaultIconwrites 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.lnkfolder shortcut pointing at the Start MenuLinux Appsfolder, plus a Quick Access pin via theShell.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.listper 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 primarymimeapps.list, so default-handler detection matches whatxdg-mime query defaultresolves. - #167 —
uninstall.sh --purgeskips guest reverse-open scrub.--purgeis for "nuke the host, container is also going away" cases; reaching into the guest withunregister-apps.ps1is pointless and slow if the pod is about to be deleted anyway. - #166 — Short-menu visibility + chooser cache + Firefox handoff. Fixes the
OpenWithListwrite so the entry surfaces in the short Open With menu (sub-key, not value — Windows treats values underOpenWithListas the MRU list and sub-key names as the population list). Callsie4uinit.exe -showafter 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
.exeshim + NTFS hard links. Replaces the original PowerShell/.cmd shim with awindows_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
.cmdwrappers + 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.cmdshape gives each slug a distinct path. Registration scope narrowed to "apps the user has actually set as their Linux default" (viais_default_for) — registering every MIME-handling app flooded the chooser.install.shauto-triggers reverse-open sync;uninstall.shincludes ahost-open unregister-gueststep. 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".
- #171 — Embed the per-slug icon into the per-slug
-
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.enableddefaults to True. Three new guest-side scripts (config/oem/reverse-open/register-apps.ps1,unregister-apps.ps1, and the PowerShell shim undershim/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/execendpoint with a 180s budget; a successful sync persistscfg.reverse_open.last_synced_at.winpodx pod startnow auto-spawns the host-side listener daemon (idempotent — no-op if already running),winpodx pod stoptears it down before stopping the pod.winpodx host-open refreshnow also pushes to the guest when the agent is reachable and reportspushed N app(s) + M icon(s) → registeredin its summary. The PowerShell shim writes an atomic<uuid>.json.tmpto\\tsclient\home\.local\share\winpodx\reverse-open\incoming\then renames to<uuid>.jsonso the host listener never picks up a partial request. registry shape on the guest: per-appHKCU\Software\Classes\winpodx-<slug>ProgID with shim invocation inshell\open\command, plusHKCU\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/− Removebuttons that route through the samevalidate_sluggrammar the CLI uses), live daemon status (running / pid, cached app count, generated-at timestamp), and four action buttons that dispatch the existinghost_openhandlers (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'sadd/removeso a slug round-trips between GUI andwinpodx host-openwithout surprises. Settings page wires the panel into the existing column layout next to the RDP / Pod cards and folds save into the existing_save_settingsflow — no separate "Save" button for reverse-open, the panel writes to the sharedcfg.reverse_openand 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 + guestregister-apps.ps1/unregister-apps.ps1+ agent/reverse-open/syncendpoint) 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-opensubcommands. 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:AppEntrywith schema-validated load from apps.json + :func:substitute_pathargv builder (first%f/%u/%F/%Uwins, subsequent placeholders dropped, never re-shelled). (2)reverse_open/listener.py— :class:Listenerwith 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'sSeenUUIDsring buffer, schema validation (version=1, slug-shapeapp, NUL-freepath,\\\\tsclient\\prefix),safe_open_uncTOCTOU-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 viastats_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 (sohost-open refreshdoesn't restart the daemon), stale-pid recovery (process gone but file lingering → cleanup on next status query), idempotentstart_listener,os._exiton the daemon body to avoid leaking SystemExit back through the fork-without-exec parent's stack. Four newwinpodx host-opensubcommands:start-listener,stop-listener,daemon-status, all with--json; plusrefreshnow sends SIGHUP automatically when a daemon is running and reportsdaemon_reloadedin the JSON summary. +60 tests acrosstest_reverse_open_apps_db.py(12),test_reverse_open_listener.py(27 including a real fork-and-process integration test through Phase 1'ssafe_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 + guestregister-apps.ps1/unregister-apps.ps1+ agent/reverse-open/syncendpoint — 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
.desktopdiscovery, ICO conversion, and thewinpodx host-openCLI. Lifts the dormant Phase 1 foundations into a usable host-only flow: a newwinpodx host-open refreshcommand walks$XDG_DATA_HOME/applications+$XDG_DATA_DIRSlikexdg-opendoes, applies the existing allow / denylist filter fromcfg.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 anapps.jsonmanifest 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 whoseExec=line contains shell metacharacters, drops Wine / winapps / winpodx wrappers (recursion defence), respectsHidden/NoDisplay/OnlyShowIn/NotShowIn, validatesTryExec=, marks the user'smimeapps.listdefaults — so a malicious.desktopin~/.local/share/applications/can't smuggle a shell command through the staged manifest into Phase 2b's spawn path. The CLI also exposesstatus,list(live-scan or--cached),enable/disable, andadd/removefor allow / denylist management (with--denytoggle and slug-shape validation). New optional extrawinpodx[reverse-open]pulls inPillow,cairosvg, andpyxdg; without ithost-open refreshstill produces the manifest but writes a placeholder ICO per app and logs a warning. +60 tests acrosstest_reverse_open_discovery.py(25),test_reverse_open_icons.py(14),test_cli_host_open.py(21). Phase 2b (inotify listener +lifecycledaemon + agent/reverse-open/syncendpoint), Phase 2c (Go shim + guest-sideregister-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'spod wait-ready --logsoutput flows live again. v0.4.3 piped the wait-ready invocation throughteeto 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] activationlines 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.shnow prependsPYTHONUNBUFFERED=1to the wait-ready call, which forces line-buffered stdout regardless of pipe presence. Thetee-captured file contents are unchanged, so theno such containerdiscrimination 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 byinstall.sh). Previously a non-purgeuninstall.sh(which strips winpodx files but should keep the container) followed by a re-run ofinstall.shwould printExisting config found, skipping setup.and proceed straight topod wait-ready, which then exploded withError: no container with name or ID "winpodx-windows" found: no such containerbecause the container had also been wiped (either by the user picking--purgeinteractively without realising, or by some external cleanup that took winpodx's container alongside the rest). The only recovery path was a full--purgereinstall, 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 — whencfg.pod.backend in ("podman","docker"),pod_statusreports anything other than RUNNING/PAUSED/STARTING, AND a direct<backend> ps -aprobe doesn't findcfg.pod.container_namein its output — and runsensure_ready(cfg)to recreate the container from the existingcompose.yamlidempotently.install.sh'spod wait-readyfailure path now also discriminates on stderr: if the captured output containsno such containerit prints a clear "container is missing — likely from a partial uninstall" hint with the exact--purgerecovery commands instead of the generic 60-minute-timeout message that previously masked the real cause. -
winpodx rotate-passwordandwinpodx pod sync-passwordnow 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 forrun_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 explicitrotate-passwordcommand) and thesync-passwordrecovery 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-passwordis 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. Bothcore/rotation/_change_windows_passwordandcli/pod/_sync_passwordnow calldispatch(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 reachablesync-passwordno 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_MARKERrecovery contract is unchanged — it still triggers on any mid-rotation disconnect regardless of which transport ran. Supersedesdocs/TRANSPORT_ABC.mdrule #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 andnet userargv 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_FLAGSallowlist (+/-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 intoCOMMAND_LINE_VALUE_BOOL(genuine+/-nametoggles) vsCOMMAND_LINE_VALUE_OPTIONAL(only/name:valueaccepted) — 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 clearBlocked unsafe extra_flagwarning, 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/gfxvalue-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 whilecfg.install.agent_first = False(default). New modules: (1)core/install_state.py—GuestInstallStep/GuestInstallStatedataclasses andfetch_install_state(cfg)that readsC:\winpodx\install-state\*.donemarkers via the agent and falls back to a$XDG_STATE_HOME/winpodx/last_install_state.jsoncache 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), aRetryCounterclass wrappingretry_counts.jsonwith corruption-tolerant ACID updates, and the install-failure redactor (redact_log_line/redact_payload) that stripsnet user <user> <pw>argv,Authorization: Bearer ...headers,password=/token=/apikey=query-style patterns, and base64 blobs > 40 chars before they ever reach disk viawrite_install_failure. (3)core/config.pyextended withInstallConfig(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 intowinpodx pod's subcommand router with the full Phase-3 flag surface (--json,--no-color,--logs,--non-interactivefor status;--non-interactive,--yes/-y,--forcefor resume), each printing "Phase 3 implementation pending" and exiting 0. (5)docs/design/install_failure.schema.json— JSON Schema draft-07 withadditionalProperties: false, slug-shapefailed_step/error_class, capped string lengths, and a 50-linelast_log_linescap. +95 new tests acrosstest_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-sidewait-ready5-stage +install-status/install-resumeimplementations + GUI integration), 4 (default flip toagent_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 indocs/design/REVERSE_OPEN_DESIGN.md(#48). Four foundation modules land with no behaviour change for existing users: (1)paths.pytranslates UNC paths from the guest's\\tsclient\...namespace into POSIX paths under the configured share roots, with a TOCTOU-safesafe_open_uncatomic 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 withO_NOFOLLOWand validates the realpath against the allowed roots while still holding the fd, so there is no window an attacker can race); (2)config.pydefines theReverseOpenConfigschema with a baked-in dangerous-app denylist (cmd/powershell/wscript/etc.) andcfg.reverse_open.enabled = Falsedefault — this PR adds zero user-visible behaviour change; (3)mime.pyships a curated 86-entry MIME→extension mapping covering the file types the guest is most likely to hand back, with a runtime fallback toxdg.Mimefor anything outside the curated table; (4)seen_uuids.pyis 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 coveringpaths.translate_unc_to_posixtraversal 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-argsper-launch FreeRDP override + GUI Settings → Extra FreeRDP args. Diagnostic / workaround escape hatch for distro builds that ship a broken xfreerdp3. Per-launch--extra-argsis appended AFTERcfg.rdp.extra_flagsso a one-shot debug flag wins over the global default; both paths run through the same_filter_extra_flagsallowlist so the new entry point can't smuggle anything unsafe. The Settings page in the GUI now has an "Extra FreeRDP args" input bound tocfg.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-h264is the workaround for cachyos's xfreerdp3 build (WITH_VAAPI_H264_ENCODING=ONflagged experimental by FreeRDP itself), where xiyeming hit silent RemoteApp failure during RAIL post_connect — now they can setextra_flags = "-gfx-h264"in winpodx.toml or via the GUI to force RemoteFX fallback while we wait forwinpodx 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'sproc.shfires this warning unconditionally on every btrfs host based purely ondf --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)whencfg.pod.storage_pathis 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 runwinpodx 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:
- 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 viawinpodx migrate) moves it to the bind mount path without reinstalling Windows. - PR #127 — compose-prefixed volume name resolution.
podman-compose upmaterialises named volumes with the project name prefix (winpodx_winpodx-data, notwinpodx-data); the migration's volume probe now tries the prefixed form first and falls back to bare. - 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-lsattrboth the dir and a sample.imgafter rsync to catch the silent-no-op case where chattr returned 0 but the flag never persisted. - 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_pathwould let_maybe_auto_migrate_storagersync 64 GiB over the already-migrated NoCoW disk image with a fresh CoW copy. - PR #130 — the actual root cause for "chattr never applies on fresh installs."
plan_migrationcallsdetect_path_fs(target)BEFORE the dir ismkdir'd. On openSUSE Tumbleweedfindmnt --target <nonexistent_path>returnsrc=1with empty stdout (the docs lie) —detect_path_fsreturned"unknown",chattr_will_runevaluated 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 setupon a fresh install now mounts the Windows VM disk image from a user-owned host directory (~/.local/share/winpodx/storageby default) instead of the legacywinpodx-datanamed volume. When the path lands on btrfs, setup runschattr +Con 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 existingwinpodx-datanamed 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 -aSof ~60 GiB). The Windows install is preserved — no Sysprep redo, no ISO redownload. Idempotent: pod is stopped, btrfschattr +Capplied to the empty target so the copy lands NoCoW, contents rsync'd,cfg.pod.storage_pathpersisted, 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(whichinstall.shcalls on every upgrade) now detects the same condition —winpodx-datanamed volume, mountpoint on btrfs,cfg.pod.storage_pathempty — 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 runningmigrateis to apply fixes. On failure the original named volume is preserved and the user is pointed atwinpodx setup --migrate-storagefor manual retry. cfg.pod.storage_pathconfig field. Empty string preserves the legacy named-volume mode; an absolute path switches compose to a host bind mount at that path.Config.loadvalidates the field and falls back to empty on bad values;_render_storage_blocksdefends against unsafe values (newlines, quotes, brace characters) by reverting to the named volume so a hand-editedwinpodx.tomlcan'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 viapodman-compose upbecause the actual volume on disk iswinpodx_winpodx-data— podman-compose namespaces volumes by the compose project name (we declarename: "winpodx"in the template). Auto-migration inwinpodx migrateand the Case-2 warning inwinpodx setupboth 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 atwinpodx_winpodx-data, the auto-migrate hook had been dormant for hours. Newresolve_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_volumecarries the resolved name through so thevolume rmstep at end of migration targets the same volume that was migrated. detect_path_fswalks up to an existing ancestor before invokingfindmnt. 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_migrationcallsdetect_path_fs(~/.local/share/winpodx/storage)BEFORE the target directory is mkdir'd. On openSUSE Tumbleweedfindmnt --target <nonexistent_path>returnsrc=1with empty stdout (despite docs claiming it falls back to the nearest existing parent), so the helper returned"unknown". That madechattr_will_run = (target_fs == "btrfs")evaluate to False, the entire chattr branch inexecute_migrationsilently no-op'd, the post-rsync NoCoW verification (PR #128) had nothing to flag becausechattr_will_runwas False there too, AND PR #128's surfacingprint()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 tofindmnt, so a not-yet-mkdir'd target still resolves to the fs that will contain it.chattr_will_runis now True for fresh-install migration plans on btrfs targets, which means thechattr +Capply, the inlineprint()of the outcome, and the post-rsynclsattrverification 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_pathempty (root cause still under investigation — one possibility is install.sh's git-update step leaving a stale install dir whosePodConfigdataclass lacked thestorage_pathfield, soConfig.loadsilently dropped the value when round-tripping), AND found the named volumewinpodx_winpodx-datarecreated bypodman-compose upwith a 64 GiB raw disk. The auto-migration then ran a SECONDrsync -aSof 64 GiB into~/.local/share/winpodx/storage— which was already populated with the FIRST migration's output (including thechattr +C-applieddata.imgfrom the manual recoverycp --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_storagenow checksdefault_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 restorestorage_pathinwinpodx.tomlor 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 +Coutcome 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"), butlsattr -dafterwards showed noCflag on either the bind-mount directory or the freshly rsync'd 64 GiBdata.img— the entire NoCoW benefit of the migration was silently missing because the chattr outcome only flowed throughlog.warning(filtered out of the install.sh foreground) and there was no post-rsync sanity check.execute_migrationnow (a)print()s every chattr outcome —disabled/already_off/failed— so the user sees inline whether NoCoW was applied, and (b) re-runsis_cow_disabledafter rsync against both the target directory AND a sample*.imgfile 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 inMigrationResult.detailwith a recovery recipe (cp --reflink=neverof the affected file with the pod stopped).cp --reflink=neverreads + rewrites the file inside the now-+Cparent, 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 --logsrewrites dockur's container-internal VNC URL to the host's mapped port. dockur printsvisit http://127.0.0.1:8006/ to view the screenfrom inside its container, where8006IS the listening port. The host maps that tocfg.pod.vnc_port(default8007, seecore/pod/compose.py's127.0.0.1:{vnc_port}:8006spec), so the URL streamed to the user's terminal pointed at a port only reachable inside the container._drainin_wait_readynow substitutes127.0.0.1:8006→127.0.0.1:{cfg.pod.vnc_port}per streamed line, using the user's actual configured port (so a customvnc_portinwinpodx.tomlis honored). Reported by @xiyeming. Closes #118.- Packaging workflows tolerate the create-release race.
debs-publishandrhel-publishboth ran anEnsure release existsstep that didview || create. Onv0.4.1tag push both jobs hit the view-failed branch in parallel and both successfully calledgh release create; GitHub's release-create POST isn't atomic on tag uniqueness, so two GitHub Releases appeared at the same tag andrelease.yml's subsequentsoftprops/action-gh-releasefailed withtag_name: already_exists, leaving the release page without sdist/wheel attached. The create call is now idempotent: try create, swallow anyalready_existserror, then verify the release is reachable viaview. 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 separateREL-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.ymlnow matchesREL-*and strips the prefix when extracting the version, so the public Release page is titledv0.4.0(notREL-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 theirv*.*.*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 viagh release editto backfill the missing credits.
Fixed
- install.bat no longer hangs at PS Expand-Archive on first boot (the
agent never installsroot cause).Add-MpPreference -ExclusionPath C:\OEM,C:\winpodx -ExclusionProcess rdprrap-installer.exeis 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-Archivereplaced with Windows-nativetar -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_syncnow honorsWINPODX_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_guestnow 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_appshonorsWINPODX_REQUIRE_AGENT=1and raisesDiscoveryError(kind="agent_unavailable")instead of falling back to FreeRDP.wait_for_windows_responsivebumps 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 theinst_cmp < (0, 1, 9) <= cur_cmpgate 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 /yat the end ofrdprrap-activate.ps1killed 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\Runfires, agent starts cleanly. OEM bundle 21 → 22. app refreshand migrate's interactive discovery prompt now retry on agent-respawn race._apply_vbs_launchers(part of the apply chain) ends by spawningagent-respawn.ps1detached — the respawn waits ~3 s, kills the running agent, then starts a new one. Ifapp refresh(or the user accepting the migrateRun app discovery now?prompt) hit the agent during that kill-then-restart window,/execreturnedRemote end closed connection without responseand discovery deferred to pending. Both call sites now retry up to 3 times with 10 s spacing; the menu populates beforeinstall.shexits and migrate's interactive path completes inline.- install.sh waits for the agent to settle between migrate and app refresh. A
curl /healthpoll between the two steps gives the agent up to 60 s to come back after migrate's_apply_multi_sessioncycles TermService. Combined with the 3-attempt retry above, the menu reliably populates beforeinstall.shexits instead of needing a follow-up CLI / GUI launch to fire pending-resume. setup.logno longer self-locks onAdd-Contentfrom inside redirected PowerShell blocks. The HKCU\Run reg-add and agent-spawn PowerShell blocks redirect stderr to setup.log via2>>"%SETUP_LOG%", and used to also callAdd-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 innerAdd-ContentwithWrite-Outputand switched cmd-level redirect from2>>to>>"%SETUP_LOG%" 2>&1so PS stdout is the single writer.- Info screen no longer flashes "Overall: FAIL" during fresh-install warm-up.
probe_agent_health,probe_guest_exec, andprobe_guest_summaryreturnedFAILwhen 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 returnWARN("agent warming up or busy: ..."), andrun_all()does dependency-aware skipping: ifagent_healthisn't OK, downstream probes (auth_ready,guest_exec,guest_summary) are returned withstatus="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.
/healthis intentionally unauthenticated (the host needs to know if the agent is up before it has a token). The agent transport's/execpath however requires the host token; if the token file isn't readable yet (race during first install.sh run),dispatch()would pickAgentTransportbased on green/healthand then fail on the first/execwith token-missing.AgentClient.auth_ready()now returns(bool, detail)for whether_token()resolves;AgentTransport.health()consults it after the/healthcall and reportsavailable=Falseif the token isn't ready. Default dispatch then falls back toFreerdpTransportinstead 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-containercpthen failed withPermission deniedbecause~/.config/winpodx/oem/inherited a restrictive parent mode. Branch onos.access(bundle_oem, R_OK | W_OK): if the user owns the bundle, return it directly (no copy — Podman's:Zrelabel works fine on user-owned paths). Otherwise fall back to the user-space copy with explicitchmod 0644/0755so 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 tamperedwinpodx.tomlwith 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);PasswordFilterredacts FreeRDP's/p:<password>switch in addition tokey=valuepatterns; password rotation reordersgenerate_composebeforecfg.saveso a compose-generation failure leaves disk on the OLD password (no half-state).install.batis 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 undertests/test_oem_install_bat.pypin 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"toDiscoveryError(kind="pod_not_running"). That over-fired on session-side errors where the pod was very much running -- specificallyERRINFO_LOGOFF_BY_USER(0x0001000C) andERRINFO_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) → newsession_disconnectedkind; 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 startno longer fails withlsetxattr: operation not permittedon Fedora / SELinux-enforcing systems. Three bugs chained: (1)bundle_dir()usedany()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 becauseagent_token.txtwas 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:Zfor SELinux relabeling — which fails withlsetxattr: operation not permittedbecause rootless Podman can't relabel root-owned files. (3)stage_token_to_oem()wroteagent_token.txtinto the bundle OEM dir, so on RPM installs the runtime token landed under/usr/share/winpodx/(untracked by RPM, survivesrpm -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 fromany()toall()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 PowerShellStart-Processblock that spawns the agent in this session viawscript.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_portnow 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 didsocket.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'swait-readyskipped 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_portnow sends a minimal X.224 Connection Request (TPKT-wrapped) and reads back 2 bytes. A real RDP server responds with a TPKT (first byte0x03, second byte0x00); 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 helpercheck_tcp_portexposes the old TCP-accept-only behavior for the one place that legitimately wants it (the VNC liveness check insiderecover_rdp_if_needed-- VNC doesn't speak RDP, so the X.224 probe would naturally fail there).wait_for_windows_responsiveno longer deadlocksinstall.shphase 3 when agent /health doesn't come up. Pre-this-fix the helper required BOTH an open RDP port ANDagent.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 oninstall.sh'swinpodx 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 atmin(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 viatransport.dispatch) plus a warning log pointing atC:\winpodx\setup.logfor diagnostics. Only RDP-port-never-opens returns False. Sametransport.dispatchagent-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\with2>nulsuppressing any error and registeredHKCU\Run\WinpodxAgent/WinpodxMediaviareg addpointing 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). Thereg additself 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 — eachcopy /Yis followed by anif existcheck with the result logged toC:\winpodx\setup.log(launcher OK:/launcher FAILED:per file). (2) HKCU\Run registration moved to a single PowerShell block viaSet-ItemProperty— clean .NET string storage, no cmd-quoting hell. (3) Existence-gated fallback — ifhidden-launcher.vbsdidn't survive copy (LAUNCHERS_OK unset), HKCU\Run is registered with the legacy directpowershell.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.vbsandconfig/oem/launch_uwp.vbshad em-dash characters in their leading comments. wscript.exe reads.vbsfiles 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.ps1files; this PR completes the audit by ASCII-ifying the.vbsfiles. The full set of script files (5 PS + 2 VBS) is now strict ASCII. agent-respawn.ps1legacy fallback removed. The old code had aTest-Path hidden-launcher.vbscheck with an else-branch that fell back toStart-Process powershell.exe -WindowStyle Hidden -File agent.ps1— i.e., the same broken pattern PR #58 fixed forHKCU\Run\WinpodxAgent. Post-OEM-v13 the wrapper is always staged (install.bat +_apply_vbs_launchersboth 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 withexit 1if 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-fixesno 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 refreshat 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 partialGet-AppxPackageview; (2) Start Menu indexer hadn't propagated all.lnkfiles 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/execfired. Three layers now collapse the variance:- Guest-side readiness gate (
scripts/windows/discover_apps.ps1): waits forAppXSvc.Status -eq 'Running'AND ProgramData Start Menu.lnkcount > 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/healthand 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.
- Guest-side readiness gate (
apply-fixesno longer cycles TermService (and kills the agent) when rdprrap is already patched but the activation marker is stale. PR #81 made_apply_multi_sessionself-heal by spawningrdprrap-activate.ps1whenever the.activation_statusmarker said anything other thanenabled. That correctly triggered for pods with no marker (pre-OEM-v15) or anot-activated/installer-failedmarker. But it also triggered for pods like kernalix7's where the marker saidinstaller-failedfrom a partial OEM-time apply — yetServiceDllhad successfully been flipped totermwrap.dllanyway and multi-session was working. Re-firing activation in that state cycled TermService unnecessarily, killed the agent's RDP session, and (becauseHKCU\Runonly fires at user logon) left the agent dead until the user opened an app. Repeat apply-fixes calls (eachinstall.sh --mainupgrade re-runs migrate's apply chain) re-killed the agent each time. Fix: before spawning the detached activator, also probeHKLM\SYSTEM\CurrentControlSet\Services\TermService\Parameters\ServiceDll. If it already matchestermwrap, writeenabledto 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\WinpodxMediano longer flashes a black PS console on every app launch. Pre-OEM-v19 install.bat registeredmedia_monitor.ps1(USB auto-mapping background process) with barepowershell.exe -WindowStyle Hidden ...—-WindowStyle Hiddenis 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 aswscript.exe hidden-launcher.vbs powershell.exe ... media_monitor.ps1(same wscript+SW_HIDE wrapper that PR #58 applied toWinpodxAgent). Migrate path:_apply_vbs_launchersrewritesHKCU\Run\WinpodxMediaif an unwrapped entry exists, so existing pods get the fix on nextwinpodx pod apply-fixes(orwinpodx 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.imagenow defaults to a SHA-pinned dockur image; migrate aligns existing pods. Before this fix,cfg.pod.imagedefaulted todocker.io/dockurr/windows:latest(orghcr.io/dockur/windows:latestfor installs ≤ v0.3.0). Everypodman-compose upre-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:latestcontaining a proc.sh substring failure (proc.sh: line 137: -1: substring expression < 0), and a routineinstall.sh --mainupgrade triggered a full container rebuild + 7.5GB ISO redownload + Sysprep reset on a working pod that had nothing wrong with it. Pin:cfg.pod.imagedefaults todocker.io/dockurr/windows@sha256:20b398ab935465f97ec8ab06489f7a85a5ad58e74e036ce66cc3c9172e7dbea8(queried from Docker Hub registry at release time and stored asDOCKUR_IMAGE_PINincore.config). Migrate's "already current" + cross-version paths both call a new_ensure_canonical_image_pinstep that rewritescfg.pod.image+ regeneratescompose.yamlso existing pods land on the same canonical pin a fresh main install would write — one container recreate on the nextpod 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-imageopt-in dockur version bump. New flag on the existingsetupsubcommand. Pullsdocker.io/dockurr/windows:latestvia the user's container backend, resolves the now-local image's repo-digest, writes it back tocfg.pod.image, and regeneratescompose.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 nextpod 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, orinputs.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 withsrc/onPYTHONPATH. 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 singlebundle_dir()resolver inwinpodx.utils.pathsthat searches$WINPODX_BUNDLE_DIRenv var → source checkout →sys.prefix/share/winpodx→~/.local/bin/winpodx-app. Each candidate must contain at least one ofscripts/,config/,data/to qualify, falling back to the source-checkout guess for stable error messages. Tests for affected helpers now monkeypatchbundle_dirdirectly rather than jugglingHOME+sys.prefix+__file__. (Thanks @Mic92 — PR #65.)
Fixed
winpodx migrateno longer silently dropsapply-fixeson 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 oninst_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 runninginstall.sh --mainhit 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 ranwinpodx pod apply-fixesmanually. Fix: parser now extracts leading digits per segment so0.3.0-RTM1,0.3.0rc1,0.3.0+devall 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-fixesauto-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'smulti_sessionstep 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_statusreadsenabled, no-op (no /exec round-trip beyond the one we're in, no disconnect, no churn); otherwise queue the detached activator. Apply chain reorders sovbs_launchers(which stages rdprrap-activate.ps1 + hidden-launcher.vbs) runs beforemulti_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 onactivates rdprrap on existing pods without a container recreate. Activation needsrdprrap-installer installplus anet stop/start TermServicecycle so the patchedServiceDllis loaded — but the cycle kills every active RDP session, including the agent's own user session, so an inline/execcouldn'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 topodman rm -fthe 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 newrdprrap-activate.ps1script (idempotent — falls back to extracting the bundled rdprrap-*.zip fromC:\OEM\if the installer binary isn't staged, retries up to 3×, verifies theServiceDllflip post-restart, writes the same.activation_statusmarker install.bat OEM v15+ writes) is staged intoC:\Users\Public\winpodx\launchers\andwinpodx pod multi-session onspawns it detached via wscript+hidden-launcher.vbs (mirrors agent-respawn pattern). The/execcall returns immediately with "OK: activation queued"; the user reconnects after the brief disconnect; the agent auto-starts via HKCU\Run; subsequentwinpodx pod multi-session status(now a marker probe identical to the apply-fixes status surface) confirmsenabled.winpodx pod apply-fixespushesrdprrap-activate.ps1alongside the other VBS launchers, so existing v0.3.0-RTM1 pods get it on the next migrate without recreate. (statusno longer shells out tordprrap-conf.exe;disablestill 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 tordprrap-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 aStart-Process wscript.exe ... agent-respawn.ps1at 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 viaWin32_Processcmdline 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 againstIApplicationActivationManager: 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 smallAdd-Type'd C# helper class — the cast happens inside compiled C# scope where the managed cast resolves to a real COMQueryInterface. Existing pods get the fixed script viawinpodx 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.PIPEleft 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>.stderrso the session outlives the parent and the tail stays inspectable;RDPSession.stderr_tailreads 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-freerdplacks RAIL per FreeRDP #9078,wlfreerdpis deprecated with broken RAIL repaint) failed post-detach with "failed to open display".launch_appnow refuses to spawn that combination and raises an actionable error pointing at XWayland (compositor built-in orxwayland-satellitefor niri / river). (Thanks @Mic92 — PR #64.) is_freerdp_pid()no longer adopts unrelated processes as live RDP sessions. The check used to look forb"freerdp"orb"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.cprocmarkers 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 binariesfind_freerdp()actually launches (xfreerdp{,3},sdl-freerdp{,3}, plus theflatpak run com.freerdp.FreeRDPfallback). Two regression tests cover the substring-match cases that used to slip through. The downstream PID-reuse test was rewritten withoutbash -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(wasghcr.io/dockur/windows:latest). Aligns with upstream's official compose /docker runreference (the upstream README and examplecompose.ymlboth usedockurr/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.tomlpersists the resolved value, so users who already ranwinpodx setupkeep the old reference. To pick up the new default, either drop theimage = "ghcr.io/..."line fromwinpodx.tomland re-runwinpodx setup(regeneratescompose.yaml), or edit~/.config/winpodx/compose.yamldirectly. (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 registerpowershell.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) andWshShell.RunwithintWindowStyle=0propagatesSW_HIDEtoCreateProcessso 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 invokeswscript.exe launch_uwp.vbs <AUMID>, which callsIApplicationActivationManager::ActivateApplicationdirectly. The UWP frame becomes the visible RemoteApp window with no transition; ~300ms faster too. - Remaining
run_in_windowscallers 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 throughwinpodx.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/healthdoesn't answer. Password rotation (rule #6) and thewinpodx pod sync-passwordrescue path stay on FreeRDP intentionally — they need direct credential auth.
- Agent autostart via
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-stampshidden = trueinapp.tomlso 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 fromC:\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
argsstring fromapp.tomland forwards it as the FreeRDPcmd:field. Solves the longstanding "explorer.exe RemoteApp shows nothing" gotcha — the File Explorer essential shipsargs = "shell:MyComputerFolder"so launching it opens theThis PCview in a normal window instead of trying to take over as the user shell. Existing apps withargs = ""are unaffected. - Per-app .desktop descriptions. Discovery now extracts a one-line description from the guest (
.lnkComment field, exeProductName, or UWP<VisualElements Description>) and lands it as theComment=key in each app's.desktopfile. Replaces the blanketComment=Windows application via winpodxstamp 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
DisplayNameresolves to anms-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 dottedPackageFamilyNameand 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.ps1runs inside Windows on127.0.0.1:8765, bound via+:8765so 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;/healthstays unauthenticated for the readiness probe. Child PS spawned via[Diagnostics.Process]+CreateNoWindow=$true+ asyncReadToEndAsync— no PS-window flashes, no pipe-buffer deadlocks. Token delivered via the OEM bind mount (mode0600on host, gitignored). - Transport ABC v1 (
core/transport/{base,agent,freerdp,dispatch}).dispatch()prefers the agent and falls back to FreeRDP when/healthdoesn't answer. Password rotation explicitly never routes through Transport (rule #6 indocs/TRANSPORT_ABC.md) — rotation owns its own credentials and callsrun_in_windowsdirectly to avoid a bootstrap loop on stale-password recovery. winpodx checkhealth probes. New CLI command runs a fast multi-source health audit and prints one line per probe withOK/WARN/FAIL/SKIPand per-probe duration.--jsonemits machine-readable output for scripting. Exit code is0unless any probe isFAIL. Probes:pod_running,rdp_port,agent_health— surface bring-up stateguest_exec— POSTs/execwith a trivialWrite-Output okand verifies rc=0 + stdout="ok". Proves the host→guest channel actually round-trips, not just that/healthanswersguest_summary— single/execreturning Windows version + uptime + current user + active session count + C: free spaceoem_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) andR(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.shref selection.--maininstalls fromorigin/main(development),--ref TAGinstalls 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_pathwalked 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_missingaspod_not_running._looks_like_pod_downmatched the substring "pod" inside install paths likewinpodx-app/..., so any DiscoveryError with the install path in its message was routed to the wrong dialog. The RefreshWorker now reads the explicitDiscoveryError.kindfirst and only falls back to the substring heuristic when no kind is set. - Agent
/execreturnedrc:nullafter clean child exit. PowerShell'sStart-Process -PassThru+WaitForExit(timeout)can leave$proc.ExitCodeas$nulleven when the child exited normally. The agent (rev4) now coerces the null to0at the source and the hostAgentClienttoleratesrc:nullas0so existing pods on rev2 / rev3 stay functional. - Agent flashed a PowerShell window for every
/exec.Start-Process -NoNewWindowfrom a hidden parent (-WindowStyle Hiddenvia HKCU\Run) re-creates a console for fast-exiting children. agent.ps1 (rev4) now spawns via[Diagnostics.Process]+ProcessStartInfowithCreateNoWindow=$trueandUseShellExecute=$false; stdio is drained via asyncReadToEndAsyncto avoid pipe-buffer deadlocks.WINPODX_OEM_VERSION 11 → 12so 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.pendingresume system. New~/.config/winpodx/.pending_setupmarker tracks which install.sh steps couldn't complete (wait_ready/migrate/discovery). The next CLI invocation (any subcommand other thanversion/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/.welcomedso 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.logstream 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_sessions10 → 25 andpod.ram_gb4 → 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_specsreads/proc/meminfo+os.cpu_count()andrecommend_tiermaps 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 GBBoth 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_sessionswrote to the wrong registry key. The runtime apply targetedHKLM\...\Terminal Server\MaxInstanceCountbut Windows actually readsHKLM\...\Terminal Server\WinStations\RDP-Tcp\MaxInstanceCount. Result: every release since session-cap shipping silently no-op'd cfg changes — onlyinstall.bat's OEM-time value was authoritative. v0.2.1 writes the correct subkey (withfSingleSessionPerUserstill 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.
MaxDisconnectionTimewas set to0in bothinstall.batand_apply_rdp_timeouts. In RDP semantics that means no timeout — disconnected sessions stay alive forever. Each FreeRDP window the user closed left a session inDiscstate, 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 to30000(30 seconds) — disconnected sessions auto-logoff after 30 s, so the user can close and reopen apps freely without accumulating zombies. Patched ininstall.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_succeededand_on_refresh_failedslots both didself._refresh_worker = Noneimmediately. Python's reference drop raced with Qt's queuedworker.deleteLater()event — whichever ran second hit a freed/being-freedQObjectand crashed in~QObject()on the worker thread. Coredump on 2026-04-28 confirmed: top frameQObject::~QObjecton worker thread 2282062, while main thread 2281803 was inside the slot's PySide6callPythonMetaMethoddispatch. Fix: drop_refresh_worker/_refresh_threadPython refs only via_cleanup_refresh_worker, bound tothread.finishedwhich fires after both Qt objects are fully torn down. WorkerdeleteLaterkeeps 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) callspersist_discoveredwhich calls_validate_png_bytes, which usedQImage.loadFromData— but Qt + libgallium / Mesa state on Wayland race whenQImageis touched off the main thread, dropping aSignal: 11 (SEGV)core. v0.2.0.10 has_validate_png_bytesshort-circuit to the stdlib chunk walker whenthreading.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 --purgethen re-install) downloads the ~7.5 GB Windows ISO + extracts + Sysprep + OEM apply + final reboot — that's 15–30 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 2–5 min. - GUI Refresh now installs
.desktopentries (parity withwinpodx app refreshCLI). 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_DiscoveryWorkerruns_sync_desktop_entrieswhich is the worker-thread-safe sibling ofcli/app._register_desktop_entries.
Added
- GUI auto-discovery on first boot. When the pod transitions to
runningAND 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)andLive (app)start atail -Fagainst the container or~/.config/winpodx/winpodx.logand stream new lines into the panel as they appear;App logshows the last 200 lines of winpodx's own application log;Stop tailkills 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_sessionto the self-heal apply chain — it shells out tordprrap-conf --enableinside 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
.desktopentries 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: anywinpodx-*.desktopnot corresponding to an entry inlist_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-badgestyle. 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 refreshdiscovered apps but never registered them in the desktop menu. The refresh path persistedapp.toml+ icons under~/.local/share/winpodx/discovered/but the actual.desktopentries were only created by the separatewinpodx app install-allcommand — 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 Hiddenmakes 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_stampwith<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 --logsshowed no[container]lines on a fast container. Two issues: (1) the tail was started with--tail 0which 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) Onlystdoutwas 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 100so the user sees recent context immediately, and drains both streams in parallel threads.
Fixed
wait_for_windows_responsivecollapsed in <1s on a still-booting guest, defeating the entirepod wait-readyUX. 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 overalltimeoutexpires (paced 3s between attempts so we don't pin a CPU spinning FreeRDP). Nowpod wait-ready --timeout 600actually 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.shis now single-shot — install.sh exits when the install is actually finished, not when the container started. The flow is nowsetup→pod 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 sawInstallation complete!while Windows was still silently booting and had to wait again on first app launch. Skip the wait withWINPODX_NO_WAIT=1for CI / non-interactive setups; skip discovery withWINPODX_NO_DISCOVERY=1.- Removed the redundant
winpodx pod apply-fixescall 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
--purgeinstall. v0.1.9.5 added_probe_password_syncto 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=147ERRCONNECT_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:- 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. - 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.
- The probe now waits on
Fixed
- Discovery hit the same boot race the apply path used to. v0.2.0.1 gated
_apply_*andpod apply-fixesonwait_for_windows_responsive, butwinpodx migrate's "Run app discovery now?" prompt andprovisioner._auto_discover_if_empty(fired by ensure_ready on first pod boot) still launched the FreeRDP RemoteApp channel without a probe. On a fresh--purgereinstall the Windows VM was still booting inside QEMU when discovery fired, so the scan collapsed withERRCONNECT_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
--purgereinstall reported a bogus "0.1.7 -> X detected" upgrade.winpodx setupsavedwinpodx.tomlbut never stampedinstalled_version.txt, so the follow-upwinpodx migrate(whichinstall.shchains 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 toinstalled_version.txtif 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 momentpod_statusreportedRUNNING. The dockur Linux container reachesRUNNINGin seconds, but the Windows VM inside QEMU needs another 30–90s before its RDP listener can accept FreeRDP RemoteApp activation. Within that window every apply collapsed with eitherERRCONNECT_CONNECT_TRANSPORT_FAILED [0x0002000D](rc=147, RDP socket open but server not initialized — connection reset by peer) on a fresh install, orERRCONNECT_ACTIVATION_TIMEOUT [0x0002001C](rc=131, FreeRDP connected but activation phase didn't complete) onwinpodx 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 duringwinpodx setup→winpodx migrate. - New
wait_for_windows_responsive(cfg, timeout=90)helper: pollscheck_rdp_port, then fires a 20s no-opWrite-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-fixesCLI — explicit "Waiting for Windows guest to finish booting (up to 90s)…" message so users know it's not hung.winpodx migrateapply 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 inWindowsExecErrorswallow 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_fixesfailed on first apply withAllowComputerToTurnOffDeviceparameter error. v0.1.9.5 shipped the runtime apply through FreeRDP RemoteApp PowerShell, but the payload still passedSet-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 intry/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 refreshpreviously hung silently for 30–90s 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_windowsaccepts aprogress_callback, the wrapper exports$Global:WinpodxProgressFile+Write-WinpodxProgress, anddiscover_apps.ps1emits 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 tordprrap-conf.exeinside the Windows guest via FreeRDP RemoteApp, so users no longer need to recreate the container to enable / disable / inspect the patch. TriesC:\OEM\rdprrap\rdprrap-conf.exe,C:\OEM\rdprrap-conf.exe,C:\Program Files\rdprrap\rdprrap-conf.exein 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 toPackageFamilyName(e.g.Microsoft.AAD.BrokerPlugin). v0.2.0 drops these via host-side denylist patterns + executable basename matching + UWP fallback detection. SetWINPODX_DISCOVERY_INCLUDE_ALL=1to 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.tomlunder~/.local/share/winpodx/data/discovered/<slug>/icon.{png,svg}(already populated since v0.1.8); the GUI now reads them viaQPixmap(PNG, smooth scaled) andQSvgRenderer(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 utf8which (in Windows PowerShell 5.1) writes a UTF-8 BOM. The host thenjson.loads'd the file with the defaultutf-8codec 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_windowsnow reads withutf-8-sigso the BOM is consumed transparently. _apply_max_sessionskilled its own RDP session. The payload includedRestart-Service -Force TermServiceto 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 sawERRINFO_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-scriptRestart-Service; the registry write alone is enough, and TermService picks up the new value on its next natural cycle (next pod boot orwinpodx pod restart).
Changed (architectural)
- Migrated every remaining host-to-Windows command path off the broken
podman exec ... powershell.exechannel ontowindows_exec.run_in_windows. Six functions had been silently no-op'ing for releases 0.1.0 through 0.1.9.4 —podman execonly reaches the Linux container that hosts QEMU, not the Windows VM running inside, so any call topowershell.exereturnedrc=127 executable file not found in $PATHand 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_debloatandgui/main_window._on_debloat(debloat.ps1 — was double-broken:podman cpto copy the script +podman execto 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-passwordCLI 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 incompose.yml'sPASSWORDenv var), authenticates FreeRDP with it, then runsnet userinside 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 migrateruns and the user is on the "already current" path, it now fires a no-opWrite-Output 'sync-check'payload through the FreeRDP channel first. If FreeRDP fails with auth/no-result-file, migrate prints a clear "runwinpodx 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 undersrc/winpodx/(other thanwindows_exec.pyitself) reintroduces thepodman exec ... powershell.exepattern. Single canonical channel for Windows-side commands going forward.
Tests
tests/test_provisioner.pyupdated to mockwindows_exec.run_in_windowsfor_apply_max_sessionsand assert thatRestart-Serviceis no longer in the payload.tests/test_security.py::TestPowerShellEscaperewritten —_change_windows_passwordnow goes throughwindows_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_channelrewritten for the new transport.tests/test_discovery.py— five tests rewritten to mockwindows_exec.run_in_windowsinstead ofsubprocess.Popen+ stdin pipe. TheHARD_STDOUT_CAPflooding test was removed; the cap was specific to the_run_boundedpath 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.exereturnedrc=127 executable file not found in $PATH. Root cause:podman execruns commands in the Linux container that hosts QEMU, not in the Windows VM running inside QEMU; the Linux container has nopowershell.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-facingapply_windows_runtime_fixesreported per-helper "ok" because the helpers didn'traise. So three previous releases shipped silent no-ops. Three changes:- New
core/windows_exec.py—run_in_windows(cfg, ps_payload)launches PowerShell as a FreeRDP RemoteApp and pipes the script through the existing\\tsclient\homeredirection. Wrapper writes{rc, stdout, stderr}JSON back via the same share. The host parses it and returnsWindowsExecResult. Channel failures (FreeRDP missing, auth fail, timeout, no result file) raiseWindowsExecError; non-zero script rc surfaces viaWindowsExecResult.rc. _apply_max_sessions,_apply_rdp_timeouts,_apply_oem_runtime_fixesrewritten — each builds a PS payload, callsrun_in_windows, and nowraise RuntimeErroronrc != 0so failures actually propagate.apply_windows_runtime_fixeshonest reporting —try/excepton each helper still works the same way, but now an actualrc != 0from inside the Windows VM producesfailed: rc=2 ...instead of fakeok.
Cost: ~5–10 s per call (RDP handshake + auth + script + disconnect) plus a brief PowerShell window flash that
-WindowStyle Hiddenminimizes. Trade-off: works on existing pods (no container recreate) and the rc check actually means something.Caveat: requires
cfg.rdp.passwordto match the Windows guest's actual password. If password rotation has been silently failing for previous releases (samepodman execroot cause), the first call here will fail with auth error and the user has to reset the Windows-side password (openwinpodx app run desktop, runnet user User <password-from-config>). - New
Tests
- 9 new tests in
tests/test_windows_exec.pycovering 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.pyrewritten to mockwindows_exec.run_in_windowsinstead ofsubprocess.run. New tests assert each helper raisesRuntimeErroronrc != 0andWindowsExecErroron 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]truncated0.1.9.1and0.1.9.2to the same(0, 1, 9)tuple, soinst_cmp >= cur_cmptriggered 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-fixesstandalone CLI command. Idempotent — calls_apply_max_sessions,_apply_rdp_timeouts,_apply_oem_runtime_fixesagainst 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-fixesat the end of every install, after the migrate wizard. Failure-tolerant (|| true) — silent skip if the pod isn't running. This guarantees a freshcurl | bashalways 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_timeoutsruntime helper was wired intoprovisioner.ensure_readyAFTER itscheck_rdp_portearly-return — so the helper never fired against an already-healthy pod.provisioner.ensure_ready: probepod_statusonce 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 (NICSet-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false,sc.exe failure TermServicerecovery actions) to existing guests viapodman exec powershell— same stdin-pipe transportdiscover_apps.ps1uses. 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_failedconstructed aQMessageBox(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 viaQTimer.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 properlydeleteLaters 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
MaxIdleTimedefault). install.bat (OEM v7 -> v8) and a new_apply_rdp_timeoutsprovisioner step now writeMaxIdleTime=0,MaxDisconnectionTime=0,MaxConnectionTime=0,KeepAliveEnable=1+KeepAliveInterval=1to bothHKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Servicesand theRDP-TcpWinStation, plusKeepAliveTimeout=1on the WinStation so TCP keep-alive fires every minute. Existing 0.1.x guests get the runtime apply on the nextensure_readywithout 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 underdata/apps/were removed. The Linux app menu now populates exclusively viawinpodx app refresh, which is fired automatically byprovisioner.ensure_readythe first time the Windows pod is reachable and the discovered tree is empty. Manual rescan stays the same:winpodx app refreshfrom the CLI or the "Refresh Apps" button on the GUI Apps page.AppInfo.sourcedrops the"bundled"enum value — only"discovered"and"user"remain.winpodx migrateupgrading from any 0.1.x < 0.1.9 prompts to remove legacy~/.local/share/applications/winpodx-{14-bundled-slugs}.desktopfiles (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 infois 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-runsgather_infoon aQThread. All probes are hard-bounded so a sick pod can't block the panel.
Fixed
- Bug A:
winpodx app refreshon Windows. v0.1.8 usedpodman 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 thatpodman cpcannot write. The script body is now piped viapodman 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 tokind="pod_not_running"so the cli still routes to exit code 2 + the "runwinpodx 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, runspodman exec powershell Restart-Service -Force TermService; w32tm /resync /force, and re-probes RDP up to three times with backoff. Wired intoprovisioner.ensure_readypost-_ensure_pod_running. OEM bundle bumps 6 → 7 soinstall.batadds preventiveSet-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $falseplussc.exe failure TermService reset=86400 actions=restart/5000/restart/5000/restart/5000for Windows-side self-recovery.
[0.1.8] - 2026-04-25
Added
- Dynamic Windows-app discovery. A new
winpodx app refreshCLI 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.ps1scans RegistryApp Paths(HKLM + HKCU), Start Menu.lnkrecursion, UWP/MSIX packages viaGet-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 viapodman cp, executes it withpodman 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_commandnow accepts alaunch_uri+ strict-regex-validated AUMID (<PackageFamilyName>!<AppId>) and maps UWP apps to/app:program:explorer.exe,cmd:shell:AppsFolder\<AUMID>. Per-slugwinpodx-uwp-<aumid-slug>fallback for/wm-classkeeps Linux taskbar grouping distinct when two UWP apps share the same hint. - PowerShell Core smoke test in CI. A new
discover-apps-psjob installspwshon the Ubuntu runner and runsdiscover_apps.ps1 -DryRunon every PR, validating that stdout parses as the JSON array shapecore.discoveryexpects. - Post-upgrade migration wizard. A new
winpodx migrateCLI subcommand shows per-version release notes for every version the user has skipped over and optionally runswinpodx app refreshso the Windows-app menu populates in one step.install.shnow invokeswinpodx migrateautomatically at the end of every upgrade (existing~/.config/winpodx/winpodx.tomldetected); opt out withWINPODX_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 from0.1.7. pod.max_sessionsis now configurable. Default stays 10; clamped to[1, 50].ensure_ready()reads the value, compares against the guest's currentHKLM:\...\Terminal Server\MaxInstanceCount, and rewrites + restartsTermServiceonly when they disagree — active RemoteApp sessions aren't dropped every provision.fSingleSessionPerUser=0is also re-asserted on every apply. A rough memory budget helper (estimate_session_memory,check_session_budgetinwinpodx.core.config) surfaces a warning viawinpodx config show,winpodx config set,winpodx info, and the GUI Settings page only whenmax_sessionsover-subscribesram_gb— the default config stays silent.install.shlocal-path flags for offline / air-gapped installs.--source PATHcopies winpodx from a local directory instead ofgit clone(validatespyproject.toml+src/winpodx/are present).--image-tar PATHpreloads the Windows container image viapodman load -i(ordocker load -i) so first boot doesn't hit the registry.--skip-depsskips 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) socurl | bashcallers can compose them too.install.sh --helpprints the full usage.
Changed
AppInfogainssource: "bundled" | "discovered" | "user",args,wm_class_hint, andlaunch_urifields so the GUI can badge discovered entries and so RDP launches can target UWP apps.desktop.entry._install_iconnow dispatches betweenhicolor/scalable/apps/(SVG) andhicolor/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), andllccd/RDPWrapOffsetFinder(MIT). 0.1.2 shippedNOTICE+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 therdprrap-confAbout dialog. 0.1.3 expands theNOTICEto all 16 sources (grouped by upstream binary — RDPWInst / RDPConf / RDPCheck), aligns the About-dialog copyright to matchLICENSE, and cites CC BY 4.0 for the adapted Contributor Covenant text. It also carries forward the registry-readback fix that avoided thetermsrv.dlllcorruption inOriginalServiceDll. New bundle SHA256 is pinned inconfig/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.mddocumenting the bundled rdprrap binary and the runtime/optional Python dependencies (PySide6 LGPL, libvirt-python LGPL, docker-py Apache-2.0, tomli MIT). debian/copyrightnow declares the bundled rdprrap files separately and notes that the in-ZIPNOTICE+vendor/licenses/texts satisfy the upstream Apache-2.0 / MIT attribution requirements.
Fixed
install.shnow works undercurl … | bash. When piped, bash reads from stdin andBASH_SOURCE[0]is unset; combined with theset -uguard at the top of the script, that aborted the installer at line 205 withBASH_SOURCE[0]: unbound variablebefore 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 atC:\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
python3is 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
.debbuild no longer aborts with "missing files" duringdh_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.
.debbuild 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
.debpublish workflows now actually run and attach their artifacts to the Release. - RPM build is resilient to the upstream
pyproject.tomlversion 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 cleanupremoves~$*.*lock files from the home directory. - Windows time sync:
winpodx timesyncre-synchronizes the Windows clock after host sleep/wake. - Windows debloat:
winpodx debloatdisables telemetry, ads, Cortana, search indexing. - Power management:
winpodx power --suspend/--resumemanually pauses/resumes the container. - System diagnostics:
winpodx inforeports 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/windowsimage). - Per-app taskbar separation: each app gets a unique WM_CLASS /
StartupWMClass. - Windows build pinning:
TargetReleaseVersionpolicy blocks feature updates while leaving security updates on. - Upstream update monitoring: weekly check for new
dockur/windowsreleases. - 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
urbdrcwhen available, graceful fallback to drive sharing. - Windows-side USB drive-letter auto-mapping (event-based, no polling).
- Desktop integration:
.desktopentries, 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.confimport 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.