From 3e779be6f374a526155c53ff629abf95310f42c6 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:32:37 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .github/FUNDING.yml | 9 + .github/ISSUE_TEMPLATE/bug_report.md | 41 + .github/ISSUE_TEMPLATE/feature_request.md | 27 + .github/PULL_REQUEST_TEMPLATE.md | 23 + .github/workflows/appimage-publish.yml | 283 +++ .github/workflows/aur-publish.yml | 118 ++ .github/workflows/check-windows-updates.yml | 280 +++ .github/workflows/ci.yml | 99 + .github/workflows/debs-publish.yml | 154 ++ .github/workflows/obs-publish.yml | 264 +++ .github/workflows/pages.yml | 40 + .github/workflows/release.yml | 195 ++ .github/workflows/rhel-publish.yml | 206 ++ .gitignore | 102 + CHANGELOG.md | 1123 ++++++++++ CODE_OF_CONDUCT.md | 63 + CONTRIBUTING.md | 191 ++ LICENSE | 21 + README.md | 304 +++ README.wehub.md | 7 + SECURITY.md | 171 ++ THIRD_PARTY_LICENSES.md | 137 ++ config/oem/README.md | 23 + config/oem/VERSIONS.txt | 24 + config/oem/agent-keepalive.ps1 | 128 ++ config/oem/agent-respawn.ps1 | 74 + config/oem/agent/agent.ps1 | 357 ++++ config/oem/disguise-cleanup.ps1 | 34 + config/oem/hidden-launcher.vbs | 34 + config/oem/install.bat | 762 +++++++ config/oem/launch_uwp.ps1 | 94 + config/oem/launch_uwp.vbs | 32 + config/oem/power-monitor.ps1 | 81 + config/oem/rdprrap-0.3.0-windows-x64.zip | Bin 0 -> 1716173 bytes config/oem/rdprrap-activate.ps1 | 191 ++ config/oem/rdprrap_version.txt | 3 + config/oem/reverse-open/register-apps.ps1 | 514 +++++ config/oem/reverse-open/shim/.gitignore | 5 + config/oem/reverse-open/shim/Cargo.toml | 33 + .../reverse-open/shim/bin/LICENSE-rcedit.txt | 20 + config/oem/reverse-open/shim/bin/rcedit.exe | Bin 0 -> 1360384 bytes .../shim/bin/winpodx-reverse-open-shim.exe | Bin 0 -> 270848 bytes config/oem/reverse-open/shim/build.rs | 45 + config/oem/reverse-open/shim/src/main.rs | 279 +++ config/oem/reverse-open/unregister-apps.ps1 | 314 +++ config/oem/toggle_updates.ps1 | 104 + data/debloat/items.toml | 148 ++ data/locale/windows_zones.toml | 201 ++ data/winpodx-icon.svg | 27 + data/winpodx.desktop | 11 + data/winpodx.toml.example | 66 + debian/changelog | 1036 +++++++++ debian/control | 38 + debian/copyright | 76 + debian/postinst | 35 + debian/postrm | 44 + debian/rules | 11 + debian/source/format | 1 + debian/winpodx.docs | 3 + debian/winpodx.install | 2 + docs/ARCHITECTURE.ko.md | 228 ++ docs/ARCHITECTURE.md | 240 +++ docs/CHANGELOG.ko.md | 1100 ++++++++++ docs/CODE_OF_CONDUCT.ko.md | 60 + docs/COMPARISON.ko.md | 55 + docs/COMPARISON.md | 55 + docs/CONTRIBUTING.ko.md | 168 ++ docs/FEATURES.ko.md | 244 +++ docs/FEATURES.md | 244 +++ docs/INSTALL.ko.md | 348 +++ docs/INSTALL.md | 348 +++ docs/README.ko.md | 296 +++ docs/README.md | 26 + docs/RELEASE_TESTING.ko.md | 97 + docs/RELEASE_TESTING.md | 105 + docs/SECURITY.ko.md | 72 + docs/USAGE.ko.md | 346 +++ docs/USAGE.md | 347 +++ docs/design/AGENT_FIRST_INSTALL_DESIGN.md | 1070 ++++++++++ docs/design/AGENT_V2_DESIGN.md | 289 +++ docs/design/GUEST_SYNC_DESIGN.md | 110 + docs/design/LIFECYCLE.ko.md | 570 +++++ docs/design/LIFECYCLE.md | 638 ++++++ docs/design/PROVISION_UNIFY_FIDELITY_AUDIT.md | 66 + docs/design/README.md | 42 + docs/design/REVERSE_OPEN_DESIGN.md | 1044 +++++++++ docs/design/ROADMAP-0.6.0.md | 80 + .../ROTATION_SECURITY_REVIEW_2026-05-07.md | 288 +++ docs/design/TRANSPORT_ABC.md | 331 +++ docs/design/USB-PASSTHROUGH-INVESTIGATION.md | 197 ++ docs/design/install_failure.schema.json | 114 + docs/images/CI.png | Bin 0 -> 112034 bytes docs/images/CI.svg | 28 + docs/images/CI_ICON.svg | 53 + flake.lock | 27 + flake.nix | 122 ++ install.sh | 1752 +++++++++++++++ packaging/appimage/README.md | 122 ++ packaging/appimage/build.sh | 93 + packaging/appimage/bundle-system-bins.sh | 168 ++ packaging/appimage/licenses/README.md | 55 + packaging/appimage/licenses/conmon/LICENSE | 190 ++ packaging/appimage/licenses/crun/COPYING | 339 +++ .../appimage/licenses/freerdp-libs/LICENSE | 202 ++ packaging/appimage/licenses/libwinpr/LICENSE | 202 ++ packaging/appimage/licenses/netavark/LICENSE | 201 ++ .../licenses/netavark/LICENSE.dependencies | 234 +++ .../licenses/netavark/cargo-vendor.txt | 301 +++ .../appimage/licenses/passt/BSD-3-Clause.txt | 27 + .../licenses/passt/GPL-2.0-or-later.txt | 117 ++ .../appimage/licenses/podman-compose/LICENSE | 339 +++ packaging/appimage/licenses/podman/LICENSE | 201 ++ .../appimage/licenses/podman/modules.txt | 1211 +++++++++++ .../licenses/pyside6/GPL-3.0-only.txt | 674 ++++++ .../licenses/pyside6/LGPL-3.0-only.txt | 165 ++ .../appimage/licenses/pyside6/NOTICE.txt | 30 + .../appimage/licenses/slirp4netns/COPYING | 280 +++ packaging/appimage/recipe/entrypoint.sh | 47 + packaging/appimage/recipe/requirements.txt | 6 + packaging/appimage/recipe/winpodx.desktop | 10 + packaging/appimage/recipe/winpodx.png | Bin 0 -> 12782 bytes packaging/appimage/slim-pyside6.sh | 105 + packaging/aur-git/.SRCINFO | 24 + packaging/aur-git/PKGBUILD | 77 + packaging/aur-git/README.md | 60 + packaging/aur-git/winpodx.install | 46 + packaging/aur/PKGBUILD | 63 + packaging/aur/README.md | 100 + packaging/aur/winpodx.install | 46 + packaging/obs/README.md | 164 ++ packaging/obs/_service | 24 + packaging/qemu-disguise/Dockerfile | 121 ++ packaging/qemu-disguise/README.md | 77 + packaging/qemu-disguise/patch-strings.sh | 125 ++ packaging/qemu-disguise/ssdt-sensors.asl | 43 + packaging/qemu-disguise/wsmt.asl | 28 + packaging/rpm/winpodx.spec | 150 ++ packaging/scripts/postrm-common.sh | 86 + pyproject.toml | 105 + scripts/ci/obs_check_shipping.py | 82 + scripts/ci/validate_discover_dryrun.py | 67 + scripts/ci/verify_versions.py | 174 ++ scripts/windows/debloat/ads.ps1 | 65 + scripts/windows/debloat/onedrive.ps1 | 29 + scripts/windows/debloat/scheduled_tasks.ps1 | 58 + scripts/windows/debloat/search_indexing.ps1 | 12 + scripts/windows/debloat/startup_programs.ps1 | 35 + scripts/windows/debloat/sysmain.ps1 | 8 + scripts/windows/debloat/telemetry.ps1 | 12 + scripts/windows/debloat/transparency.ps1 | 8 + scripts/windows/debloat/undo/ads.ps1 | 41 + .../windows/debloat/undo/scheduled_tasks.ps1 | 31 + .../windows/debloat/undo/search_indexing.ps1 | 6 + scripts/windows/debloat/undo/sysmain.ps1 | 6 + scripts/windows/debloat/undo/telemetry.ps1 | 12 + scripts/windows/debloat/undo/transparency.ps1 | 5 + .../windows/debloat/undo/visual_effects.ps1 | 22 + scripts/windows/debloat/undo/web_search.ps1 | 18 + scripts/windows/debloat/undo/widgets.ps1 | 8 + scripts/windows/debloat/visual_effects.ps1 | 27 + scripts/windows/debloat/web_search.ps1 | 29 + scripts/windows/debloat/widgets.ps1 | 9 + scripts/windows/discover_apps.ps1 | 1102 ++++++++++ scripts/windows/find_teams.ps1 | 53 + scripts/windows/rdp_setup.reg | 21 + scripts/windows/time_sync.ps1 | 43 + src/winpodx/__init__.py | 18 + src/winpodx/__main__.py | 8 + src/winpodx/backend/__init__.py | 0 src/winpodx/backend/_hostenv.py | 149 ++ src/winpodx/backend/base.py | 196 ++ src/winpodx/backend/docker.py | 135 ++ src/winpodx/backend/manual.py | 35 + src/winpodx/backend/podman.py | 235 +++ src/winpodx/backend/select.py | 99 + src/winpodx/cli/__init__.py | 0 src/winpodx/cli/app.py | 427 ++++ src/winpodx/cli/config_cmd.py | 247 +++ src/winpodx/cli/debloat_menu.py | 194 ++ src/winpodx/cli/device.py | 263 +++ src/winpodx/cli/disguise.py | 200 ++ src/winpodx/cli/doctor.py | 917 ++++++++ src/winpodx/cli/first_run.py | 163 ++ src/winpodx/cli/guest.py | 44 + src/winpodx/cli/host_open.py | 794 +++++++ src/winpodx/cli/install_cmd.py | 53 + src/winpodx/cli/main.py | 1565 ++++++++++++++ src/winpodx/cli/migrate.py | 1147 ++++++++++ src/winpodx/cli/pod.py | 1872 +++++++++++++++++ src/winpodx/cli/pod_install_resume.py | 54 + src/winpodx/cli/pod_install_status.py | 57 + src/winpodx/cli/setup_cmd.py | 1268 +++++++++++ src/winpodx/cli/uninstall.py | 66 + src/winpodx/core/__init__.py | 0 src/winpodx/core/agent.py | 331 +++ src/winpodx/core/agent_install_state.py | 306 +++ src/winpodx/core/agent_resync.py | 138 ++ src/winpodx/core/app.py | 403 ++++ src/winpodx/core/checks.py | 423 ++++ src/winpodx/core/compose.py | 39 + src/winpodx/core/config.py | 1211 +++++++++++ src/winpodx/core/daemon.py | 471 +++++ src/winpodx/core/debloat.py | 407 ++++ src/winpodx/core/deps_quickcheck.py | 68 + src/winpodx/core/devices.py | 454 ++++ src/winpodx/core/discovery/__init__.py | 1670 +++++++++++++++ src/winpodx/core/disk.py | 556 +++++ src/winpodx/core/guest_disk.py | 249 +++ src/winpodx/core/guest_sync.py | 499 +++++ src/winpodx/core/i18n.py | 109 + src/winpodx/core/info.py | 240 +++ src/winpodx/core/install_state.py | 371 ++++ src/winpodx/core/mime_map.py | 98 + src/winpodx/core/pod/__init__.py | 36 + src/winpodx/core/pod/backend.py | 138 ++ src/winpodx/core/pod/compose.py | 937 +++++++++ src/winpodx/core/pod/health.py | 170 ++ src/winpodx/core/pod/lifecycle.py | 42 + src/winpodx/core/pod/recovery.py | 118 ++ src/winpodx/core/pod/smbios.py | 277 +++ src/winpodx/core/process.py | 209 ++ src/winpodx/core/provisioner.py | 1461 +++++++++++++ src/winpodx/core/rdp.py | 1577 ++++++++++++++ src/winpodx/core/rotation/__init__.py | 275 +++ src/winpodx/core/stats.py | 551 +++++ src/winpodx/core/storage_migration.py | 511 +++++ src/winpodx/core/transport/__init__.py | 38 + src/winpodx/core/transport/agent.py | 134 ++ src/winpodx/core/transport/base.py | 141 ++ src/winpodx/core/transport/dispatch.py | 87 + src/winpodx/core/transport/freerdp.py | 146 ++ src/winpodx/core/updates.py | 69 + src/winpodx/core/url_schemes.py | 102 + src/winpodx/core/usbredir.py | 476 +++++ src/winpodx/core/windows_exec.py | 354 ++++ src/winpodx/desktop/__init__.py | 0 src/winpodx/desktop/autostart.py | 140 ++ src/winpodx/desktop/entry.py | 242 +++ src/winpodx/desktop/icons.py | 200 ++ src/winpodx/desktop/menu.py | 236 +++ src/winpodx/desktop/mime.py | 125 ++ src/winpodx/desktop/notify.py | 103 + src/winpodx/desktop/tray.py | 897 ++++++++ src/winpodx/desktop/tray_spawn.py | 124 ++ src/winpodx/display/__init__.py | 0 src/winpodx/display/detector.py | 71 + src/winpodx/display/layout.py | 210 ++ src/winpodx/display/scaling.py | 228 ++ src/winpodx/gui/__init__.py | 0 src/winpodx/gui/_main_window_apps.py | 355 ++++ src/winpodx/gui/_main_window_bringup.py | 1410 +++++++++++++ src/winpodx/gui/_main_window_dashboard.py | 473 +++++ src/winpodx/gui/_main_window_devices.py | 500 +++++ src/winpodx/gui/_main_window_header.py | 348 +++ src/winpodx/gui/_main_window_info.py | 440 ++++ src/winpodx/gui/_main_window_library.py | 1125 ++++++++++ src/winpodx/gui/_main_window_license.py | 338 +++ src/winpodx/gui/_main_window_logs.py | 581 +++++ src/winpodx/gui/_main_window_maintenance.py | 834 ++++++++ src/winpodx/gui/_main_window_nav.py | 300 +++ src/winpodx/gui/_main_window_pod.py | 403 ++++ src/winpodx/gui/_main_window_settings.py | 1492 +++++++++++++ src/winpodx/gui/_ring_gauge.py | 213 ++ src/winpodx/gui/_widget_helpers.py | 587 ++++++ src/winpodx/gui/app_dialog.py | 535 +++++ src/winpodx/gui/debloat_picker.py | 341 +++ src/winpodx/gui/deleted_apps_dialog.py | 142 ++ src/winpodx/gui/icons/README | 1 + src/winpodx/gui/icons/__init__.py | 41 + src/winpodx/gui/icons/check.svg | 3 + src/winpodx/gui/icons/chevron-left.svg | 3 + src/winpodx/gui/icons/chevron-right.svg | 3 + src/winpodx/gui/icons/clean.svg | 5 + src/winpodx/gui/icons/clock.svg | 4 + src/winpodx/gui/icons/close.svg | 4 + src/winpodx/gui/icons/desktop.svg | 5 + src/winpodx/gui/icons/diamond.svg | 3 + src/winpodx/gui/icons/dot.svg | 3 + src/winpodx/gui/icons/error.svg | 5 + src/winpodx/gui/icons/gear.svg | 11 + src/winpodx/gui/icons/globe.svg | 6 + src/winpodx/gui/icons/grid.svg | 6 + src/winpodx/gui/icons/hardware.svg | 11 + src/winpodx/gui/icons/home.svg | 4 + src/winpodx/gui/icons/list.svg | 8 + src/winpodx/gui/icons/minus.svg | 3 + src/winpodx/gui/icons/overflow.svg | 5 + src/winpodx/gui/icons/pause.svg | 4 + src/winpodx/gui/icons/pending.svg | 6 + src/winpodx/gui/icons/performance.svg | 5 + src/winpodx/gui/icons/pin.svg | 4 + src/winpodx/gui/icons/play.svg | 3 + src/winpodx/gui/icons/plus.svg | 4 + src/winpodx/gui/icons/prompt.svg | 4 + src/winpodx/gui/icons/rdp.svg | 6 + src/winpodx/gui/icons/refresh.svg | 6 + .../gui/icons/reverse-associations.svg | 7 + src/winpodx/gui/icons/search.svg | 4 + src/winpodx/gui/icons/session.svg | 4 + src/winpodx/gui/icons/stop.svg | 3 + src/winpodx/gui/icons/update-arrows.svg | 6 + src/winpodx/gui/icons/warning.svg | 5 + src/winpodx/gui/launcher.py | 1384 ++++++++++++ src/winpodx/gui/launcher_state.py | 124 ++ src/winpodx/gui/main_window.py | 344 +++ src/winpodx/gui/reverse_open_panel.py | 474 +++++ src/winpodx/gui/spawn.py | 71 + src/winpodx/gui/theme.py | 738 +++++++ src/winpodx/gui/workers.py | 141 ++ src/winpodx/locale/de.json | 924 ++++++++ src/winpodx/locale/fr.json | 924 ++++++++ src/winpodx/locale/it.json | 924 ++++++++ src/winpodx/locale/ja.json | 924 ++++++++ src/winpodx/locale/ko.json | 924 ++++++++ src/winpodx/locale/zh.json | 924 ++++++++ src/winpodx/reverse_open/__init__.py | 35 + src/winpodx/reverse_open/apps_db.py | 209 ++ src/winpodx/reverse_open/config.py | 148 ++ src/winpodx/reverse_open/discovery.py | 469 +++++ src/winpodx/reverse_open/icons.py | 425 ++++ src/winpodx/reverse_open/lifecycle.py | 404 ++++ src/winpodx/reverse_open/listener.py | 585 ++++++ src/winpodx/reverse_open/mime.py | 407 ++++ src/winpodx/reverse_open/paths.py | 501 +++++ src/winpodx/reverse_open/seen_uuids.py | 315 +++ src/winpodx/reverse_open/sync.py | 421 ++++ src/winpodx/setup_wizard/__init__.py | 33 + src/winpodx/setup_wizard/__main__.py | 130 ++ src/winpodx/setup_wizard/host_state.py | 140 ++ src/winpodx/setup_wizard/pkexec.py | 177 ++ src/winpodx/utils/__init__.py | 0 src/winpodx/utils/agent_token.py | 83 + src/winpodx/utils/btrfs.py | 230 ++ src/winpodx/utils/compat.py | 131 ++ src/winpodx/utils/deps.py | 189 ++ src/winpodx/utils/install_source.py | 339 +++ src/winpodx/utils/locale.py | 185 ++ src/winpodx/utils/logging.py | 113 + src/winpodx/utils/paths.py | 74 + src/winpodx/utils/pending.py | 204 ++ src/winpodx/utils/specs.py | 409 ++++ src/winpodx/utils/toml_writer.py | 67 + tests/__init__.py | 0 tests/conftest.py | 30 + tests/test_agent.py | 363 ++++ tests/test_agent_install_state.py | 390 ++++ tests/test_agent_port_constant.py | 56 + tests/test_agent_ps1_syntax.py | 268 +++ tests/test_agent_resync.py | 174 ++ tests/test_agent_token.py | 141 ++ tests/test_app.py | 685 ++++++ tests/test_appimage_recipe.py | 216 ++ tests/test_audit5_core.py | 423 ++++ tests/test_backend.py | 248 +++ tests/test_backend_select.py | 158 ++ tests/test_btrfs.py | 241 +++ tests/test_busydialog_and_layout.py | 79 + tests/test_checks.py | 369 ++++ tests/test_cli_host_open.py | 400 ++++ tests/test_cli_issues.py | 742 +++++++ tests/test_command_taxonomy.py | 902 ++++++++ tests/test_compose_arch.py | 547 +++++ tests/test_compose_network.py | 46 + tests/test_compose_timezone.py | 87 + tests/test_config.py | 726 +++++++ tests/test_config_schema_version.py | 164 ++ tests/test_config_set_auto.py | 135 ++ tests/test_daemon.py | 397 ++++ tests/test_dashboard_reverse_open_toggle.py | 67 + tests/test_debloat_catalog.py | 307 +++ tests/test_debloat_menu.py | 175 ++ tests/test_debloat_picker.py | 119 ++ tests/test_deps_unified.py | 238 +++ tests/test_desktop.py | 920 ++++++++ tests/test_device_cli.py | 142 ++ tests/test_devices.py | 290 +++ tests/test_devices_gui.py | 122 ++ tests/test_discover_apps_ps1_syntax.py | 102 + tests/test_discovery/__init__.py | 0 tests/test_discovery/test_discovery.py | 1340 ++++++++++++ tests/test_discovery/test_public_api.py | 70 + tests/test_disguise_cli.py | 166 ++ tests/test_disk.py | 305 +++ tests/test_display.py | 52 + tests/test_display_layout.py | 114 + tests/test_doctor.py | 524 +++++ tests/test_finish_provisioning.py | 826 ++++++++ tests/test_first_run_prompt.py | 194 ++ tests/test_guest_disk.py | 144 ++ tests/test_guest_sync.py | 247 +++ tests/test_gui_refresh_threading.py | 65 + tests/test_gui_spawn.py | 122 ++ tests/test_hostenv.py | 251 +++ tests/test_i18n.py | 67 + tests/test_info.py | 267 +++ tests/test_install_cli_stubs.py | 224 ++ tests/test_install_config.py | 186 ++ tests/test_install_sh_provision.py | 243 +++ tests/test_install_state.py | 388 ++++ tests/test_launcher.py | 73 + tests/test_library_filter_reentrancy.py | 87 + tests/test_locale.py | 175 ++ tests/test_main_window_bringup.py | 1076 ++++++++++ tests/test_main_window_logs.py | 97 + tests/test_migrate.py | 948 +++++++++ tests/test_mime_map.py | 110 + tests/test_no_broken_podman_exec.py | 84 + tests/test_oem_install_bat.py | 106 + tests/test_paths.py | 138 ++ tests/test_pending.py | 177 ++ tests/test_pod.py | 293 +++ tests/test_pod_recovery.py | 109 + tests/test_process.py | 234 +++ tests/test_provisioner.py | 501 +++++ tests/test_pyside6_hint.py | 107 + tests/test_rdp.py | 1392 ++++++++++++ tests/test_reverse_open_apps_db.py | 157 ++ tests/test_reverse_open_config.py | 260 +++ tests/test_reverse_open_discovery.py | 326 +++ tests/test_reverse_open_icons.py | 262 +++ tests/test_reverse_open_lifecycle.py | 299 +++ tests/test_reverse_open_listener.py | 676 ++++++ tests/test_reverse_open_mime.py | 321 +++ tests/test_reverse_open_panel.py | 187 ++ tests/test_reverse_open_paths.py | 615 ++++++ tests/test_reverse_open_seen_uuids.py | 393 ++++ tests/test_reverse_open_sync.py | 270 +++ tests/test_rotation/__init__.py | 0 tests/test_rotation/test_rotation.py | 171 ++ .../test_rotation_agent_first.py | 221 ++ tests/test_scaling.py | 160 ++ tests/test_security.py | 595 ++++++ tests/test_session_gate.py | 73 + tests/test_setup.py | 555 +++++ tests/test_setup_wizard.py | 110 + tests/test_setup_wizard_prompts.py | 86 + tests/test_smbios.py | 214 ++ tests/test_specs.py | 310 +++ tests/test_stats.py | 270 +++ tests/test_storage_migration.py | 712 +++++++ tests/test_sync_password_agent_first.py | 198 ++ tests/test_transport/__init__.py | 0 tests/test_transport/test_agent.py | 168 ++ tests/test_transport/test_base.py | 122 ++ tests/test_transport/test_dispatch.py | 91 + tests/test_transport/test_freerdp.py | 220 ++ tests/test_tray_reverse_open_lifecycle.py | 37 + tests/test_tray_sleep_listener.py | 49 + tests/test_uninstall_wrapper.py | 137 ++ tests/test_url_schemes.py | 80 + tests/test_usbredir.py | 117 ++ tests/test_version_edition_single_source.py | 127 ++ tests/test_wait_ready_eta.py | 133 ++ tests/test_windows_exec.py | 339 +++ tests/uninstall_smoke.sh | 185 ++ uninstall.sh | 591 ++++++ web/.nojekyll | 0 web/CNAME | 1 + web/assets/winpodx-demo.png | Bin 0 -> 364173 bytes web/assets/winpodx-icon.svg | 27 + web/faq.html | 125 ++ web/features.html | 205 ++ web/get-started.html | 168 ++ web/i18n.js | 79 + web/index.html | 250 +++ web/lang/de.json | 214 ++ web/lang/en.json | 214 ++ web/lang/fr.json | 214 ++ web/lang/it.json | 214 ++ web/lang/ja.json | 214 ++ web/lang/ko.json | 214 ++ web/lang/translations.js | 3 + web/lang/zh.json | 214 ++ web/style.css | 904 ++++++++ 474 files changed, 118156 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/appimage-publish.yml create mode 100644 .github/workflows/aur-publish.yml create mode 100644 .github/workflows/check-windows-updates.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/debs-publish.yml create mode 100644 .github/workflows/obs-publish.yml create mode 100644 .github/workflows/pages.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/rhel-publish.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 SECURITY.md create mode 100644 THIRD_PARTY_LICENSES.md create mode 100644 config/oem/README.md create mode 100644 config/oem/VERSIONS.txt create mode 100644 config/oem/agent-keepalive.ps1 create mode 100644 config/oem/agent-respawn.ps1 create mode 100644 config/oem/agent/agent.ps1 create mode 100644 config/oem/disguise-cleanup.ps1 create mode 100644 config/oem/hidden-launcher.vbs create mode 100644 config/oem/install.bat create mode 100644 config/oem/launch_uwp.ps1 create mode 100644 config/oem/launch_uwp.vbs create mode 100644 config/oem/power-monitor.ps1 create mode 100644 config/oem/rdprrap-0.3.0-windows-x64.zip create mode 100644 config/oem/rdprrap-activate.ps1 create mode 100644 config/oem/rdprrap_version.txt create mode 100644 config/oem/reverse-open/register-apps.ps1 create mode 100644 config/oem/reverse-open/shim/.gitignore create mode 100644 config/oem/reverse-open/shim/Cargo.toml create mode 100644 config/oem/reverse-open/shim/bin/LICENSE-rcedit.txt create mode 100644 config/oem/reverse-open/shim/bin/rcedit.exe create mode 100755 config/oem/reverse-open/shim/bin/winpodx-reverse-open-shim.exe create mode 100644 config/oem/reverse-open/shim/build.rs create mode 100644 config/oem/reverse-open/shim/src/main.rs create mode 100644 config/oem/reverse-open/unregister-apps.ps1 create mode 100644 config/oem/toggle_updates.ps1 create mode 100644 data/debloat/items.toml create mode 100644 data/locale/windows_zones.toml create mode 100644 data/winpodx-icon.svg create mode 100644 data/winpodx.desktop create mode 100644 data/winpodx.toml.example create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100755 debian/postinst create mode 100755 debian/postrm create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 debian/winpodx.docs create mode 100644 debian/winpodx.install create mode 100644 docs/ARCHITECTURE.ko.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CHANGELOG.ko.md create mode 100644 docs/CODE_OF_CONDUCT.ko.md create mode 100644 docs/COMPARISON.ko.md create mode 100644 docs/COMPARISON.md create mode 100644 docs/CONTRIBUTING.ko.md create mode 100644 docs/FEATURES.ko.md create mode 100644 docs/FEATURES.md create mode 100644 docs/INSTALL.ko.md create mode 100644 docs/INSTALL.md create mode 100644 docs/README.ko.md create mode 100644 docs/README.md create mode 100644 docs/RELEASE_TESTING.ko.md create mode 100644 docs/RELEASE_TESTING.md create mode 100644 docs/SECURITY.ko.md create mode 100644 docs/USAGE.ko.md create mode 100644 docs/USAGE.md create mode 100644 docs/design/AGENT_FIRST_INSTALL_DESIGN.md create mode 100644 docs/design/AGENT_V2_DESIGN.md create mode 100644 docs/design/GUEST_SYNC_DESIGN.md create mode 100644 docs/design/LIFECYCLE.ko.md create mode 100644 docs/design/LIFECYCLE.md create mode 100644 docs/design/PROVISION_UNIFY_FIDELITY_AUDIT.md create mode 100644 docs/design/README.md create mode 100644 docs/design/REVERSE_OPEN_DESIGN.md create mode 100644 docs/design/ROADMAP-0.6.0.md create mode 100644 docs/design/ROTATION_SECURITY_REVIEW_2026-05-07.md create mode 100644 docs/design/TRANSPORT_ABC.md create mode 100644 docs/design/USB-PASSTHROUGH-INVESTIGATION.md create mode 100644 docs/design/install_failure.schema.json create mode 100644 docs/images/CI.png create mode 100644 docs/images/CI.svg create mode 100644 docs/images/CI_ICON.svg create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 install.sh create mode 100644 packaging/appimage/README.md create mode 100755 packaging/appimage/build.sh create mode 100755 packaging/appimage/bundle-system-bins.sh create mode 100644 packaging/appimage/licenses/README.md create mode 100644 packaging/appimage/licenses/conmon/LICENSE create mode 100644 packaging/appimage/licenses/crun/COPYING create mode 100644 packaging/appimage/licenses/freerdp-libs/LICENSE create mode 100644 packaging/appimage/licenses/libwinpr/LICENSE create mode 100644 packaging/appimage/licenses/netavark/LICENSE create mode 100644 packaging/appimage/licenses/netavark/LICENSE.dependencies create mode 100644 packaging/appimage/licenses/netavark/cargo-vendor.txt create mode 100644 packaging/appimage/licenses/passt/BSD-3-Clause.txt create mode 100644 packaging/appimage/licenses/passt/GPL-2.0-or-later.txt create mode 100644 packaging/appimage/licenses/podman-compose/LICENSE create mode 100644 packaging/appimage/licenses/podman/LICENSE create mode 100644 packaging/appimage/licenses/podman/modules.txt create mode 100644 packaging/appimage/licenses/pyside6/GPL-3.0-only.txt create mode 100644 packaging/appimage/licenses/pyside6/LGPL-3.0-only.txt create mode 100644 packaging/appimage/licenses/pyside6/NOTICE.txt create mode 100644 packaging/appimage/licenses/slirp4netns/COPYING create mode 100755 packaging/appimage/recipe/entrypoint.sh create mode 100644 packaging/appimage/recipe/requirements.txt create mode 100644 packaging/appimage/recipe/winpodx.desktop create mode 100644 packaging/appimage/recipe/winpodx.png create mode 100755 packaging/appimage/slim-pyside6.sh create mode 100644 packaging/aur-git/.SRCINFO create mode 100644 packaging/aur-git/PKGBUILD create mode 100644 packaging/aur-git/README.md create mode 100644 packaging/aur-git/winpodx.install create mode 100644 packaging/aur/PKGBUILD create mode 100644 packaging/aur/README.md create mode 100644 packaging/aur/winpodx.install create mode 100644 packaging/obs/README.md create mode 100644 packaging/obs/_service create mode 100644 packaging/qemu-disguise/Dockerfile create mode 100644 packaging/qemu-disguise/README.md create mode 100755 packaging/qemu-disguise/patch-strings.sh create mode 100644 packaging/qemu-disguise/ssdt-sensors.asl create mode 100644 packaging/qemu-disguise/wsmt.asl create mode 100644 packaging/rpm/winpodx.spec create mode 100755 packaging/scripts/postrm-common.sh create mode 100644 pyproject.toml create mode 100755 scripts/ci/obs_check_shipping.py create mode 100755 scripts/ci/validate_discover_dryrun.py create mode 100755 scripts/ci/verify_versions.py create mode 100644 scripts/windows/debloat/ads.ps1 create mode 100644 scripts/windows/debloat/onedrive.ps1 create mode 100644 scripts/windows/debloat/scheduled_tasks.ps1 create mode 100644 scripts/windows/debloat/search_indexing.ps1 create mode 100644 scripts/windows/debloat/startup_programs.ps1 create mode 100644 scripts/windows/debloat/sysmain.ps1 create mode 100644 scripts/windows/debloat/telemetry.ps1 create mode 100644 scripts/windows/debloat/transparency.ps1 create mode 100644 scripts/windows/debloat/undo/ads.ps1 create mode 100644 scripts/windows/debloat/undo/scheduled_tasks.ps1 create mode 100644 scripts/windows/debloat/undo/search_indexing.ps1 create mode 100644 scripts/windows/debloat/undo/sysmain.ps1 create mode 100644 scripts/windows/debloat/undo/telemetry.ps1 create mode 100644 scripts/windows/debloat/undo/transparency.ps1 create mode 100644 scripts/windows/debloat/undo/visual_effects.ps1 create mode 100644 scripts/windows/debloat/undo/web_search.ps1 create mode 100644 scripts/windows/debloat/undo/widgets.ps1 create mode 100644 scripts/windows/debloat/visual_effects.ps1 create mode 100644 scripts/windows/debloat/web_search.ps1 create mode 100644 scripts/windows/debloat/widgets.ps1 create mode 100644 scripts/windows/discover_apps.ps1 create mode 100644 scripts/windows/find_teams.ps1 create mode 100644 scripts/windows/rdp_setup.reg create mode 100644 scripts/windows/time_sync.ps1 create mode 100644 src/winpodx/__init__.py create mode 100644 src/winpodx/__main__.py create mode 100644 src/winpodx/backend/__init__.py create mode 100644 src/winpodx/backend/_hostenv.py create mode 100644 src/winpodx/backend/base.py create mode 100644 src/winpodx/backend/docker.py create mode 100644 src/winpodx/backend/manual.py create mode 100644 src/winpodx/backend/podman.py create mode 100644 src/winpodx/backend/select.py create mode 100644 src/winpodx/cli/__init__.py create mode 100644 src/winpodx/cli/app.py create mode 100644 src/winpodx/cli/config_cmd.py create mode 100644 src/winpodx/cli/debloat_menu.py create mode 100644 src/winpodx/cli/device.py create mode 100644 src/winpodx/cli/disguise.py create mode 100644 src/winpodx/cli/doctor.py create mode 100644 src/winpodx/cli/first_run.py create mode 100644 src/winpodx/cli/guest.py create mode 100644 src/winpodx/cli/host_open.py create mode 100644 src/winpodx/cli/install_cmd.py create mode 100644 src/winpodx/cli/main.py create mode 100644 src/winpodx/cli/migrate.py create mode 100644 src/winpodx/cli/pod.py create mode 100644 src/winpodx/cli/pod_install_resume.py create mode 100644 src/winpodx/cli/pod_install_status.py create mode 100644 src/winpodx/cli/setup_cmd.py create mode 100644 src/winpodx/cli/uninstall.py create mode 100644 src/winpodx/core/__init__.py create mode 100644 src/winpodx/core/agent.py create mode 100644 src/winpodx/core/agent_install_state.py create mode 100644 src/winpodx/core/agent_resync.py create mode 100644 src/winpodx/core/app.py create mode 100644 src/winpodx/core/checks.py create mode 100644 src/winpodx/core/compose.py create mode 100644 src/winpodx/core/config.py create mode 100644 src/winpodx/core/daemon.py create mode 100644 src/winpodx/core/debloat.py create mode 100644 src/winpodx/core/deps_quickcheck.py create mode 100644 src/winpodx/core/devices.py create mode 100644 src/winpodx/core/discovery/__init__.py create mode 100644 src/winpodx/core/disk.py create mode 100644 src/winpodx/core/guest_disk.py create mode 100644 src/winpodx/core/guest_sync.py create mode 100644 src/winpodx/core/i18n.py create mode 100644 src/winpodx/core/info.py create mode 100644 src/winpodx/core/install_state.py create mode 100644 src/winpodx/core/mime_map.py create mode 100644 src/winpodx/core/pod/__init__.py create mode 100644 src/winpodx/core/pod/backend.py create mode 100644 src/winpodx/core/pod/compose.py create mode 100644 src/winpodx/core/pod/health.py create mode 100644 src/winpodx/core/pod/lifecycle.py create mode 100644 src/winpodx/core/pod/recovery.py create mode 100644 src/winpodx/core/pod/smbios.py create mode 100644 src/winpodx/core/process.py create mode 100644 src/winpodx/core/provisioner.py create mode 100644 src/winpodx/core/rdp.py create mode 100644 src/winpodx/core/rotation/__init__.py create mode 100644 src/winpodx/core/stats.py create mode 100644 src/winpodx/core/storage_migration.py create mode 100644 src/winpodx/core/transport/__init__.py create mode 100644 src/winpodx/core/transport/agent.py create mode 100644 src/winpodx/core/transport/base.py create mode 100644 src/winpodx/core/transport/dispatch.py create mode 100644 src/winpodx/core/transport/freerdp.py create mode 100644 src/winpodx/core/updates.py create mode 100644 src/winpodx/core/url_schemes.py create mode 100644 src/winpodx/core/usbredir.py create mode 100644 src/winpodx/core/windows_exec.py create mode 100644 src/winpodx/desktop/__init__.py create mode 100644 src/winpodx/desktop/autostart.py create mode 100644 src/winpodx/desktop/entry.py create mode 100644 src/winpodx/desktop/icons.py create mode 100644 src/winpodx/desktop/menu.py create mode 100644 src/winpodx/desktop/mime.py create mode 100644 src/winpodx/desktop/notify.py create mode 100644 src/winpodx/desktop/tray.py create mode 100644 src/winpodx/desktop/tray_spawn.py create mode 100644 src/winpodx/display/__init__.py create mode 100644 src/winpodx/display/detector.py create mode 100644 src/winpodx/display/layout.py create mode 100644 src/winpodx/display/scaling.py create mode 100644 src/winpodx/gui/__init__.py create mode 100644 src/winpodx/gui/_main_window_apps.py create mode 100644 src/winpodx/gui/_main_window_bringup.py create mode 100644 src/winpodx/gui/_main_window_dashboard.py create mode 100644 src/winpodx/gui/_main_window_devices.py create mode 100644 src/winpodx/gui/_main_window_header.py create mode 100644 src/winpodx/gui/_main_window_info.py create mode 100644 src/winpodx/gui/_main_window_library.py create mode 100644 src/winpodx/gui/_main_window_license.py create mode 100644 src/winpodx/gui/_main_window_logs.py create mode 100644 src/winpodx/gui/_main_window_maintenance.py create mode 100644 src/winpodx/gui/_main_window_nav.py create mode 100644 src/winpodx/gui/_main_window_pod.py create mode 100644 src/winpodx/gui/_main_window_settings.py create mode 100644 src/winpodx/gui/_ring_gauge.py create mode 100644 src/winpodx/gui/_widget_helpers.py create mode 100644 src/winpodx/gui/app_dialog.py create mode 100644 src/winpodx/gui/debloat_picker.py create mode 100644 src/winpodx/gui/deleted_apps_dialog.py create mode 100644 src/winpodx/gui/icons/README create mode 100644 src/winpodx/gui/icons/__init__.py create mode 100644 src/winpodx/gui/icons/check.svg create mode 100644 src/winpodx/gui/icons/chevron-left.svg create mode 100644 src/winpodx/gui/icons/chevron-right.svg create mode 100644 src/winpodx/gui/icons/clean.svg create mode 100644 src/winpodx/gui/icons/clock.svg create mode 100644 src/winpodx/gui/icons/close.svg create mode 100644 src/winpodx/gui/icons/desktop.svg create mode 100644 src/winpodx/gui/icons/diamond.svg create mode 100644 src/winpodx/gui/icons/dot.svg create mode 100644 src/winpodx/gui/icons/error.svg create mode 100644 src/winpodx/gui/icons/gear.svg create mode 100644 src/winpodx/gui/icons/globe.svg create mode 100644 src/winpodx/gui/icons/grid.svg create mode 100644 src/winpodx/gui/icons/hardware.svg create mode 100644 src/winpodx/gui/icons/home.svg create mode 100644 src/winpodx/gui/icons/list.svg create mode 100644 src/winpodx/gui/icons/minus.svg create mode 100644 src/winpodx/gui/icons/overflow.svg create mode 100644 src/winpodx/gui/icons/pause.svg create mode 100644 src/winpodx/gui/icons/pending.svg create mode 100644 src/winpodx/gui/icons/performance.svg create mode 100644 src/winpodx/gui/icons/pin.svg create mode 100644 src/winpodx/gui/icons/play.svg create mode 100644 src/winpodx/gui/icons/plus.svg create mode 100644 src/winpodx/gui/icons/prompt.svg create mode 100644 src/winpodx/gui/icons/rdp.svg create mode 100644 src/winpodx/gui/icons/refresh.svg create mode 100644 src/winpodx/gui/icons/reverse-associations.svg create mode 100644 src/winpodx/gui/icons/search.svg create mode 100644 src/winpodx/gui/icons/session.svg create mode 100644 src/winpodx/gui/icons/stop.svg create mode 100644 src/winpodx/gui/icons/update-arrows.svg create mode 100644 src/winpodx/gui/icons/warning.svg create mode 100644 src/winpodx/gui/launcher.py create mode 100644 src/winpodx/gui/launcher_state.py create mode 100644 src/winpodx/gui/main_window.py create mode 100644 src/winpodx/gui/reverse_open_panel.py create mode 100644 src/winpodx/gui/spawn.py create mode 100644 src/winpodx/gui/theme.py create mode 100644 src/winpodx/gui/workers.py create mode 100644 src/winpodx/locale/de.json create mode 100644 src/winpodx/locale/fr.json create mode 100644 src/winpodx/locale/it.json create mode 100644 src/winpodx/locale/ja.json create mode 100644 src/winpodx/locale/ko.json create mode 100644 src/winpodx/locale/zh.json create mode 100644 src/winpodx/reverse_open/__init__.py create mode 100644 src/winpodx/reverse_open/apps_db.py create mode 100644 src/winpodx/reverse_open/config.py create mode 100644 src/winpodx/reverse_open/discovery.py create mode 100644 src/winpodx/reverse_open/icons.py create mode 100644 src/winpodx/reverse_open/lifecycle.py create mode 100644 src/winpodx/reverse_open/listener.py create mode 100644 src/winpodx/reverse_open/mime.py create mode 100644 src/winpodx/reverse_open/paths.py create mode 100644 src/winpodx/reverse_open/seen_uuids.py create mode 100644 src/winpodx/reverse_open/sync.py create mode 100644 src/winpodx/setup_wizard/__init__.py create mode 100644 src/winpodx/setup_wizard/__main__.py create mode 100644 src/winpodx/setup_wizard/host_state.py create mode 100644 src/winpodx/setup_wizard/pkexec.py create mode 100644 src/winpodx/utils/__init__.py create mode 100644 src/winpodx/utils/agent_token.py create mode 100644 src/winpodx/utils/btrfs.py create mode 100644 src/winpodx/utils/compat.py create mode 100644 src/winpodx/utils/deps.py create mode 100644 src/winpodx/utils/install_source.py create mode 100644 src/winpodx/utils/locale.py create mode 100644 src/winpodx/utils/logging.py create mode 100644 src/winpodx/utils/paths.py create mode 100644 src/winpodx/utils/pending.py create mode 100644 src/winpodx/utils/specs.py create mode 100644 src/winpodx/utils/toml_writer.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_agent.py create mode 100644 tests/test_agent_install_state.py create mode 100644 tests/test_agent_port_constant.py create mode 100644 tests/test_agent_ps1_syntax.py create mode 100644 tests/test_agent_resync.py create mode 100644 tests/test_agent_token.py create mode 100644 tests/test_app.py create mode 100644 tests/test_appimage_recipe.py create mode 100644 tests/test_audit5_core.py create mode 100644 tests/test_backend.py create mode 100644 tests/test_backend_select.py create mode 100644 tests/test_btrfs.py create mode 100644 tests/test_busydialog_and_layout.py create mode 100644 tests/test_checks.py create mode 100644 tests/test_cli_host_open.py create mode 100644 tests/test_cli_issues.py create mode 100644 tests/test_command_taxonomy.py create mode 100644 tests/test_compose_arch.py create mode 100644 tests/test_compose_network.py create mode 100644 tests/test_compose_timezone.py create mode 100644 tests/test_config.py create mode 100644 tests/test_config_schema_version.py create mode 100644 tests/test_config_set_auto.py create mode 100644 tests/test_daemon.py create mode 100644 tests/test_dashboard_reverse_open_toggle.py create mode 100644 tests/test_debloat_catalog.py create mode 100644 tests/test_debloat_menu.py create mode 100644 tests/test_debloat_picker.py create mode 100644 tests/test_deps_unified.py create mode 100644 tests/test_desktop.py create mode 100644 tests/test_device_cli.py create mode 100644 tests/test_devices.py create mode 100644 tests/test_devices_gui.py create mode 100644 tests/test_discover_apps_ps1_syntax.py create mode 100644 tests/test_discovery/__init__.py create mode 100644 tests/test_discovery/test_discovery.py create mode 100644 tests/test_discovery/test_public_api.py create mode 100644 tests/test_disguise_cli.py create mode 100644 tests/test_disk.py create mode 100644 tests/test_display.py create mode 100644 tests/test_display_layout.py create mode 100644 tests/test_doctor.py create mode 100644 tests/test_finish_provisioning.py create mode 100644 tests/test_first_run_prompt.py create mode 100644 tests/test_guest_disk.py create mode 100644 tests/test_guest_sync.py create mode 100644 tests/test_gui_refresh_threading.py create mode 100644 tests/test_gui_spawn.py create mode 100644 tests/test_hostenv.py create mode 100644 tests/test_i18n.py create mode 100644 tests/test_info.py create mode 100644 tests/test_install_cli_stubs.py create mode 100644 tests/test_install_config.py create mode 100644 tests/test_install_sh_provision.py create mode 100644 tests/test_install_state.py create mode 100644 tests/test_launcher.py create mode 100644 tests/test_library_filter_reentrancy.py create mode 100644 tests/test_locale.py create mode 100644 tests/test_main_window_bringup.py create mode 100644 tests/test_main_window_logs.py create mode 100644 tests/test_migrate.py create mode 100644 tests/test_mime_map.py create mode 100644 tests/test_no_broken_podman_exec.py create mode 100644 tests/test_oem_install_bat.py create mode 100644 tests/test_paths.py create mode 100644 tests/test_pending.py create mode 100644 tests/test_pod.py create mode 100644 tests/test_pod_recovery.py create mode 100644 tests/test_process.py create mode 100644 tests/test_provisioner.py create mode 100644 tests/test_pyside6_hint.py create mode 100644 tests/test_rdp.py create mode 100644 tests/test_reverse_open_apps_db.py create mode 100644 tests/test_reverse_open_config.py create mode 100644 tests/test_reverse_open_discovery.py create mode 100644 tests/test_reverse_open_icons.py create mode 100644 tests/test_reverse_open_lifecycle.py create mode 100644 tests/test_reverse_open_listener.py create mode 100644 tests/test_reverse_open_mime.py create mode 100644 tests/test_reverse_open_panel.py create mode 100644 tests/test_reverse_open_paths.py create mode 100644 tests/test_reverse_open_seen_uuids.py create mode 100644 tests/test_reverse_open_sync.py create mode 100644 tests/test_rotation/__init__.py create mode 100644 tests/test_rotation/test_rotation.py create mode 100644 tests/test_rotation/test_rotation_agent_first.py create mode 100644 tests/test_scaling.py create mode 100644 tests/test_security.py create mode 100644 tests/test_session_gate.py create mode 100644 tests/test_setup.py create mode 100644 tests/test_setup_wizard.py create mode 100644 tests/test_setup_wizard_prompts.py create mode 100644 tests/test_smbios.py create mode 100644 tests/test_specs.py create mode 100644 tests/test_stats.py create mode 100644 tests/test_storage_migration.py create mode 100644 tests/test_sync_password_agent_first.py create mode 100644 tests/test_transport/__init__.py create mode 100644 tests/test_transport/test_agent.py create mode 100644 tests/test_transport/test_base.py create mode 100644 tests/test_transport/test_dispatch.py create mode 100644 tests/test_transport/test_freerdp.py create mode 100644 tests/test_tray_reverse_open_lifecycle.py create mode 100644 tests/test_tray_sleep_listener.py create mode 100644 tests/test_uninstall_wrapper.py create mode 100644 tests/test_url_schemes.py create mode 100644 tests/test_usbredir.py create mode 100644 tests/test_version_edition_single_source.py create mode 100644 tests/test_wait_ready_eta.py create mode 100644 tests/test_windows_exec.py create mode 100755 tests/uninstall_smoke.sh create mode 100755 uninstall.sh create mode 100644 web/.nojekyll create mode 100644 web/CNAME create mode 100644 web/assets/winpodx-demo.png create mode 100644 web/assets/winpodx-icon.svg create mode 100644 web/faq.html create mode 100644 web/features.html create mode 100644 web/get-started.html create mode 100644 web/i18n.js create mode 100644 web/index.html create mode 100644 web/lang/de.json create mode 100644 web/lang/en.json create mode 100644 web/lang/fr.json create mode 100644 web/lang/it.json create mode 100644 web/lang/ja.json create mode 100644 web/lang/ko.json create mode 100644 web/lang/translations.js create mode 100644 web/lang/zh.json create mode 100644 web/style.css diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1af4ab3 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,9 @@ +# GitHub repo 'Sponsor' button. GitHub Sponsors is now approved for +# kernalix7, so it goes first (renders with the GitHub heart). Ko-fi is on +# GitHub's recognized list so it uses the proper `ko_fi:` field (renders +# with the Ko-fi icon). fairy.hada.io is a Korean tipping platform not on +# the recognized list, so it goes under `custom:` (generic external link). +github: kernalix7 +ko_fi: kernalix7 +custom: + - https://fairy.hada.io/@kernalix7 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..652f881 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug Report +about: Report a bug to help us improve WinPodX +title: "[Bug] " +labels: bug +assignees: '' +--- + +## Description + +A clear and concise description of the bug. + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. + +## Environment + +- **OS**: [e.g. Ubuntu 24.04, Fedora 41, openSUSE Tumbleweed] +- **Python version**: [e.g. 3.13] +- **FreeRDP version**: [e.g. 3.x] +- **Desktop Environment**: [e.g. GNOME 47, KDE Plasma 6] +- **Display Server**: [e.g. X11, Wayland] + +## Screenshots + +If applicable, add screenshots to help explain the problem. + +## Additional Context + +Any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..d1278d3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature Request +about: Suggest a new feature or improvement for WinPodX +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +## Summary + +A clear and concise description of the feature you'd like. + +## Motivation + +Why is this feature needed? What problem does it solve? + +## Proposed Solution + +Describe how you'd like this to work. + +## Alternatives Considered + +Any alternative solutions or features you've considered. + +## Additional Context + +Any other context, mockups, or references here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..60c9a05 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## Summary + +Brief description of what this PR does. + +## Changes + +- + +## Related Issues + +Closes # + +## Checklist + +- [ ] `pytest tests/ -v`: all tests pass +- [ ] `ruff check src/ tests/`: zero errors +- [ ] `ruff format --check src/ tests/`: formatted +- [ ] Documentation updated (CHANGELOG, docs: both ko & en) +- [ ] No hardcoded paths, credentials, or personal info + +## Screenshots + +If applicable, add screenshots for UI changes. diff --git a/.github/workflows/appimage-publish.yml b/.github/workflows/appimage-publish.yml new file mode 100644 index 0000000..acb712b --- /dev/null +++ b/.github/workflows/appimage-publish.yml @@ -0,0 +1,283 @@ +name: AppImage + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Optional release tag to build from (e.g. v0.5.8). Leave empty to build from the selected ref (usually main) -- useful for verifying the recipe before cutting a tag." + required: false + default: "" + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # workflow_dispatch input: explicit tag; tag push: ref already there. + ref: ${{ github.event.inputs.tag || github.ref }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build prerequisites + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade build + + - name: Pre-install appimagetool (extract-and-wrap, FUSE-free) + # GitHub Actions ubuntu-24.04 runners cannot mount AppImages + # via FUSE in the sandboxed environment. Download appimagetool + # ourselves, extract its squashfs payload, and put a thin + # wrapper on PATH at /usr/local/bin/appimagetool that exec's + # into the extracted AppRun. + run: | + cd /tmp + wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool.AppImage + chmod +x appimagetool.AppImage + ./appimagetool.AppImage --appimage-extract >/dev/null + sudo mv squashfs-root /opt/appimagetool + sudo tee /usr/local/bin/appimagetool >/dev/null <<'WRAPPER' + #!/bin/sh + exec /opt/appimagetool/AppRun "$@" + WRAPPER + sudo chmod +x /usr/local/bin/appimagetool + appimagetool --version || true + + - name: Build winpodx wheel + run: python -m build --wheel --outdir dist + + - name: Stage AppDir with portable Python + winpodx + # Bypass python-appimage entirely -- it does its own + # appimagetool download that ignores our PATH wrapper and + # silently fails on CI. Instead: drop a python-build-standalone + # portable Python 3.11 into AppDir, install winpodx into its + # site-packages, write AppRun + desktop + icon, then hand off + # to bundle-system-bins.sh for the FreeRDP overlay. + # + # Thin AppImage (0.6.0 item A): we no longer pip-install + # podman-compose into the bundled interpreter, and the dnf step + # below no longer installs the podman stack. Rootless podman + # cannot be bundled (host systemd / subuid integration); the + # AppImage now requires the user to install podman / docker / + # libvirt from the host package manager. See #357 / #363 for the + # root cause this redesign removes. + run: | + set -euo pipefail + mkdir -p AppDir/opt AppDir/usr/bin AppDir/usr/lib + # Portable Python 3.11 with linked OpenSSL, gnu-linux x86_64. + # Pinned to a known-good astral-sh python-build-standalone release. + # Bump the tag + version pair together when refreshing. + PYBUILD_URL="https://github.com/astral-sh/python-build-standalone/releases/download/20240909/cpython-3.11.10+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz" + curl -fsSL "${PYBUILD_URL}" -o /tmp/python-standalone.tar.gz + # Supply-chain: verify against the upstream .sha256 sidecar + # (astral-sh publishes one per asset). The portable interpreter + # executes winpodx's code -- a tampered asset would run with the + # user's RDP password + agent token, so fail closed on mismatch. + curl -fsSL "${PYBUILD_URL}.sha256" -o /tmp/python-standalone.sha256 + echo "$(cat /tmp/python-standalone.sha256) /tmp/python-standalone.tar.gz" | sha256sum -c - + tar -xzf /tmp/python-standalone.tar.gz -C AppDir/opt/ + # Tarball extracts to AppDir/opt/python/ on the install_only build. + AppDir/opt/python/bin/python3 -m pip install --upgrade pip + WHEEL="$(ls -t dist/winpodx-*.whl | head -n1)" + AppDir/opt/python/bin/python3 -m pip install --no-cache-dir "${WHEEL}[gui,reverse-open]" + # AppRun + cat > AppDir/AppRun <<'APPRUN' + #!/bin/sh + APPDIR="$(dirname "$(readlink -f "$0")")" + export PATH="$APPDIR/usr/bin:$APPDIR/opt/python/bin:$PATH" + export LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/opt/python/lib:$LD_LIBRARY_PATH" + export PYTHONPATH="$APPDIR/opt/python/lib/python3.11/site-packages:$PYTHONPATH" + # Invoke via `python3 -m winpodx`, NOT the pip-generated + # bin/winpodx console script. pip bakes an absolute build-time + # shebang (#!/home/runner/work/.../AppDir/opt/python/bin/python3) + # into bin/winpodx that doesn't exist at AppImage-extract time + # -> "bad interpreter". The -m form runs the module through the + # bundled interpreter directly and ignores the broken shebang. + exec "$APPDIR/opt/python/bin/python3" -m winpodx "$@" + APPRUN + chmod +x AppDir/AppRun + # Desktop + icon -- appimagetool expects them in AppDir root. + cp packaging/appimage/recipe/winpodx.desktop AppDir/ + cp packaging/appimage/recipe/winpodx.png AppDir/ + # Symlink common icon location too (some launchers look here) + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + cp packaging/appimage/recipe/winpodx.png AppDir/usr/share/icons/hicolor/256x256/apps/winpodx.png + # License texts (LGPL/GPL/Apache/PSF redistribution obligation -- + # the fat AppImage redistributes third-party binaries, so their + # license + NOTICE texts must travel with it). winpodx's own MIT + # LICENSE + the aggregate THIRD_PARTY_LICENSES.md go at the doc + # root; the Fedora-bundled binaries' texts are added by the + # docker step below under third-party/. PyPI deps (PySide6/Qt, + # Pillow, cairosvg, pyxdg) already carry their license in their + # site-packages *.dist-info inside AppDir/opt/python. + mkdir -p AppDir/usr/share/doc/winpodx/third-party + cp LICENSE THIRD_PARTY_LICENSES.md AppDir/usr/share/doc/winpodx/ + # python-build-standalone ships its license files; surface them. + find AppDir/opt/python -maxdepth 3 -iname "LICENSE*" -path "*python*" \ + -exec cp {} AppDir/usr/share/doc/winpodx/third-party/python-LICENSE.txt \; 2>/dev/null || true + # LGPL compliance (legal gate): the fat AppImage REDISTRIBUTES the + # PySide6/Qt6 binaries (LGPL-3.0), so their license text must travel + # with it. The PySide6 wheels do NOT ship the LGPL/GPL texts (verified: + # neither the dist-info nor the PySide6/ tree carries them), so we + # vendor the verbatim texts + a NOTICE in-repo under + # packaging/appimage/licenses/pyside6/ and copy those in. HARD-FAIL if + # the vendored texts are missing, so the compliance artifact can't be + # dropped silently. + mkdir -p AppDir/usr/share/doc/winpodx/third-party/pyside6 + cp packaging/appimage/licenses/pyside6/*.txt \ + AppDir/usr/share/doc/winpodx/third-party/pyside6/ + if ! ls AppDir/usr/share/doc/winpodx/third-party/pyside6/ 2>/dev/null \ + | grep -qiE "lgpl"; then + echo "::error::LGPL compliance: no PySide6/Qt LGPL text bundled in the AppImage; refusing to publish a non-compliant build." >&2 + exit 1 + fi + du -sh AppDir + + - name: Slim PySide6 — drop unused Qt6 modules + # PySide6 wheels bundle the entire Qt6 stack (QtWebEngine ~100 MB+, + # QtQuick/QtQml, Qt3D, QtCharts, QtMultimedia + FFmpeg, QtPdf, …). + # winpodx links only QtCore/QtGui/QtWidgets/QtSvg/QtDBus, so the rest + # is dead weight — this was the real reason the "Thin" AppImage was + # still ~270 MB (dropping the podman stack only saved ~20 MB). The + # prune removes ~500 MB uncompressed (the bulk of the AppImage). + run: | + bash packaging/appimage/slim-pyside6.sh \ + AppDir/opt/python/lib/python3.11/site-packages/PySide6 + du -sh AppDir + + - name: Bundle FreeRDP via Fedora 41 + # Thin-bundle stage: pulls only the FreeRDP 3 client binaries + + # libwinpr out of a Fedora 41 container and traverses ldd to bring + # in transitive lib deps. Host-critical libs (glibc, libX11, + # libwayland, libGL, ...) stay on the host -- they cannot be + # bundled without breaking desktop integration or hard-crashing + # on glibc mismatch. + # + # The container stack (podman / podman-compose / conmon / crun / + # netavark / aardvark-dns / slirp4netns / passt / pasta) is + # intentionally NOT installed here -- 0.6.0 item A drops it. + # Rootless podman needs host systemd / subuid integration that + # an AppImage can't carry; bundling it caused #357 (shadowed + # host podman-compose) and #363 (LD_LIBRARY_PATH-poisoned + # systemd-run / aardvark-dns). The AppImage now requires the + # user to install a container runtime from the host package + # manager (same model as install.sh). + run: | + docker run --rm \ + -v "$PWD/AppDir:/AppDir" \ + -v "$PWD/packaging/appimage:/scripts" \ + fedora:41 bash -c ' + set -euo pipefail + dnf install -y --setopt=install_weak_deps=False \ + freerdp \ + freerdp-libs \ + libwinpr \ + file + echo "[provenance] freerdp binaries provided:" + rpm -ql freerdp | grep -E "^(/usr/bin|/usr/libexec)/.*freerdp.*" || true + bash /scripts/bundle-system-bins.sh /AppDir + # License + NOTICE texts for the bundled FreeRDP stack + # (Apache-2.0). VENDORED in-repo at + # packaging/appimage/licenses/ (mounted at /scripts/licenses), + # harvested verbatim from the Fedora rpm payloads -- see + # that dir`s README for provenance + refresh steps. We do + # NOT read them off the installed filesystem: the fedora + # image installs with `tsflags=nodocs`, which strips + # /usr/share/licenses, and every attempt to defeat that on + # the CI runner silently produced nothing. + # + # Thin AppImage: only the FreeRDP stack`s license texts + # ride along now; the container-stack license dirs (podman + # / podman-compose / conmon / crun / netavark / passt / + # slirp4netns) stay vendored in-repo for provenance but are + # no longer copied into the AppImage because their binaries + # are no longer bundled. + dst=/AppDir/usr/share/doc/winpodx/third-party + mkdir -p "$dst" + src=/scripts/licenses + # REQUIRED FreeRDP-stack license texts -- fail the build if + # missing. Apache-2.0 redistribution obligation: freerdp-libs + # + libwinpr ship the combined FreeRDP license. + for pkg in freerdp-libs libwinpr; do + if [ ! -d "$src/$pkg" ]; then + echo "FATAL: vendored license dir $src/$pkg missing -- cannot ship a" >&2 + echo "license-compliant AppImage. Re-run packaging/appimage/licenses refresh." >&2 + exit 1 + fi + cp -rL "$src/$pkg" "$dst/$pkg" + done + echo "[provenance] bundled license dirs:" + ls -1 "$dst" || true + ' + + - name: Repack as thin AppImage + env: + ARCH: x86_64 + run: | + OUT="winpodx-x86_64.AppImage" + appimagetool AppDir "${OUT}" + chmod +x "${OUT}" + ls -lh "${OUT}" + + - name: Locate built AppImage + id: locate + run: | + APPIMAGE="$(ls -t winpodx-x86_64.AppImage | head -n1)" + if [ -z "${APPIMAGE}" ]; then + echo "No AppImage produced." >&2 + exit 1 + fi + echo "appimage_path=${APPIMAGE}" >> "$GITHUB_OUTPUT" + ls -lh "${APPIMAGE}" + + - name: Upload as workflow artefact (always) + uses: actions/upload-artifact@v4 + with: + name: winpodx-appimage-x86_64 + path: ${{ steps.locate.outputs.appimage_path }} + if-no-files-found: error + + # Single-writer rule: release.yml (REL- marker tag) is the SOLE creator + # of the GitHub Release and the only writer of its title/notes. This + # workflow only attaches the AppImage to an already-created release — + # it never creates or edits the notes. (softprops/action-gh-release + # would create-or-update the release even with only `files:` set, which + # contributed to the "edited N times" churn; `gh release upload` to an + # existing release does not.) Wait up to 12 min for release.yml — which + # runs the full test suite first — to create the release; skip + # gracefully if it never appears (the AppImage stays a build artifact). + - name: Wait for release (release.yml owns creation) + if: github.event_name == 'push' + id: wait + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ github.ref_name }}" + for _ in $(seq 1 48); do + if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $tag exists — attaching AppImage." + echo "exists=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Release $tag not created yet (release.yml owns creation) — waiting…" + sleep 15 + done + echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping AppImage upload; it remains as a build artifact." + echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Attach AppImage to GitHub Release (tag push only) + if: github.event_name == 'push' && steps.wait.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ github.ref_name }}" \ + "${{ steps.locate.outputs.appimage_path }}" --clobber \ + --repo "${{ github.repository }}" diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml new file mode 100644 index 0000000..f99e513 --- /dev/null +++ b/.github/workflows/aur-publish.yml @@ -0,0 +1,118 @@ +name: AUR Publish + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v0.1.0)" + required: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + # Secret-gated so tag pushes made before AUR_SSH_PRIVATE_KEY is set up + # (see packaging/aur/README.md for the one-time onboarding flow) do not + # red-X the Release. Once the secret is configured, subsequent tag + # pushes publish to AUR automatically with no other changes. + - name: Check AUR secret presence + id: check + env: + AUR_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: | + if [ -z "$AUR_KEY" ]; then + echo "::notice::AUR_SSH_PRIVATE_KEY is not set; skipping AUR publish. See packaging/aur/README.md for one-time setup." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve tag + if: steps.check.outputs.skip == 'false' + id: tag + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${{ github.ref_name }}" + else + tag="${{ inputs.tag }}" + fi + # AUR pkgver disallows '-' (reserved for the pkgrel delimiter), + # so strip the RTM gate suffix before stamping PKGBUILD. Tag + # name itself is preserved for the source URL. + version="${tag#v}" + version="${version%%-RTM*}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Render PKGBUILD (stamp version + sha256) + if: steps.check.outputs.skip == 'false' + id: render + run: | + set -e + version="${{ steps.tag.outputs.version }}" + tag="${{ steps.tag.outputs.tag }}" + url="https://github.com/${{ github.repository }}/archive/${tag}.tar.gz" + + # GitHub generates the archive tarball on first request; give it + # a few attempts in case the tag-push → archive materialization + # has a small race window. + for i in 1 2 3 4 5; do + sha=$(curl -sSL "$url" | sha256sum | awk '{print $1}') + # sha256 of empty string → don't accept it. + if [ "$sha" != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ] \ + && [ -n "$sha" ]; then + break + fi + echo "archive not ready yet, retrying ($i/5)..." + sleep 5 + done + if [ -z "$sha" ]; then + echo "failed to compute sha256 for $url" >&2 + exit 1 + fi + echo "sha256: $sha" + + # Anchor on the assignment lines so the template comment at the + # top (which mentions __PKGVER__ / __SHA256__ literally) is not + # also rewritten — otherwise the published PKGBUILD ships with + # the stamped values inlined into the explanatory text. + sed -e "s|^pkgver=__PKGVER__$|pkgver=${version}|" \ + -e "s|^sha256sums=('__SHA256__')$|sha256sums=('${sha}')|" \ + packaging/aur/PKGBUILD > PKGBUILD.rendered + mv PKGBUILD.rendered packaging/aur/PKGBUILD + echo "---- rendered PKGBUILD ----" + cat packaging/aur/PKGBUILD + + # KSXGitHub/github-actions-deploy-aur handles: makepkg-style SRCINFO + # regeneration, SSH key material setup, ssh-keyscan of aur.archlinux.org, + # and the git clone/commit/push to ssh://aur@aur.archlinux.org/.git. + # Pinned to v4.1.3 (specific patch) — v2 floating tag has an upstream + # regression that fails with `bash: --command: invalid option` + # (upstream issue #50, fixed 2026-04-18). v3+ uses the same input + # shape, so the pin is safe. + - name: Publish to AUR + if: steps.check.outputs.skip == 'false' + uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 + with: + pkgname: winpodx + pkgbuild: packaging/aur/PKGBUILD + # #255 PR 4: ship the install scriptlet alongside PKGBUILD so + # pacman's install= directive resolves at install time. Without + # this, the post_install / post_remove banners + cleanup hook + # don't fire (the PKGBUILD references winpodx.install but the + # AUR repo wouldn't have the file). + assets: | + packaging/aur/winpodx.install + commit_username: kernalix7 + commit_email: kernalix7@kodenet.io + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "Update to ${{ steps.tag.outputs.version }}" + ssh_keyscan_types: rsa,ecdsa,ed25519 diff --git a/.github/workflows/check-windows-updates.yml b/.github/workflows/check-windows-updates.yml new file mode 100644 index 0000000..45fe367 --- /dev/null +++ b/.github/workflows/check-windows-updates.yml @@ -0,0 +1,280 @@ +name: Check Windows Build Updates + +on: + schedule: + - cron: "0 9 * * 3" # Every Wednesday 09:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Check latest dockur Windows images + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # x86_64 Windows image (dockur/windows) + DOCKUR_LATEST=$(gh api repos/dockur/windows/releases/latest --jq '.tag_name' 2>/dev/null || echo "unknown") + echo "dockur_latest=$DOCKUR_LATEST" >> "$GITHUB_OUTPUT" + + # ARM Windows image (dockur/windows-arm) — pulled in by aarch64 + # hosts via core/config.py:_default_pod_image. See #141. + DOCKUR_ARM_LATEST=$(gh api repos/dockur/windows-arm/releases/latest --jq '.tag_name' 2>/dev/null || echo "unknown") + echo "dockur_arm_latest=$DOCKUR_ARM_LATEST" >> "$GITHUB_OUTPUT" + + VERSION_FILE="config/oem/VERSIONS.txt" + DOCKUR_CURRENT="" + DOCKUR_ARM_CURRENT="" + if [ -f "$VERSION_FILE" ]; then + DOCKUR_CURRENT=$(grep "^dockur=" "$VERSION_FILE" | cut -d= -f2 || echo "") + DOCKUR_ARM_CURRENT=$(grep "^dockur-arm=" "$VERSION_FILE" | cut -d= -f2 || echo "") + fi + + NEEDS_UPDATE_X86=false + if [ "$DOCKUR_LATEST" != "$DOCKUR_CURRENT" ] && [ "$DOCKUR_LATEST" != "unknown" ]; then + NEEDS_UPDATE_X86=true + echo "dockur/windows: ${DOCKUR_CURRENT:-none} -> $DOCKUR_LATEST" + fi + + NEEDS_UPDATE_ARM=false + if [ "$DOCKUR_ARM_LATEST" != "$DOCKUR_ARM_CURRENT" ] && [ "$DOCKUR_ARM_LATEST" != "unknown" ]; then + NEEDS_UPDATE_ARM=true + echo "dockur/windows-arm: ${DOCKUR_ARM_CURRENT:-none} -> $DOCKUR_ARM_LATEST" + fi + + echo "needs_update_x86=$NEEDS_UPDATE_X86" >> "$GITHUB_OUTPUT" + echo "needs_update_arm=$NEEDS_UPDATE_ARM" >> "$GITHUB_OUTPUT" + echo "dockur_current=$DOCKUR_CURRENT" >> "$GITHUB_OUTPUT" + echo "dockur_arm_current=$DOCKUR_ARM_CURRENT" >> "$GITHUB_OUTPUT" + + - name: Check :latest digest drift on Docker Hub + id: digest_check + run: | + # Anonymous Docker Hub Registry API. We fetch the manifest + # headers for ``:latest`` and read ``Docker-Content-Digest`` + # to detect silent rebuilds (digest changed but the GitHub + # release tag did not). dockur sometimes ships security + # patches as same-tag rebuilds, which the release-tag check + # alone misses. + fetch_digest() { + local repo="$1" + local out_dir + out_dir="$(mktemp -d)" + curl -fsSL -o "$out_dir/token.json" \ + "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull" + local token + token=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['token'])" "$out_dir/token.json") + curl -fsSL -D "$out_dir/headers.txt" -o /dev/null \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json" \ + "https://registry-1.docker.io/v2/${repo}/manifests/latest" + grep -i '^docker-content-digest:' "$out_dir/headers.txt" \ + | awk '{print $2}' | tr -d '\r\n' + } + + DIGEST_X86_LATEST=$(fetch_digest "dockurr/windows" || echo "") + DIGEST_ARM_LATEST=$(fetch_digest "dockurr/windows-arm" || echo "") + + VERSION_FILE="config/oem/VERSIONS.txt" + DIGEST_X86_CURRENT="" + DIGEST_ARM_CURRENT="" + if [ -f "$VERSION_FILE" ]; then + DIGEST_X86_CURRENT=$(grep "^dockur-digest=" "$VERSION_FILE" | cut -d= -f2- || echo "") + DIGEST_ARM_CURRENT=$(grep "^dockur-arm-digest=" "$VERSION_FILE" | cut -d= -f2- || echo "") + fi + + DIGEST_DRIFT_X86=false + if [ -n "$DIGEST_X86_LATEST" ] && [ "$DIGEST_X86_LATEST" != "$DIGEST_X86_CURRENT" ]; then + DIGEST_DRIFT_X86=true + echo "dockur/windows:latest digest drift: ${DIGEST_X86_CURRENT:-none} -> $DIGEST_X86_LATEST" + fi + + DIGEST_DRIFT_ARM=false + if [ -n "$DIGEST_ARM_LATEST" ] && [ "$DIGEST_ARM_LATEST" != "$DIGEST_ARM_CURRENT" ]; then + DIGEST_DRIFT_ARM=true + echo "dockur/windows-arm:latest digest drift: ${DIGEST_ARM_CURRENT:-none} -> $DIGEST_ARM_LATEST" + fi + + echo "digest_drift_x86=$DIGEST_DRIFT_X86" >> "$GITHUB_OUTPUT" + echo "digest_drift_arm=$DIGEST_DRIFT_ARM" >> "$GITHUB_OUTPUT" + echo "digest_x86_latest=$DIGEST_X86_LATEST" >> "$GITHUB_OUTPUT" + echo "digest_arm_latest=$DIGEST_ARM_LATEST" >> "$GITHUB_OUTPUT" + echo "digest_x86_current=$DIGEST_X86_CURRENT" >> "$GITHUB_OUTPUT" + echo "digest_arm_current=$DIGEST_ARM_CURRENT" >> "$GITHUB_OUTPUT" + + - name: Open x86_64 tracking issue + if: steps.check.outputs.needs_update_x86 == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKUR_LATEST: ${{ steps.check.outputs.dockur_latest }} + DOCKUR_CURRENT: ${{ steps.check.outputs.dockur_current }} + DIGEST_X86_LATEST: ${{ steps.digest_check.outputs.digest_x86_latest }} + run: | + TITLE="chore: dockur/windows update available ($DOCKUR_LATEST)" + + EXISTING=$(gh issue list \ + --search "$TITLE in:title is:open" \ + --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already tracks this update, skipping" + exit 0 + fi + + gh issue create \ + --title "$TITLE" \ + --body "$(cat <}\` + 2. Refresh \`DOCKUR_IMAGE_PIN\` in \`src/winpodx/core/config.py\` if rolling forward: + \`\`\` + podman pull docker.io/dockurr/windows:latest + podman image inspect docker.io/dockurr/windows:latest -f '{{json .RepoDigests}}' + \`\`\` + 3. Recreate container to pick up the new Windows build + 4. Check if \`TargetReleaseVersionInfo\` in \`config/oem/install.bat\` needs updating + + Release notes: https://github.com/dockur/windows/releases/tag/$DOCKUR_LATEST + EOF + )" + + - name: Open aarch64 tracking issue + if: steps.check.outputs.needs_update_arm == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKUR_ARM_LATEST: ${{ steps.check.outputs.dockur_arm_latest }} + DOCKUR_ARM_CURRENT: ${{ steps.check.outputs.dockur_arm_current }} + DIGEST_ARM_LATEST: ${{ steps.digest_check.outputs.digest_arm_latest }} + run: | + TITLE="chore: dockur/windows-arm update available ($DOCKUR_ARM_LATEST)" + + EXISTING=$(gh issue list \ + --search "$TITLE in:title is:open" \ + --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already tracks this update, skipping" + exit 0 + fi + + gh issue create \ + --title "$TITLE" \ + --body "$(cat <}\` + 2. Refresh \`DOCKUR_IMAGE_ARM_PIN\` in \`src/winpodx/core/config.py\` if rolling forward: + \`\`\` + podman pull docker.io/dockurr/windows-arm:latest + podman image inspect docker.io/dockurr/windows-arm:latest -f '{{json .RepoDigests}}' + \`\`\` + 3. Recreate container on an aarch64 host to pick up the new Windows-on-ARM build + 4. Check if \`TargetReleaseVersionInfo\` in \`config/oem/install.bat\` needs updating + + Release notes: https://github.com/dockur/windows-arm/releases/tag/$DOCKUR_ARM_LATEST + EOF + )" + + - name: Open x86_64 silent-rebuild issue + # Only fire when digest drifted AND no release-tag bump (the + # tag-bump issue above already covers the drift in that case). + if: steps.digest_check.outputs.digest_drift_x86 == 'true' && steps.check.outputs.needs_update_x86 != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DOCKUR_LATEST: ${{ steps.check.outputs.dockur_latest }} + DIGEST_X86_LATEST: ${{ steps.digest_check.outputs.digest_x86_latest }} + DIGEST_X86_CURRENT: ${{ steps.digest_check.outputs.digest_x86_current }} + run: | + TITLE="chore: dockur/windows :latest digest drift (silent rebuild)" + + EXISTING=$(gh issue list \ + --search "$TITLE in:title is:open" \ + --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already tracks this drift, skipping" + exit 0 + fi + + gh issue create \ + --title "$TITLE" \ + --body "$(cat </dev/null || echo "") + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already tracks this drift, skipping" + exit 0 + fi + + gh issue create \ + --title "$TITLE" \ + --body "$(cat </dev/null 2>&1; then + echo "pwsh already installed: $(pwsh --version)" + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends wget apt-transport-https ca-certificates + # Official Microsoft repository for PowerShell on Ubuntu. + source /etc/os-release + wget -q "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/packages-microsoft-prod.deb + sudo dpkg -i /tmp/packages-microsoft-prod.deb + sudo apt-get update + sudo apt-get install -y powershell + pwsh --version + fi + - name: Run discover_apps.ps1 -DryRun and validate JSON shape + run: | + pwsh -NoProfile -File scripts/windows/discover_apps.ps1 -DryRun > /tmp/discover.json + python3 scripts/ci/validate_discover_dryrun.py /tmp/discover.json + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install pip-audit + # `[all]` is libvirt-free as of 0.6.0 (the libvirt backend + its + # libvirt-python extra were dropped — #286), so it no longer needs the + # libvirt-dev native headers and is safe to install on ubuntu-latest. + - run: pip install -e ".[all]" || pip install -e ".[dev]" + - run: pip-audit --desc --skip-editable + continue-on-error: true diff --git a/.github/workflows/debs-publish.yml b/.github/workflows/debs-publish.yml new file mode 100644 index 0000000..fdf63f9 --- /dev/null +++ b/.github/workflows/debs-publish.yml @@ -0,0 +1,154 @@ +name: Debian/Ubuntu Packages + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v0.1.0)" + required: true + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - image: debian:12 + suffix: debian12 + backports: true + - image: debian:13 + suffix: debian13 + - image: ubuntu:24.04 + suffix: ubuntu2404 + - image: ubuntu:25.04 + suffix: ubuntu2504 + - image: ubuntu:25.10 + suffix: ubuntu2510 + - image: ubuntu:26.04 + suffix: ubuntu2604 + container: ${{ matrix.image }} + steps: + - name: Bootstrap container (git + apt) + shell: bash + run: | + set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends ca-certificates git + + - uses: actions/checkout@v5 + + - name: Enable backports if needed + if: matrix.backports + shell: bash + run: | + set -e + export DEBIAN_FRONTEND=noninteractive + codename=$(. /etc/os-release && echo "$VERSION_CODENAME") + echo "deb http://deb.debian.org/debian ${codename}-backports main" \ + > /etc/apt/sources.list.d/backports.list + apt-get update + + - name: Install build deps + shell: bash + run: | + set -e + export DEBIAN_FRONTEND=noninteractive + pkgs="build-essential devscripts debhelper dh-python \ + python3-all python3-setuptools python3-wheel \ + python3-hatchling python3-pip python3-build" + apt-get install -y --no-install-recommends $pkgs + if [ "${{ matrix.backports }}" = "true" ]; then + codename=$(. /etc/os-release && echo "$VERSION_CODENAME") + apt-get install -y -t ${codename}-backports pybuild-plugin-pyproject + else + apt-get install -y --no-install-recommends pybuild-plugin-pyproject + fi + + - name: Build .deb + shell: bash + run: | + set -e + dpkg-buildpackage -us -uc -b + mkdir -p dist + for f in ../winpodx_*.deb; do + base=$(basename "$f" .deb) + cp "$f" "dist/${base}_${{ matrix.suffix }}.deb" + done + ls -la dist/ + + - uses: actions/upload-artifact@v4 + with: + name: deb-${{ matrix.suffix }} + path: dist/*.deb + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/download-artifact@v4 + with: + path: all-debs + pattern: deb-* + merge-multiple: true + + - name: List collected debs + run: ls -la all-debs/ + + # Single-writer rule: release.yml (REL- marker tag) is the SOLE creator + # of the GitHub Release and the only writer of its title/notes. This + # packaging workflow never creates or edits the release — it waits for + # release.yml to create it, then attaches its assets. (The old per- + # workflow `gh release create --notes "Release artifacts…"` stubs both + # raced to produce duplicate releases — kernalix7 hit this on v0.4.1 + # push 2026-05-05 — and churned the notes with placeholder text that + # release.yml then overwrote, so the release showed as "edited" many + # times. Waiting instead of creating eliminates both.) + # + # release.yml runs the full test suite before creating the release, so + # it typically appears ~5-6 min after the push; poll up to 12 min. If it + # never appears (e.g. only the v-tag was pushed, no REL- marker), skip + # the upload gracefully — the .debs remain available as build-job + # artifacts — rather than failing the run. + - name: Wait for release (release.yml owns creation) + id: wait + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ steps.tag.outputs.tag }}" + for _ in $(seq 1 48); do + if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $tag exists — attaching .debs." + echo "exists=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Release $tag not created yet (release.yml owns creation) — waiting…" + sleep 15 + done + echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping .deb upload; .debs remain as build-job artifacts." + echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Upload .deb to release + if: steps.wait.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.tag.outputs.tag }}" all-debs/*.deb --clobber \ + --repo "${{ github.repository }}" diff --git a/.github/workflows/obs-publish.yml b/.github/workflows/obs-publish.yml new file mode 100644 index 0000000..04aa67b --- /dev/null +++ b/.github/workflows/obs-publish.yml @@ -0,0 +1,264 @@ +name: OBS Publish + +# OBS builds RPMs asynchronously on shared openSUSE infrastructure +# (~30-60 min per rebuild). Trying to trigger the rebuild AND download the +# freshly-built RPMs in the same tag-push run was structurally broken: the +# download ran minutes after the trigger, long before OBS had published the +# new version, so every release push failed with "No RPMs were downloaded". +# +# This workflow is split to match OBS's async reality: +# * `trigger` (tag push / dispatch) — fire the OBS rebuild, then exit +# green. It never tries to download, so a release push can't go red here. +# * `attach` (hourly schedule / dispatch) — once OBS has published the +# RPMs for the latest release's version, download them and attach them to +# the existing GitHub Release. It is a no-op (green) when the RPMs aren't +# built yet (retries next hour) or are already attached (no churn). +# +# Single-writer rule: this workflow NEVER creates the GitHub Release or edits +# its title/notes — release.yml (REL- marker tag) is the sole creator and the +# only writer of the release body. `attach` only uploads assets to a release +# that already exists, so it can't cause the "edited N times" / placeholder- +# notes churn the old per-workflow `gh release create --notes …` stubs did. + +on: + push: + tags: + - "v*.*.*" + schedule: + # Hourly sweep: attach OBS RPMs to the latest release once OBS finishes + # building them. A no-op the rest of the time (already attached / not + # built yet), so it never edits a release it has nothing new for. + - cron: "17 * * * *" + workflow_dispatch: + inputs: + tag: + description: "Release tag to trigger / attach RPMs to (e.g. v0.6.0)" + required: true + force: + description: "Force OBS runservice even if Tumbleweed RPM exists (use when source bumped without version change)" + required: false + default: "false" + +env: + OBS_PROJECT: home:Kernalix7 + OBS_PACKAGE: winpodx + OBS_API: https://api.opensuse.org + # /public/ is OBS's anonymous read-only API mirror — no auth needed for + # public home: projects. The runservice token can't read /build/. + OBS_PUBLIC_API: https://api.opensuse.org/public + OBS_DOWNLOAD: https://download.opensuse.org/repositories/home:/Kernalix7 + +permissions: + contents: write + +jobs: + # Fire the OBS rebuild and exit. Runs on tag push and manual dispatch only. + # Never downloads — so a release push is always green here; the RPMs land + # asynchronously via the `attach` job once OBS finishes the server-side build. + trigger: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Resolve tag + id: tag + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${{ github.ref_name }}" + else + tag="${{ inputs.tag }}" + fi + # OBS feeds the version into both deb and rpm specs; rpm forbids + # '-' in Version: (Version/Release delimiter). Strip the RTM + # gate suffix so `0.3.0-RTM1` lands as `0.3.0`. + version="${tag#v}" + version="${version%%-RTM*}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install curl + jq + run: sudo apt-get update && sudo apt-get install -y curl jq + + - name: Probe whether OBS already has target version built + # Avoid triggering an unnecessary OBS rebuild when the target version + # is already published. A single rebuild costs ~30-60min on OBS shared + # infrastructure and is pure waste when the RPMs we'd download are + # byte-identical to what's already on the server. Probe a representative + # shipping repo (Tumbleweed) for a winpodx--*.rpm filename in + # the public download index; if found, OBS has the build cached. + id: probe + run: | + version="${{ steps.tag.outputs.version }}" + if [ "${{ inputs.force }}" = "true" ]; then + echo "force=true requested — bypassing Tumbleweed RPM probe and triggering runservice." + echo "skip_runservice=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + listing_url="${OBS_DOWNLOAD}/openSUSE_Tumbleweed/noarch/" + html=$(curl -sS -f -L "$listing_url" 2>/dev/null || echo "") + if echo "$html" | grep -qE "winpodx-${version}-[0-9]+\.[0-9]+\.noarch\.rpm"; then + echo "OBS already has openSUSE_Tumbleweed RPM for v${version} — skipping runservice." + echo "skip_runservice=true" >> "$GITHUB_OUTPUT" + else + echo "OBS doesn't yet have openSUSE_Tumbleweed RPM for v${version} — will trigger runservice." + echo "skip_runservice=false" >> "$GITHUB_OUTPUT" + fi + + - name: Trigger OBS service run (token) + if: steps.probe.outputs.skip_runservice == 'false' + env: + OBS_TOKEN: ${{ secrets.OBS_TOKEN }} + run: | + if [ -z "$OBS_TOKEN" ]; then + echo "OBS_TOKEN secret is not set" >&2 + exit 1 + fi + curl -sS -f -X POST \ + -H "Authorization: Token $OBS_TOKEN" \ + "$OBS_API/trigger/runservice?project=$OBS_PROJECT&package=$OBS_PACKAGE" + echo "OBS rebuild triggered for v${{ steps.tag.outputs.version }}. RPMs will be attached by the hourly 'attach' job once OBS finishes (~30-60 min)." + + # Download OBS-built RPMs once they're published and attach them to the + # existing GitHub Release. Runs hourly (sweeps the latest release) and on + # manual dispatch (targets inputs.tag). Never creates or edits the release. + attach: + if: github.event_name != 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install curl + jq + run: sudo apt-get update && sudo apt-get install -y curl jq + + - name: Resolve target tag + id: tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + tag="${{ inputs.tag }}" + else + # schedule: sweep the most recent published release. + tag=$(gh release list --repo "${{ github.repository }}" --limit 1 \ + --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") + fi + if [ -z "$tag" ]; then + echo "No target tag (no releases yet?) — nothing to attach." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + version="${tag#v}" + version="${version%%-RTM*}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Skip if release already has OBS RPMs attached + id: gate + if: steps.tag.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ steps.tag.outputs.tag }}" + # release.yml owns creation — only attach to a release that exists. + if ! gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $tag does not exist yet (release.yml owns creation) — nothing to attach." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Tumbleweed is always built and is the canary for "OBS RPMs already + # attached". If its asset is present, the sweep already ran for this + # release — exit green without re-uploading (no edit churn). + assets=$(gh release view "$tag" --repo "${{ github.repository }}" \ + --json assets --jq '.assets[].name' 2>/dev/null || echo "") + if echo "$assets" | grep -q "openSUSE_Tumbleweed\.rpm$"; then + echo "OBS RPMs already attached to $tag — no-op." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Discover repos and download RPMs + id: download + if: steps.gate.outputs.skip == 'false' + run: | + mkdir -p dist-rpms + version="${{ steps.tag.outputs.version }}" + + # Parse enabled repos from project _meta (public, no auth) + meta=$(curl -sS -f "$OBS_PUBLIC_API/source/$OBS_PROJECT/_meta") + repos=$(echo "$meta" | grep -oP '(?<=&2 + exit 1 + fi + + for repo in $repos; do + # openSUSE_Factory_{ARM,PowerPC,RISCV,zSystems} builds the same + # noarch content as openSUSE_Tumbleweed — publishing all five would + # just list four identical RPMs with misleading architecture-looking + # names. Skip the non-x86 Factory rebuilds; Tumbleweed users on + # aarch64/ppc64/riscv64/s390x install the Tumbleweed noarch RPM. + case "$repo" in + openSUSE_Factory_*) echo "Skipping $repo (noarch duplicate of Tumbleweed)"; continue ;; + esac + # Tumbleweed / Fedora / Slowroll all produce the same filename + # (winpodx--.noarch.rpm) because their release tags lack + # a distro suffix. Without renaming, only the last-iterated repo's + # file survives in dist-rpms/. Append the repo name to every RPM so + # each distro's build gets a stable, unique asset name on the + # Release — matches the RHEL convention (.el9.rpm / .el10.rpm). + # Normalize short Leap repo names ("15.6", "16.0") to the familiar + # openSUSE_Leap_* form so the final filename is self-describing + # instead of ending in a bare version number like ".15.6.rpm". + case "$repo" in + 1[0-9].[0-9]|1[0-9].[0-9][0-9]) repo_slug="openSUSE_Leap_$repo" ;; + *) repo_slug=$(echo "$repo" | tr '/:' '__') ;; + esac + for arch in x86_64 noarch; do + base="$OBS_DOWNLOAD/$repo/$arch" + # MirrorCache listing hrefs have a "./" prefix + # (href="./winpodx--.noarch.rpm"); match the filename + # directly instead of anchoring on `href="winpodx-`. + listing=$(curl -sSL "$base/" || true) + [ -z "$listing" ] && continue + # Anchor the filename match to the current tag's version so a + # stale older RPM still in the OBS publish index (Tumbleweed / + # Slowroll rolling-release repos are slow to garbage-collect) + # doesn't get downloaded and re-uploaded as a v${version} + # release asset. + rpms=$(echo "$listing" | grep -oE "winpodx-${version}-[^\"]+\.rpm" | sort -u) + for f in $rpms; do + case "$f" in + *debuginfo*|*debugsource*|*.src.rpm) continue ;; + esac + out="dist-rpms/${f%.rpm}.${repo_slug}.rpm" + echo "Fetching $repo/$arch/$f -> $(basename "$out")" + curl -sS -fL -o "$out" "$base/$f" || true + done + done + done + + ls -la dist-rpms/ + if ! ls dist-rpms/*.rpm >/dev/null 2>&1; then + echo "::notice::OBS hasn't published RPMs for v${version} yet — they build asynchronously (~30-60 min). The hourly sweep will retry; or re-run this workflow once the build finishes." + echo "have_rpms=false" >> "$GITHUB_OUTPUT" + else + echo "have_rpms=true" >> "$GITHUB_OUTPUT" + fi + + - name: Attach RPMs to GitHub release + if: steps.download.outputs.have_rpms == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.tag.outputs.tag }}" dist-rpms/*.rpm --clobber \ + --repo "${{ github.repository }}" + echo "Attached OBS RPMs to ${{ steps.tag.outputs.tag }}." + + - uses: actions/upload-artifact@v4 + if: steps.download.outputs.have_rpms == 'true' + with: + name: rpms-${{ steps.tag.outputs.version }} + path: dist-rpms/ diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..273856b --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,40 @@ +# Deploy the winpodx.org landing page (web/) to GitHub Pages. +# +# Requires repo Settings -> Pages -> Source = "GitHub Actions" (one-time, done +# in the UI) and the custom domain winpodx.org configured there. The CNAME +# file in web/ is published with the site so the custom domain sticks. +name: Deploy site + +on: + push: + branches: [main] + paths: + - "web/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Never run two deploys at once; let an in-flight one finish (don't cancel, +# so a partial publish can't clobber a good one). +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: web + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bfba54d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,195 @@ +name: Release +on: + push: + # Only REL-prefixed marker tags create a public release. Version + # tags themselves (v0.4.0, v0.4.0rc1) trigger the four packaging + # workflows (.deb / RPM / OBS / AUR) via their `v*.*.*` globs but + # NOT this Release workflow — that's deliberate. kernalix7 found + # that rapid-iteration tags between v0.1.9 and v0.2.2.1 shipped + # unverified releases that broke users' working installs + # (2026-04-29). Releasing now requires pushing a SECOND tag pointed + # at the same commit as the version tag, namespaced with REL- so + # the version scheme itself stays clean (PEP 440 / SemVer): + # + # git tag v0.4.0 # version, triggers packaging + # git tag REL-v0.4.0 # marker, triggers this workflow + # git push origin v0.4.0 REL-v0.4.0 + # + # The previous `*RTM*` glob was retired 2026-05-05 alongside the + # PEP 440 versioning move (current versions are `0.4.0` / + # `0.4.0rc1`, no RTM suffix anywhere). Manual workflow_dispatch + # still works for emergency one-offs. + tags: + - "REL-*" + workflow_dispatch: + +permissions: + contents: write + # id-token: write # reserved for future PyPI trusted publisher + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install ruff + - name: Run ruff check + run: ruff check src/ tests/ + - name: Run ruff format check + run: ruff format --check src/ tests/ + - name: Install project + run: pip install -e ".[dev]" + - name: Run tests + run: pytest tests/ -v + # Skip tag/pyproject version parity check on manual dispatch + - name: Verify tag matches pyproject version + if: github.event_name == 'push' + run: | + # Tag layout: REL-v. Strip the REL- marker + # prefix and the leading 'v' to get pyproject.toml's version + # field (e.g., REL-v0.4.0rc1 -> 0.4.0rc1, REL-v0.4.0 -> 0.4.0). + tag="${GITHUB_REF_NAME#REL-}" + tag="${tag#v}" + pyver=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "$tag" != "$pyver" ]; then + echo "Tag version ($tag) does not match pyproject.toml version ($pyver)" >&2 + exit 1 + fi + + build: + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: python -m pip install build twine + - name: Build distribution + run: python -m build + - name: Check distribution metadata + run: python -m twine check dist/* + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Extract version from REL tag + id: version + run: | + # REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1. Used by all + # subsequent steps so the public Release page reads + # "v0.4.0" / "0.4.0" instead of the internal "REL-v0.4.0" + # marker tag name. + tag="${GITHUB_REF_NAME#REL-}" + tag="${tag#v}" + echo "value=$tag" >> "$GITHUB_OUTPUT" + - name: Extract changelog section + id: changelog + run: | + # REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1. + tag="${GITHUB_REF_NAME#REL-}" + tag="${tag#v}" + # Determine the previous published version so we can include + # any intermediate unreleased sections in the notes (sections + # the user wrote in CHANGELOG but never tagged & released). + # On 2026-05-05, v0.3.1 was such an unreleased section: it + # collected Mic92's PRs #62-#65 but no v0.3.1 tag was ever + # pushed; v0.4.0rc1's release page would have lost those + # contributor credits if it only matched ## [0.4.0]. + # Strategy: find the most recent CHANGELOG section that + # corresponds to a published GitHub Release (i.e., has its own + # tag). Capture from the current section back to (but not + # including) that already-published one. + prev="" + while IFS= read -r section; do + sver="${section#\#\# \[}" + sver="${sver%%\] *}" + # Skip the in-flight version itself and the unreleased header. + [ "$sver" = "$tag" ] && continue + [ "$sver" = "Unreleased" ] && continue + # First section we hit that has a corresponding tag is the + # previous published release. CHANGELOG is in newest-first + # order so this is correct on the first match. + if git rev-parse --verify "v${sver}^{commit}" >/dev/null 2>&1; then + prev="$sver" + break + fi + done < <(grep -E '^## \[' CHANGELOG.md) + notes=$(awk -v ver="$tag" -v prev="$prev" ' + $0 ~ "^## \\[" ver "\\]" { flag=1 } + prev != "" && $0 ~ "^## \\[" prev "\\]" { exit } + prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit } + flag { print } + ' CHANGELOG.md) + # PEP 440 prereleases (0.4.0rc1) usually share notes with the + # final (0.4.0) section in CHANGELOG. Fall back to the base + # version section if the prerelease-specific one is missing. + if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then + base=$(echo "$tag" | sed -E 's/(rc|alpha|beta)[0-9]*$//') + if [ "$base" != "$tag" ]; then + notes=$(awk -v ver="$base" -v prev="$prev" ' + $0 ~ "^## \\[" ver "\\]" { flag=1 } + prev != "" && $0 ~ "^## \\[" prev "\\]" { exit } + prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit } + flag { print } + ' CHANGELOG.md) + fi + fi + if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then + notes="See CHANGELOG.md for details." + fi + { + echo "notes<> "$GITHUB_OUTPUT" + - name: Detect prerelease + id: prerelease + run: | + # Match both PEP 440 ("0.4.0rc1", no separator) and SemVer- + # hyphen ("0.4.0-rc1") prerelease forms after the REL- prefix + # is stripped. + tag="${GITHUB_REF_NAME#REL-}" + tag="${tag#v}" + if echo "$tag" | grep -qE -- '(rc|beta|alpha)[0-9]*'; then + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "value=false" >> "$GITHUB_OUTPUT" + fi + - name: Create release + uses: softprops/action-gh-release@v2 + with: + # tag_name + name override the default github.ref_name + # (REL-v0.4.0 marker) with the clean version tag (v0.4.0) so + # the public release surface is namespace-free. The marker + # tag still exists in git as the trigger record. + tag_name: v${{ steps.version.outputs.value }} + name: v${{ steps.version.outputs.value }} + prerelease: ${{ steps.prerelease.outputs.value }} + # The hand-written CHANGELOG section is the entire release body — + # including its "### Contributors" subsection, which credits the + # issue reporters and external PR authors for the release (the repo's + # house convention; see CONTRIBUTING.md). generate_release_notes is + # deliberately NOT enabled: on this maintainer-authored repo it dumps + # a ~120-line "What's Changed" list that credits the owner on nearly + # every PR and buries the real contributor credits behind it. This + # step is the SOLE writer of the release body: the packaging + # workflows (.deb / RPM / OBS / AppImage) only attach assets, never + # touch the notes, so the release isn't edited/rewritten repeatedly. + body: ${{ steps.changelog.outputs.notes }} + files: | + dist/*.tar.gz + dist/*.whl diff --git a/.github/workflows/rhel-publish.yml b/.github/workflows/rhel-publish.yml new file mode 100644 index 0000000..1518add --- /dev/null +++ b/.github/workflows/rhel-publish.yml @@ -0,0 +1,206 @@ +name: RHEL/AlmaLinux Packages + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v0.1.0)" + required: true + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # AlmaLinux 9 ships python3 == 3.9. The spec pulls in python3-tomli + # on rhel<=9 so the stdlib-tomllib-absent case is covered. EPEL + # provides pyproject-rpm-macros + python3-hatchling + python3-tomli. + - image: almalinux:9 + suffix: el9 + epel: true + # AlmaLinux 10 ships python3 >= 3.12 so tomllib is stdlib; the spec + # conditional skips the tomli Requires on rhel >= 10. EPEL 10 is + # still enabled because python3-hatchling lives there on el10. + - image: almalinux:10 + suffix: el10 + epel: true + container: ${{ matrix.image }} + steps: + - name: Bootstrap container (git + rpm tools) + shell: bash + run: | + set -e + dnf -y install dnf-plugins-core git rpm-build rpmdevtools tar + + - name: Enable EPEL + CRB + if: matrix.epel + shell: bash + run: | + set -e + # CRB (CodeReady Builder) holds the -devel and python- build deps + # that are not in BaseOS/AppStream (needed for python3-pluggy + # providers on el9 and some python3-* build deps on el10). + dnf -y install epel-release + dnf config-manager --set-enabled crb || \ + dnf config-manager --set-enabled powertools || true + + - uses: actions/checkout@v5 + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + tag="${{ github.ref_name }}" + else + tag="${{ inputs.tag }}" + fi + # Strip the RTM gate suffix when computing the package version — + # RPM's Version: field forbids '-' (it's the Version/Release + # delimiter), so 'v0.3.0-RTM1' would land as the illegal + # 'Version: 0.3.0-RTM1'. The tag itself is preserved for the + # release upload + asset naming; only the on-disk Version is + # stripped to a plain '0.3.0'. + version="${tag#v}" + version="${version%%-RTM*}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install RPM build deps + shell: bash + run: | + set -e + dnf -y install \ + python3 python3-pip python3-wheel python3-setuptools \ + python3-hatchling python3-installer \ + pyproject-rpm-macros python-rpm-macros \ + python3-pluggy + # el9 default python3 is 3.9 → tomllib is NOT stdlib, so the runtime + # Requires: python3-tomli must be resolvable at install time too. + # Install here just to keep the build environment symmetric. + if [ "${{ matrix.suffix }}" = "el9" ]; then + dnf -y install python3-tomli || true + fi + + - name: Prepare rpmbuild tree + shell: bash + run: | + set -e + version="${{ steps.tag.outputs.version }}" + mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + # git archive produces a clean tarball matching what OBS's tar_scm + # builds from, including the @PARENT_TAG@-shaped prefix the spec's + # %autosetup expects. + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git archive --format=tar.gz \ + --prefix="winpodx-${version}/" \ + -o "$HOME/rpmbuild/SOURCES/winpodx-${version}.tar.gz" \ + HEAD + # Stamp the real version into the spec (OBS does this via its + # set_version service; here we do it locally before rpmbuild). + sed "s|^Version:.*|Version: ${version}|" \ + packaging/rpm/winpodx.spec > "$HOME/rpmbuild/SPECS/winpodx.spec" + + - name: Build RPM + shell: bash + run: | + set -e + rpmbuild -bb "$HOME/rpmbuild/SPECS/winpodx.spec" + + - name: Collect RPMs + shell: bash + run: | + set -e + mkdir -p dist + version="${{ steps.tag.outputs.version }}" + # BuildArch is noarch in the spec, so everything lands in noarch/. + for f in ~/rpmbuild/RPMS/noarch/winpodx-*.rpm; do + [ -e "$f" ] || continue + base=$(basename "$f" .rpm) + # Drop any existing distro tag (rpmbuild puts .el9 / .el10 here + # via %{dist}) and replace with our matrix suffix for clarity. + # The rpm itself is noarch so it would install on either distro; + # the suffix is just a human hint on the release asset list. + case "$base" in + *debuginfo*|*debugsource*) continue ;; + esac + cp "$f" "dist/${base}.${{ matrix.suffix }}.rpm" + done + ls -la dist/ + + - uses: actions/upload-artifact@v4 + with: + name: rpm-${{ matrix.suffix }} + path: dist/*.rpm + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - name: Resolve tag + id: tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/download-artifact@v4 + with: + path: all-rpms + pattern: rpm-* + merge-multiple: true + + - name: List collected RPMs + run: ls -la all-rpms/ + + # Single-writer rule: release.yml (REL- marker tag) is the SOLE creator + # of the GitHub Release and the only writer of its title/notes. This + # packaging workflow never creates or edits the release — it waits for + # release.yml to create it, then attaches its assets. (The old per- + # workflow `gh release create --notes "Release artifacts…"` stubs both + # raced to produce duplicate releases — kernalix7 hit this on v0.4.1 + # push 2026-05-05 — and churned the notes with placeholder text that + # release.yml then overwrote, so the release showed as "edited" many + # times. Waiting instead of creating eliminates both.) + # + # release.yml runs the full test suite before creating the release, so + # it typically appears ~5-6 min after the push; poll up to 12 min. If it + # never appears (e.g. only the v-tag was pushed, no REL- marker), skip + # the upload gracefully — the RPMs remain available as build-job + # artifacts — rather than failing the run. + - name: Wait for release (release.yml owns creation) + id: wait + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ steps.tag.outputs.tag }}" + for _ in $(seq 1 48); do + if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "Release $tag exists — attaching RPMs." + echo "exists=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Release $tag not created yet (release.yml owns creation) — waiting…" + sleep 15 + done + echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping RPM upload; RPMs remain as build-job artifacts." + echo "exists=false" >> "$GITHUB_OUTPUT" + + - name: Upload RPMs to release + if: steps.wait.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.tag.outputs.tag }}" all-rpms/*.rpm --clobber \ + --repo "${{ github.repository }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a7de07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,102 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +# Stray PEP 517 build-backend wheels that a local `python -m build` / pip can +# drop in the repo root (hatchling, build, pluggy, …). Never part of winpodx. +/*.whl +.venv/ +venv/ + +# AppImage build artifacts (packaging/appimage/ local builds) +*.AppImage +squashfs-root/ +.pytest_cache/ +.ruff_cache/ +*.so +.mypy_cache/ +.hypothesis/ +env/ + +# AI config (actual files in .priv-storage/, root has symlinks) +.priv-storage/ +CLAUDE.md +.cursorrules +.claude +.vscode +WORK_STATUS.md +# Additional per-tool files routed via .priv-storage chain or +# explicitly local. CLAUDE.md Section 8 ("AI Tooling Footprint Ban") +# requires these never reach git history. +.mcp.json +AGENTS.md +CLAUDE.local.md +demo.png + +# Backup toolkit (generic, not project-specific) +tmp-igbkp/ + +# Per-install agent bearer token staged into the OEM bind mount. +# Never commit — carries a per-install secret. +config/oem/agent_token.txt + +# Synthetic SMBIOS disguise blob, generated into the OEM bind mount at +# compose time (#246). Runtime artefact — never commit. +config/oem/winpodx-smbios.bin + +# Rust reverse-open shim build tree (~21 MB of regenerable Cargo artifacts). +# Already ignored by the nested config/oem/reverse-open/shim/.gitignore, but +# listing it at the ROOT too keeps it out of the sdist: hatchling's sdist VCS +# walk honors the root .gitignore, so a maintainer-local build (where the shim +# was compiled) produces a clean tarball. Only the committed shim/bin/ prebuilt +# .exe should ship. (The CI release builds from a fresh checkout, so target/ +# never exists there — published artifacts are clean regardless.) +config/oem/reverse-open/shim/target/ + +# Coverage / test artefacts. +.coverage +htmlcov/ + +# Editor / OS scratch files. +*.tmp +*.bak +*.old +*.orig +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +.idea/ +*.iml + +# Secret-shaped files — defensive denylist so a stray .env / key never +# slips into a commit. Currently no such files in tree. +.env +.env.* +*.env +*.pem +*.key +*.crt +*.p12 +*.pfx +id_rsa* +id_ed25519* + +# Editor / assistant scratch & backup paths +CLAUDE.md.bak +AGENTS.md.bak +.cursorrules.bak +WORK_STATUS.md.bak +.gitignore.bak +.codex/ +.aider* +.continue/ +.cline/ +.roo/ +uninstall-backup-*/ +.priv-storage/.allow-setup-reread diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8581243 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1123 @@ +# Changelog + +**English** | [한국어](docs/CHANGELOG.ko.md) + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +- **A non-purge `uninstall.sh` no longer deletes the Windows VM disk** (#716, thanks @munir-abbasi — data-loss report). The default VM storage lives under the app-data dir (`~/.local/share/winpodx/storage/data.img`), and the "remove app definitions" step did a plain `rm -rf ~/.local/share/winpodx` — so a non-purge uninstall (documented as *keeping* the VM data), especially via the package-manager `postrm` re-entry where it runs unattended, recursively wiped the Windows VM out from under `storage/`. Uninstall now canonicalizes both paths and, when the storage dir is `DATA_DIR` itself or nested beneath it and `--purge` was **not** given, preserves the storage subtree and removes only the other app-data entries. A custom `WINPODX_STORAGE_PATH` outside the app-data dir is untouched as before, and `--purge` still wipes everything. Regression-tested via the uninstall smoke harness (nested-storage, external-storage, and purge cases). + +### Added + +- **Launching a Linux app directly from its "Linux Apps" shortcut on Windows now runs it** (#616, thanks @notnotno). The reverse-open shims placed under Windows' Start Menu / Desktop "Linux Apps" folder are primarily "Open with…" chooser entries, but clicking one directly (with no file) used to do nothing — the guest shim silently exited because it required a file argument. It now emits a launch-only request (`origin: "launch"`, empty path) and the host runs the Linux app with no file (the `%f`/`%u` placeholder is stripped rather than filled), so the shortcuts double as plain app launchers. Requires the updated guest shim (re-provisions on the next `winpodx guest sync` / `apply-fixes`). + +### Fixed + +- **The GUI's `libxcb-cursor0` dependency is now auto-installed on a minimal desktop** (#712, thanks @numericOverflow). Qt 6.5+ (PySide6) refuses to start its `xcb` platform plugin without `libxcb-cursor.so.0` — "could not load the Qt platform plugin 'xcb'" — and it isn't pulled in transitively on a fresh minimal install (e.g. Linux Mint 22). `install.sh` now installs it (distro-mapped: `libxcb-cursor0` on Debian/Ubuntu/openSUSE, `xcb-util-cursor` on Fedora/Arch) when the GUI is enabled and the runtime lib isn't already present. Presence is detected by probing the real `.so` on disk across the standard lib dirs rather than `ldconfig -p` — on openSUSE the library lives in `/usr/lib64` but is absent from the ld.so cache, so an `ldconfig` probe re-prompted every run even with the package installed. + +## [0.9.0] - 2026-07-11 + +### Added + +- **Windows apps can be registered as URL-scheme handlers on Linux** (#421, #694 — host-side groundwork). When an app carries URL schemes (`mailto`, `https`, `slack`, `vnc`, …), its `.desktop` now gets `x-scheme-handler/` entries and the `Exec` accepts a URL, so clicking such a link on the host opens it in the Windows app — the URL is passed straight through to the app (not mapped to a `$HOME` file path). A shared policy module (denylist + strict scheme regex + a `/app`-cmd sanitizer) blocks dangerous schemes (`file:`, `javascript:`, `data:`, …) and neutralizes command-line injection at both registration and launch. Discovery now auto-harvests each app's schemes from the guest (per-scheme `UrlAssociations` default, per-app `Capabilities\URLAssociations`, and the UWP manifest's `windows.protocol`), so an app that handles `mailto`/`https`/etc. is registered automatically — no manual `app.toml` edit needed. + +### Changed + +- **The optional Windows debloat pass opts out of more ads / telemetry** (#669, #684, thanks @GameSoul7Eugene). Broadened the advertising and suggestion opt-outs (advertising ID, lock-screen "tips & tricks", Start / Settings / search suggestions, silent consumer-app auto-install), added a disable for the SQM/CEIP telemetry scheduled task, and applied RDP-friendly visual-effect tweaks — all kept to a conservative, reversible scope. + +### Fixed + +- **A discovered Windows app can no longer silently seize the host's default `http` / `https` handler** (security hardening). Registering a Windows app as a URL-scheme handler ran `xdg-mime default` for every declared scheme — so an app discovered from the (semi-trusted) guest could, via `app install --mime` or `doctor --fix`, become the system default for `http`/`https` and receive every web link the user clicks (session tokens included). Those two universal web schemes are now excluded from the automatic default grab: a Windows browser still works as a URL target (`app run https://…`) and stays available in "Open with", but making it your web default is now an explicit, opt-in choice. `mailto` and vendor schemes are unaffected. +- **The session window-reaper no longer mis-kills a just-relaunched app, nor mass-kills every session on a transient scan failure** (#680 follow-up). Closing a document and immediately reopening it could SIGTERM the fresh session (the reaper tracked apps by name, and a relaunch overwrites the process marker) — the reaper now binds each reap to the exact PID it armed against. And a non-zero `wmctrl` exit (the window manager momentarily busy) was read as "no windows open", so two transient failures in a row reaped every running app; a failed scan is now treated as a failure, not as zero windows. +- **The install one-liner now auto-installs `git` if it's missing instead of aborting** (#705, thanks @numericOverflow). On a fresh system without `git` (e.g. Linux Mint 22) the `curl … | bash` installer failed at the clone step with "git is required for remote install" — even though it auto-installs every other dependency. It now installs `git` through the same package-manager path as the rest, and only errors out (with a manual-install hint) if the package manager can't provide it. +- **kio-fuse is no longer reported "not installed" when it actually is** (#697, thanks @twkirk161). The Info/health check only looked for the `kio-fuse` binary in a fixed set of paths, so on distros that put it elsewhere — Fedora Kinoite / KF6-versioned libexec, or the Debian multiarch `/usr/lib//libexec/kio-fuse` (three levels deep, missed by the old two-level glob) — it read as missing even though reverse-open guest-disk mounting (#616) works fine. Detection now also matches the multiarch path and, as the authoritative signal, the `org.kde.KIOFuse` D-Bus activation service that the mount actually calls. +- **The reverse-open listener now autostarts with the tray, and tray Quit stops it deliberately** (#691, thanks @notnotno). The listener was only (re)started by `pod start` and the app-launch path, so starting just `winpodx gui` (or logging in with the tray autostart) left Windows→Linux "Open with" silently dead even with the feature enabled. And Quit only killed it *by accident*: the listener is a double-forked daemon that inherits its parent's command line, so the `pkill …gui` in Quit matched it only when it happened to be started from the GUI settings panel. The tray now runs the idempotent listener self-heal at startup (off the UI thread) and Quit calls `stop_listener()` explicitly. +- **Fast suspend/resume recovery actually subscribes to systemd-logind now** (#690, thanks @notnotno). The tray's `PrepareForSleep` D-Bus subscription passed a bare Python function to `QDBusConnection.connect`, which PySide6 rejects at runtime (`called with wrong argument types`) — so the fast post-resume pod refresh (#225) never actually armed and every install silently fell back to the 30 s status poll, logging a warning at each `winpodx gui`/tray start. The subscription now uses a QObject receiver with a `SLOT("onPrepareForSleep(bool)")` signature (held on the tray icon so it can't be garbage-collected out from under the connection), verified live against `org.freedesktop.login1`. +- **A Windows app that stays resident after you close its window no longer shows "RUNNING" forever** (#680, thanks @mdshahalam3). A FreeRDP RemoteApp client only exits when the guest disconnects, but Office keeps its process (and the RemoteApp session) resident after the document window closes — so the session never terminated, the app listed RUNNING indefinitely, and it left a half-stuck `\\tsclient\home` redirect that broke the next open. The system-tray helper now watches every session's RAIL windows and, once an app's windows have all closed (with a short debounce), terminates the stuck session so it drops off the running list. It runs in the long-lived tray (not the short-lived `winpodx app run` process, which exits immediately) so it fires for menu / CLI launches too. Best-effort (X11/XWayland with `wmctrl`); it only reaps a session whose window was actually seen, so a launching or window-less app is never reaped early. +- **FreeRDP's cosmetic `[get_next_comma]: Invalid quoted argument` warning at every RemoteApp launch is silenced** (#680, thanks @mdshahalam3). It came from the quoted `cmd:""` form winpodx keeps so a file path with spaces isn't split; the FreeRDP subprocess now runs with `WLOG_FILTER=com.winpr.commandline:FATAL`, which mutes that one WLog tag without changing the delivered command line (the `/log-filters` flag is parsed too late to suppress its own parser's warning). +- **Office 2016 (MSI) apps no longer collapse into one broken "Microsoft Office 2016 component" entry** (#680 regression, thanks @mdshahalam3). 0.8.0's Start-Menu-only discovery read each shortcut's raw `.TargetPath`, but MSI installs use *advertised shortcuts* (Windows Installer "Darwin descriptors") whose target resolves to a per-product icon stub under `C:\WINDOWS\Installer\{ProductCode}\` (e.g. `xlicons.exe`). Those stubs all share the file description "Microsoft Office 2016 component", so Word/Excel/PowerPoint/Outlook all looked identical and deduplicated into a single non-runnable entry — they vanished from `winpodx app list`. Advertised shortcuts are now resolved to their real installed executable via `MsiGetShortcutTarget` + `MsiGetComponentPath` before recording the target; normal (Click-to-Run / direct) shortcuts are unaffected. +- **"Open with" a Windows app while it's already running now reliably opens the file in a fresh window** (#675 thanks @ajeshchrist, #680 thanks @mdshahalam3). It used to silently do nothing (the already-running path swallowed every error), or — when the file was delivered to the running app via the guest agent — pop "Access denied", or hang ~30 s before a crash-looking relaunch. Root cause: the guest runs multi-session RDP, so any attempt to hand a file to the *already-visible* app lands in a different session (the agent even runs in the autologon console session, which has no per-session `\\tsclient\home` redirect at all). So winpodx no longer attempts a "warm" delivery — opening a file while the app is running goes straight to a fresh RemoteApp window carrying the file (whose credentialed connect also unlocks / re-logs-in the session). A file outside the shared `$HOME` / media locations raises a visible error instead of vanishing. Also hardened the silent-failure surface: the `.desktop` `Exec` uses `%f` (multi-select opens one window per file instead of a no-op), and `notify-send` + `file://` URIs resolve by absolute path so error toasts and file arguments survive a stripped-PATH launch. +- **The guest lock-screen check is now session-scoped, and the cold launch waits longer for a slow guest** (#680/#675, thanks @mdshahalam3 & @ajeshchrist). winpodx waits for the guest desktop to be interactive before creating a RemoteApp window, but the check used a machine-wide `Get-Process LogonUI` — so a lock screen in *another* session (the console, or a stale disconnected RAIL session) wrongly reported the app's own session as locked and made launches wait the full timeout. It now matches LogonUI/explorer by session ID. The cold launch also waits up to 45 s (was 20 s) for a slow guest to finish autologon before drawing the window, so it no longer paints a stale logon frame over the app. + +### Contributors + +Thanks to everyone who reported issues or contributed fixes in this release: @notnotno (#694, #691, #690), @twkirk161 (#697), @mdshahalam3 (#680), @ajeshchrist (#675), @numericOverflow (#705), and @GameSoul7Eugene (#669, #684). + +## [0.8.0] - 2026-06-30 + +### Added + +- **Windows apps are now grouped on the Linux menu by their Start Menu folder** (#581, thanks @Milliw). The Start Menu subfolder each app lives in (e.g. `Microsoft Office\Tools`) is mirrored into nested sub-groups under the "winpodx" menu folder, just like Windows shows them. Renders on KDE Plasma, XFCE, Cinnamon, MATE and LXQt (the freedesktop `.menu` mechanism); on GNOME the apps still appear but ungrouped (its overview is a flat grid). Top-level apps and apps with no folder stay directly under "winpodx". +- **Your `[pod] keyboard` setting now drives the FreeRDP session keyboard layout** (#660, thanks @lsvab). The locale you pick for the Windows install (e.g. `keyboard = "hu-HU"`) is now mapped to the matching Windows layout and passed to FreeRDP as `/kbd:layout:0x…`, so a non-US keyboard works in the RemoteApp window without hand-writing `rdp.extra_flags`. The default `en-US` is left untouched (FreeRDP keeps auto-detecting your host XKB layout, so users who never changed the setting aren't forced onto US), an explicit `/kbd` in `rdp.extra_flags` always wins, and an unmapped locale falls back to auto-detect. (Passing `/kbd` manually via `rdp.extra_flags` was already unblocked in 0.7.4.) + +### Changed + +- **App discovery now surfaces only the apps your Windows Start Menu actually shows, by default** (#581, thanks @Milliw). Previously every registered executable was scanned (registry App Paths, Chocolatey/Scoop shims, every UWP package) and dumped into the Linux menu, flooding it with uninstallers, helpers, and background processes. Discovery now defaults to Start-Menu-only: Start Menu shortcuts, Start-Menu-visible UWP apps (intersected with `Get-StartApps`), and the OS essentials (File Explorer / Calculator / Settings). Turn the old behaviour back on with **`winpodx config set desktop.full_app_scan true`** or the Settings → "Discover all installed apps (not just Start Menu apps)" checkbox — useful for portable apps that have no Start Menu entry. Takes effect on the next `winpodx app refresh`. +- **Bundled rdprrap bumped 0.1.3 → 0.3.0.** rdprrap (the multi-session RDP wrapper that lets each RemoteApp window get its own session) now derives its `termsrv.dll` patch sites **dynamically** — it disassembles each target function at runtime and encodes the patch bytes, instead of relying on hardcoded struct offsets / register choices / byte templates. This keeps multi-session working across Windows build-to-build `termsrv.dll` struct-layout shifts. OEM version 27 → 28, so existing installs pick it up on the next `winpodx guest sync` / `apply-fixes`. + +### Fixed + +- **The GUI no longer crashes (SIGSEGV) from a layout-recursion when app names are long/CJK or the app list is empty** (#553, thanks @hermitguo). Word-wrap `QLabel`s inside the resizable app-list scroll area (the empty-state panel, the launcher tiles) had a wrap width that tracked the viewport, so their height fed back into their width and re-entered `QBoxLayout::setGeometry` → `heightForWidth` without bound on Qt 6.11 — a hard segfault on startup (empty list) or right after adding a CJK-named app. Those labels now have a constant (fixed) wrap width, breaking the feedback loop. +- **The Debloat / maintenance task window now closes itself when the operation finishes** (#550, thanks @ismikes). The progress `BusyDialog` was closed with `QTimer.singleShot(0, dlg.finish)` from inside the worker — but a bare `threading.Thread` has no Qt event loop, so that timer never fired and the window hung open until dismissed by hand (the v0.7.2 change only fixed *when the worker starts*, not the close). `BusyDialog.finish()` is now signal-based, so a cross-thread close queues `accept()` onto the GUI thread and the window closes on completion (Debloat, Grow Disk, Sync Guest, Apply Fixes). +- **"Refresh Apps" in the GUI no longer crashes (SIGSEGV) on completion.** The discovery worker (a parentless, Python-owned `QObject`) had two delete paths — its own `deleteLater` on the worker thread *and* the Python ref-drop on the main thread. Qt6 emits `QThread.finished` before flushing the worker's `deleteLater`, so the cleanup slot dropped the last Python ref while that delete was still in flight, double-freeing the worker across threads (`QObject::~QObject` segfault). Now there is exactly one delete path (the ref-drop, after `thread.wait()` confirms the worker thread is dead), the start guard also bails on a live thread ref (so a fast re-click can't drop a still-finishing worker), and the window joins in-flight worker threads on close. +- **The GUI no longer crashes (SIGABRT) when a background worker reads the display scale.** `QGuiApplication.screens()` is GUI-thread-only, but it was reachable from worker threads (the Info panel's `gather_info`, etc.), which spammed `QObject::setParent: ... different thread` warnings and could abort the whole app with `__cxa_pure_virtual` during the worker's thread teardown — surfacing as the GUI dying on a refresh. The Qt scale probe now short-circuits to `None` off the main thread and falls back to the subprocess/env detection. +- **`winpodx app refresh` now removes apps that are no longer discovered, not just adds new ones** (#581). A refresh used to only ever *add* entries; discovered apps that vanished — an app uninstalled inside Windows, or everything dropped by the new Start-Menu-only default — lingered in the Linux menu until manually deleted. Refresh now prunes the stale discovered profiles (and their launchers) so the menu actually migrates to the current set. Manually-added apps (`~/.local/share/winpodx/apps/`) are never touched, and a failed/empty scan never wipes the menu. +- **`--win-iso` now actually installs from your ISO instead of downloading Windows anyway** (#647, thanks @ismikes). The local ISO was staged into `/custom.iso` *after* `winpodx setup` had already run `compose up` — so the container had booted and dockur had started its Microsoft download before the file existed (dockur looks for `custom.iso` the moment it boots). The staging now happens **inside `winpodx setup`**, after the storage path is resolved but **before the container is (re)created**, so dockur finds the ISO and installs from it. Also exposed as `winpodx setup --win-iso `. +- **The reverse-open listener now self-heals on app launch instead of staying dead until the next `pod start`.** A `winpodx pod stop` / tray Quit stops the listener (`stop_listener()`); while the pod kept running, nothing re-spawned the watcher, so "Open with → a Linux app" from Windows silently did nothing. `ensure_ready` (every `winpodx app run` / GUI launch) now idempotently ensures the listener is up when `reverse_open` is enabled. Surfaced during the v0.7.4 smoke. + +### Contributors + +Thanks to everyone who reported issues fixed in this release: @Milliw (#581), @lsvab (#660), @ismikes (#647, #550), @hermitguo (#553), and @liveifsh (#659). + +## [0.7.4] - 2026-06-23 + +### Added + +- **`install.sh --storage-dir ` (and `winpodx setup --storage-path`) chooses where the Windows VM lives** (#646, thanks @realahmed7777). Put the VM disk + ISO on a roomier partition instead of `~/.local/share/winpodx/storage` — the directory is created with the same prep as the default (`chattr +C` on btrfs so the raw disk doesn't fragment, SSD emulation when the target is non-rotational). Fresh installs only; relocating an existing install stays `winpodx setup --migrate-storage --migrate-storage-target`. +- **Fresh installs on a 24 GB+ host now default the VM to 8 GB RAM instead of 6** (#630, thanks @ismikes). Windows 11 runs noticeably smoother with 8 GB, and a host with ≥24 GB can spare it — the auto-tier that pre-fills `pod.ram_gb` during `winpodx setup` bumps the mid tier from 6 to 8 GB on those hosts (CPU sizing unchanged; ≥32 GB / ≥12-thread hosts still get the 12 GB high tier). Existing installs keep your configured value; change it any time in Settings or with `winpodx config set pod.ram_gb 8`. +- **`install.sh --win-iso ` installs from a local Windows ISO instead of downloading** (#647, thanks @ismikes). Point the installer at a Windows ISO you already have and it's staged into the storage dir as dockur's `custom.iso`, so the install skips the ~5-8 GB Microsoft download — handy for repeated purge/reinstall cycles. Reflink-copied where the filesystem supports it (btrfs/xfs), so it costs no extra disk. (You could already drop a `custom.iso` into the storage dir by hand; this wires it to a flag and documents it in `--help`.) + +### Fixed + +- **The Windows password is no longer written to the log in cleartext.** The "Launching RDP" / "Relaunching RDP" log lines printed the full `xfreerdp` argv, which includes `/p:`. Password tokens (`/p:`, `/gp:`) are now masked to `/p:***` before logging. (Local-only logs; pre-existing — hardened during the 0.7.4 security review.) +- **The Logs tab's Status / Pod logs / Inspect buttons now use the selected container backend instead of always running `podman`** (#658). On a Docker-backend install, the Terminal-tab diagnostics buttons shelled out `podman ps` / `podman logs` / `podman inspect` regardless of `pod.backend`, so they failed (or probed the wrong runtime) whenever podman wasn't the active backend. They now honour `cfg.pod.backend` — `docker` when Docker is selected (`manual` falls back to `podman`, where the container commands are inert). The pod-log tail's on-screen `$ …` echo was also corrected to print the real backend instead of a hardcoded `podman`. +- **Opening a second document in an already-running Windows app now works instead of silently doing nothing** (#657). With an app already open (e.g. Word), launching a second file for it — a second `gio launch`, or right-click → *Open with* → the app — dropped the file: the live session was reused but the path was discarded. The file is now delivered into the running session — mapped to its `\\tsclient` UNC path and opened via `Start-Process`, sent over the guest agent with a FreeRDP RemoteApp fallback when the agent is unreachable. Best-effort: failures are logged and never block the launch. +- **Right-click → *Open with* a Windows app now works on desktops that launch with a stripped PATH (e.g. Deepin)** (#657). Generated `.desktop` files used a bare `Exec=winpodx …`, which fails with `exec: winpodx: not found` when the desktop environment runs the launcher as a systemd transient unit that drops `~/.local/bin` from PATH (Deepin's `dde-application-manager`). Launching via `gio launch` from a terminal worked because it inherited the shell PATH, which masked the bug. The entry now embeds winpodx's absolute path, resolved with `shutil.which()` at install time (falling back to the bare name when it can't be resolved). +- **A missing RDP password now fails with a clear error instead of an unanswerable prompt** (#569, thanks @biskasarchaniotakis). When neither `rdp.password` nor `rdp.askpass` was set, xfreerdp fell back to an interactive password prompt that can't be answered under a GUI launch, so the launch failed only with the opaque `Inappropriate ioctl for device` / `ERRCONNECT_CONNECT_CANCELLED`. `build_rdp_command` now raises up front, naming the missing setting. +- **Password rotation now surfaces a rejected `net user` instead of reporting success** (#569). The guest-side PowerShell printed `password set` regardless of the `net user` exit code, so a rejected change left the stored and actual Windows passwords out of sync while reporting rc=0 — which then shows up downstream as failed RDP auth / apps not opening. It now checks `$LASTEXITCODE` and exits non-zero on failure. +- **`/kbd` is now an allowed FreeRDP extra flag** (#657). Pass a keyboard-layout override via `rdp.extra_flags` (e.g. `/kbd:layout:0x409`) to work around FreeRDP keycode-scanning warnings on some layouts. +- **The Debian/Ubuntu `.deb` now pulls in `podman-compose`, and a missing compose provider fails loudly instead of cryptically** (#644, thanks @paolodongilli). On Debian 13 the `.deb` installed winpodx + podman but not `podman-compose`, so `winpodx setup` couldn't create the container and died downstream with `no such container "winpodx-windows"`. `podman-compose` is now a package `Recommends` (apt installs it by default), and if no compose provider is present at setup time winpodx prints an actionable error naming the package to install per distro instead of silently skipping container creation. (The `curl … install.sh` path already installed it — this closes the `.deb` gap.) +- **The maintenance task dialog (Debloat, Grow Disk, Sync Guest, …) no longer opens cramped** (#550, thanks @ismikes). The `BusyDialog` progress window had only a 380px minimum width and sized its height to content, so it came up around 392×139 — too small to read comfortably, most visibly on the Debloat *Speed* run. Given a 480×168 floor. (The picker-window size and the fast-op auto-close were already addressed in 0.7.2.) + +### Contributors + +Thanks to @cxgreat2014 for the code contributions (PRs #657, #658), and to @ismikes (#630, #550), @realahmed7777 (#646), @paolodongilli (#644), and @biskasarchaniotakis (#569) for the reports and suggestions that drove this release. + +## [0.7.3] - 2026-06-20 + +### Added + +- **`+multitouch` is now an allowed FreeRDP flag — touchscreen / stylus / pen passthrough** (#623, thanks @Scratch2xs). Add `+multitouch` to your extra FreeRDP flags (`rdp.extra_flags` in `winpodx.toml`, or the Settings extra-args field) so a touchscreen, drawing tablet, or pen works inside Windows apps — handy for drawing software with no Linux build. It's an input-only toggle, so it joins the FreeRDP flag allowlist with no security trade-off. +- **Optional idle auto-stop to free the VM's RAM** (#622, thanks @hermitguo). Settings → **Idle Action** chooses what the *Idle Timeout* does when it elapses: **Pause** (default — freezes the VM, frees CPU, keeps RAM, instant resume) or **Stop** (frees the VM's RAM; the next launch is a full boot). Off by default — Idle Timeout itself is off by default, and the action stays Pause unless you opt into Stop. (The winpodx interface is already multi-language, also part of this request: Settings → **UI Language** picks from 8 languages including 中文 / 日本語 / 한국어.) +- **Reverse-open now opens files that live on the Windows VM itself, not just your shared Home** (#616, thanks @notnotno). Picking a Linux app from a Windows file's *Open with* menu used to only work for files under your shared Home (`\\tsclient\home`); a file on the Windows Desktop or anywhere on `C:` failed with a confusing "No such file". The guest's `C:` is now shared to the host over SMB (loopback only) and mounted on demand, so a host app opens the real guest file — and your edits save straight back to it. Delivered entirely at runtime (guest share via the agent + host mount), so `install.bat` is untouched. Requires **kio-fuse** on the host (KDE); `winpodx doctor` warns if it's missing. +- **Debloat disables more telemetry and ad surfaces** (#590, thanks @GameSoul7Eugene). The *Ads & suggestions* item now clears additional `ContentDeliveryManager` suggestion keys and the rotating lock-screen ads, and the *Unnecessary scheduled tasks* item disables more pure-telemetry tasks (`AitAgent`, `ProgramInventoryUpdater`, CEIP `BthSQM`, `Feedback\Siuf`, `WindowsAI` Copilot/Insights data collection, and the Office telemetry agents). Security- and system-critical tasks (Windows Defender, licensing/activation, certificate services, Windows Update repair, language packs, Windows Hello) are deliberately left untouched so debloat can't break activation, updates, or the IME. + +### Fixed + +- **Dashboard RAM + Disk C: gauges no longer get stuck on "n/a"** (#634, thanks @ismikes). Both read from the guest over the agent (CPU is host-side, which is why it kept working), and the quiet poll's timeout was a tight 4s — on a slow or freshly-relaunched guest the read didn't finish in time and both gauges blanked to "n/a". Bumped to a generous 12s (the poll runs off the GUI thread with a re-entrancy guard, so the longer budget never freezes or stacks the dashboard). Same too-tight-timeout class as #619. +- **`winpodx app refresh` no longer times out at 30s on a slow guest** (#619, thanks @KyleSanderson). Discovery enumerates the guest's Start Menu + AppX packages, which on a cold or low-spec guest legitimately runs over a minute — but the CLI capped it at 30s (`/exec timed out after 29.0s`) while a fresh install discovered fine at the library's own 180s. The timeout is now a single generous default (`DEFAULT_DISCOVERY_TIMEOUT` = 300s) across every path (provision, `app refresh`, GUI Refresh); it exists only to bound a genuinely wedged guest, not to cap normal completion. Raise it with `winpodx app refresh --timeout` for a pathologically slow guest. +- **`install.sh --ref ` now installs the branch's latest commit on a re-run** (#616). It fetched the remote but then checked out the stale *local* branch ref (and only `reset --hard` for `main`), so re-running an update against a feature branch reinstalled whatever was first cloned — a silent no-op. It now checks out `origin/`. +- **An update now applies compose-template changes and starts a stopped pod, instead of deferring** (#616). `install.sh` regenerates `compose.yaml` on every upgrade but recreated the container only when the pod was already running, and skipped the Windows-side apply entirely when the pod was stopped — so guest-side changes shipped by an update silently didn't land until the next manual launch. An update now recreates on a compose change even when stopped, and starts a stopped pod to apply (existing install — no ISO redownload). +- **Removed the USB drive-letter auto-mapper that kept destabilizing the install** (#613, #638, thanks @zephir2008 and @ismikes). `media_monitor.ps1` tried to surface each USB volume as a drive letter (E:, F:, …), but it could not do so reliably in RemoteApp (RAIL) sessions, and shipping it in the OEM bundle re-triggered the intermittent Windows Defender / rdprrap first-boot install deadlock (fresh installs hung right after the file was copied). It's gone. USB media is still reachable in **every** session via the `\\tsclient\media` redirection and the desktop **USB** shortcut; for a real drive letter or raw block-device access, use USB passthrough. Existing pods have the stale `WinpodxMedia` autostart entry cleaned up on the next `apply-fixes`. +- **The `usbredirect not found` hint now names the right package per distro** (#593, thanks @techabsol). Debian/Ubuntu is `usbredirect`, Fedora is `usbredir-tools` (plus an Atomic Fedora `rpm-ostree` form), and openSUSE stays `usbredir` — the previous message pointed at packages that don't carry the binary. +- **`install.bat` no longer throws a syntax error reserving the agent URL** (#614, thanks @zephir2008). The `netsh http add urlacl … sddl=D:(A;;GX;;;WD)` line left the SDDL unquoted, so `cmd.exe` treated its `(`, `;`, and `)` as metacharacters and the reservation could fail. The SDDL value is now quoted (`sddl="D:(A;;GX;;;WD)"`). +- **The guest agent recovers from a drifted bearer token instead of staying 401 forever** (#615, thanks @zephir2008). The agent reads its token once at boot from a baked copy of `C:\OEM\agent_token.txt`; if that diverges from the host token, `guest_exec` / `guest_summary` (and any authenticated `/exec`) return HTTP 401 with no way for the agent to fix itself. A new `winpodx guest resync-token` re-pushes the current token over the FreeRDP channel (which auths with the Windows password, so it works while the agent is 401) and respawns the agent to re-read it. `winpodx doctor` now auto-runs this when its `guest_exec` probe hits a 401, then re-checks. + +### Contributors + +Thanks to @notnotno (#616), @hermitguo (#622), @Scratch2xs (#623), @KyleSanderson (#619), @ismikes (#634, #638), @GameSoul7Eugene (#590), @techabsol (#593), and @zephir2008 (#613, #614, #615) for the reports and contributions that drove this release. + +## [0.7.2] - 2026-06-15 + +### Fixed + +- **GUI no longer crashes when clicking "Refresh Apps"** on KDE/Plasma Wayland (#567). The app list is a `widgetResizable` scroll area whose as-needed vertical scrollbar made `QScrollArea.updateScrollBars` re-query the word-wrapped labels' `heightForWidth` without bound → stack-overflow SIGSEGV. The scrollbar is now pinned so the viewport width can't oscillate. +- **Tray "Terminate Session" and "USB Devices" submenus open again** on KDE/Plasma (#573). Their menu entries were built with parentless `QAction`s inside a helper, so PySide6 garbage-collected them once the helper returned and the submenus exported with zero children (the arrow showed but nothing opened). Each action is now parented to its submenu so it survives. +- **Chinese / Japanese / Korean app names are no longer dropped from discovery** (#553). A purely non-ASCII name sanitized to an empty slug and the app vanished; it now keeps the original characters as the display name and gets a stable internal id, so CJK-named apps appear like any other. +- **`winpodx pod stop` keeps the container** instead of removing it. It used `compose down` (which deletes the container), so updating winpodx while stopped recreated the container from compose every time — "Container 'winpodx-windows' is missing — creating it …" plus an unnecessary Windows reboot. It now uses `compose stop`; `start` just restarts the kept container. The disk is unaffected. +- **A missing Windows username now fails fast with a clear message** instead of a cryptic FreeRDP `Inappropriate ioctl for device` error (#569). With no credentials xfreerdp fell back to an interactive prompt that can't run under a GUI launch; winpodx now says to run `winpodx setup`. +- **`winpodx doctor` / extra RDP flags accept the current FreeRDP 3 spellings** (#380): the cache toggles are `/cache:bitmap:on|off` (etc.) — the old FreeRDP-2 `+/-bitmap-cache` forms that xfreerdp 3 rejects are dropped from the allow-list. +- **`-gfx` can be passed to disable the GFX pipeline** (#393) as a workaround for RAIL render glitches (blue/warping windows) on some XWayland/Plasma setups; the bare `+gfx`/`-gfx` toggles were previously blocked by the extra-args allow-list. +- **The Debloat picker opens at a usable size**, and the "running debloat" dialog now closes itself when the run finishes (a fast preset could leave it hanging) (#550). + +### Contributors + +Thanks to @ismikes (#393, #550, #573), @notnotno (#380), @hermitguo (#553), @bangetto (#567), and @biskasarchaniotakis (#569) for the bug reports that drove this release. + +## [0.7.1] - 2026-06-13 + +### Added + +- **Quick app launcher (`winpodx launch`).** A Start-menu-style picker for your Windows apps — search, category pills, reveal-highlight tiles, and a compact mode — sourced from the same app registry as the main GUI and launched through `winpodx app run`. Bind it to a desktop-environment custom shortcut (KDE/GNOME) for a system-wide hotkey; no extra dependencies and Wayland-safe (#561). +- **Automatic file associations — discovered Windows apps now appear in your file manager's "Open with" menu (#545).** On by default; disable with `winpodx config set desktop.mime_associations false` or the GUI Settings toggle. Apps are only *added* to the "Open with" / Recommended list — winpodx never sets a Windows app as the default handler for a type. The associations are read live from Windows and unioned across every place an app declares what it can open (per-extension `UserChoice`, per-app `Capabilities\FileAssociations`, `Applications\\SupportedTypes`, and the UWP AppxManifest `windows.fileTypeAssociation`), so e.g. `.png` offers Paint/Photos/Snipping Tool, `.pdf` offers Edge, `.txt` offers Notepad. Extensions are mapped to MIME types via the system `shared-mime-info` database (no hardcoded tables), and the change auto-applies on the next `install.sh` update via the discovery refresh — no manual `app refresh`. +- **GUI app management — reset, custom icons, and multi-select (#530).** Per-app **Reset to detected** restores the auto-discovered name/icon; a **custom icon picker** overrides the icon; **multi-select** enables bulk **Hide selected** / **Remove selected**; and removed apps land in a **restore list** so a deletion can be undone (plus a full re-detect button). Checkboxes now use a visible (contrasting) colour. +- **`winpodx doctor` warns on an old FreeRDP build with broken RemoteApp windows (#546).** A native `xfreerdp` 3.x below 3.6.0 (e.g. the 3.5.1 that Ubuntu/Budgie 24.04 ships via apt) has RAIL window-mapping bugs that leave a RemoteApp connected but its window never shown. doctor now flags this as a warning (not a failure) and points to an upgrade or the winpodx AppImage. +- **Checksum-gated periodic icon refresh.** Discovered-app icons re-sync from the guest only when the source executable's checksum changes, so a routine refresh no longer rewrites unchanged icons (#539). + +### Fixed + +- **`winpodx gui` no longer blocks the terminal (#549).** Launched from a shell it now detaches into its own session, brings up the tray + dashboard, and returns the prompt immediately. `.desktop` autostart and the quick launcher already ran it detached; `winpodx gui --foreground` keeps it attached for debugging. +- **`install.sh --main` (and `--ref` / `--source` / `--image-tar`) is honoured on Atomic Fedora (#548).** rpm-ostree systems (Silverblue/Kinoite/Bazzite/...) defaulted to the OBS RPM and ignored an explicit source override; the RPM only ships tagged releases, so `--main` could never reach you. The rpm-ostree path is now gated on those flags being unset, so custom-image builders can layer winpodx from source. Default Atomic installs are unchanged. +- **`install.sh` auto-loads the `kvm` module when `/dev/kvm` is missing but the CPU supports virtualization (#541).** +- **Bare-metal disguise no longer crash-loops the container (#246 follow-up).** After winpodx re-writes the synthetic SMBIOS blob, the file is relabelled so the container can read it back; a stale label previously broke the next boot. +- **`winpodx doctor` no longer flags intentionally hidden apps as missing desktop entries (#535).** +- **`uninstall.sh` now removes everything winpodx creates** — the consolidated "WinPodX" menu folder (`.directory` / `.menu` fragments, which otherwise left an empty submenu), stale `mimeapps.list` handler entries, and (on `--purge`) the `/etc/modules-load.d/winpodx-kvm.conf` drop-in. +- **A discovered app name can no longer inject extra keys into its `.desktop` launcher.** Control characters in the guest-supplied name are stripped from the `Name=` field, matching the existing `Comment=` handling (hardening; a hostile guest is required to trigger it). + +### Changed + +- **`winpodx gui` detaches by default** for interactive launches, with `--foreground` to opt out (#549). + +### Contributors + +Thanks to @MirzaAyBaig12 for contributing the app launcher (#561) and for reporting #530, #535, and #545; to @hermitguo for reporting #541 and #546; to @notnotno for reporting #548; and to @ismikes for reporting #549. + +## [0.7.0] - 2026-06-11 + +### Added + +- **Bare-metal disguise — make the Windows guest read like a physical machine to VM-detection software (#246).** Opt-in, off by default. Software that refuses to run under a *detected* hypervisor — Nvidia GPU-passthrough "code 43", launch-gate VM checks, VM-hostile installers — sees a genuine-looking bare-metal box instead of QEMU/KVM. It works in two layers, picked by the **bare-metal level** (`winpodx config set pod.disguise_level off | balanced | max`, or the GUI Settings selector): + - **Per-VM, no rebuild (`balanced`+):** clears the CPUID hypervisor-present bit and the KVM signature (`-cpu -hypervisor,kvm=off,-kvm-pv-*`), mirrors the host's real SMBIOS/DMI (system / board / BIOS vendor + product, CPU vendor + model) into the guest, and injects a synthetic SMBIOS sensor/descriptor blob (voltage / temperature probe, cooling device, cache, memory array + DIMMs, 40+ structures) so the `Win32_*` / `CIM_*` WMI sensor classes report like real hardware. + - **Patched-QEMU image (`max` / "Hardened"):** `winpodx disguise build-image` compiles QEMU **locally** (~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 `_HID`s, the `WAET` table signature, disk / optical model **and INQUIRY vendor** — plus an injected thermal-zone SSDT and a WSMT table. At `max`, winpodx also presents a SATA system disk, an e1000 NIC, std VGA, and a `nec-usb-xhci` USB-3 controller (keeps USB3 while dropping the Red Hat `VEN_1B36` tell), removes the virtio-rng device (`VEN_1AF4`), and prunes the unused virtio driver service keys in the guest. Selecting Hardened in the GUI builds + wires the image automatically. + - **Privacy:** the host-derived strings are *non-identifying* (vendor / model codes, like the disk model) and flow via Docker build-args into the **local image layer only — never committed to git, never pushed**. Serial / UUID / asset-tag are never read. The source ships generic fallbacks (`ALASKA` / `Samsung SSD` / `ATA`). + - Verified against **al-khaser 0.82** on real Windows: the disk, sensor, SMBIOS, ACPI, CPUID, virtio-service and username detection families read clean. Remaining tells are the container-QEMU structural floor (RDTSC timing, the legacy `Win32_MemoryDevice` class that is empty on real hardware too, and the Windows-inherent Hyper-V integration objects that share the RDP display driver). +- **Default Windows guest username is now `WPX-User`** (was `User`), which avoids al-khaser's exact-match sandbox-username list. Change it with `winpodx config set rdp.user ` before first install. + +### Fixed + +- **Opening a file under a symlinked subdirectory of `$HOME`** — e.g. `~/Documents` symlinked to `/mnt/store/Documents` — no longer fails with "Path is outside shared locations" (#547). The host→guest path is normalised lexically instead of resolved, so a symlinked subdir is traversed like a folder (FreeRDP follows it); `..` is still blocked, and the Fedora Atomic `/home → /var/home` handling is kept (#418). +- **USB hot-plug works again on the new dockur image.** dockur v5.16 moved the QEMU monitor from `telnet:7100` to a unix socket, which broke the live USB attach path with "QEMU monitor unreachable"; winpodx now talks to the socket (and still falls back to telnet for older dockur) (#286). +- **The GUI Devices panel no longer flickers** when a host USB device is unplugged while the tab is open, and a device yanked mid-enumeration no longer raises into the Qt slot. + +### Changed + +- **Pinned dockur/windows bumped to v5.16** (x86_64) and the dockur/windows-arm `:latest` security rebuild (aarch64) (#554, #555). + +## [0.6.0] - 2026-06-05 + +### Removed + +- **The libvirt backend was dropped (breaking).** `backend = "libvirt"` is no longer valid — the default dockur backend is QEMU/KVM in a container and now covers device passthrough too (#286), so the thin "bring-your-own libvirt domain" wrapper had no remaining justification. `--backend` accepts `podman | docker | manual`; an existing config with `backend = "libvirt"` falls back to `podman` on load (with a warning). The `libvirt` pip extra (`libvirt-python`) and the install.sh/AUR/RPM/DEB libvirt references are gone; `winpodx[all]` is now libvirt-free (no `libvirt-dev` build dependency). Run Windows in your own libvirt domain? Keep using winpodx ≤ 0.5.x, or point the `manual` backend at its RDP endpoint. + +### Added + +- **A redesigned, Start-menu-style GUI — resource Dashboard home + left navigation sidebar (#460–#471).** The window is now a Start-menu shell: a left vertical navigation sidebar (one row per page, with the active page highlighted) and a new **Dashboard** home showing live Pod / RAM / CPU ring gauges + disk usage, an auto-recovery status card, pinned/recent workspace tiles, and a reverse-open toggle; the app launcher moved to an "All apps" page. It also brings a unified design system, an in-house SVG icon set (replacing the old unicode-glyph "icons"), responsive layouts that reflow (stack columns, drop grid tiles) on narrow / fractionally-scaled windows, fit-to-screen window sizing, and a hero search that doubles as a command bar. The display name is now **WinPodX** across the UI, site, and docs (the lowercase `winpodx` command / package identifier is unchanged). +- **A project website — [winpodx.org](https://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 ` / `winpodx app show `. +- **`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 list` shows host USB/PCI devices and which are assigned; `device attach ` / `detach ` assign/release them (persisted in `cfg.pod.devices`), and a **Devices** tab in the GUI is a two-column host↔guest mover. **USB hot-plugs live into the running guest** (`cfg.pod.usb_live`, default on): `device attach ` adds it with no restart by driving dockur's built-in QEMU `-monitor` (reached via ` exec ... bash -c '/dev/tcp'`) — no custom `-qmp` socket and no `device_cgroup_rules` (both crash-looped Windows boot on rootless Podman). usb_live just binds the host USB bus (`/dev/bus/usb`) into the container, which boots cleanly rootless; QEMU (root, via dockur) attaches the device to its existing `qemu-xhci` controller. Set `usb_live = false` to keep the USB bus out of the container. (USB **mass storage** also still works via the `\\tsclient\media` drive share.) **PCI** binds to `vfio-pci`, which can't be hot-plugged into a container QEMU, so it's boot-added and needs a guest restart, gated behind a safety check — risky devices (the primary GPU, the boot-disk controller, the active NIC) require an explicit `--force` (CLI) or a confirmation dialog (GUI), and the whole IOMMU group is flagged since it moves together. Device ids are hex-validated, so nothing dangerous reaches the generated compose / QEMU args. +- **System-tray USB switcher (#300).** The tray menu now has a **USB Devices** submenu with a checkable entry per host USB device — tick it to redirect the device into the running guest, untick to hand it back to the host. It's the quick-access surface alongside the CLI (`device attach`/`detach`) and the GUI Devices tab: the toggle runs the persist + live usbredir attach/detach off the UI thread (so the tray never freezes, and a `pkexec` prompt can still appear), and the submenu rebuilds on each open so it tracks hot-plugged devices and the current assignment. The persist half is now a single shared helper (`core.devices.assign_device` / `unassign_device`) behind all three surfaces. +- **Multi-monitor RAIL now works by default — a remote app window keeps working input when dragged onto a second monitor (`cfg.rdp.multimon`, defaults to `span`).** Without it, a RAIL app launch sizes the session desktop to a single monitor, so a window dragged onto a second monitor lands at host-virtual-screen coords *outside* that desktop — clicks miss, then stop registering entirely. winpodx now adds `/span` to RAIL app launches, sizing the session desktop to the bounding box of all host monitors (one wide rectangle, no per-monitor `MonitorDefArray`). `/multimon` was tried first but sends the full monitor layout, which the guest's `rdprrap` RAIL helper can't handle — it kills input outright — so the default is `span`, not `multimon`. Set `cfg.rdp.multimon = "off"` (or `winpodx setup --multimon off`) for non-rectangular layouts where the spanned bounding box leaves dead space; `multimon` is kept as a diagnosis-only value. +- **`--extra-args` / `cfg.rdp.extra_flags` now allow the multi-monitor + repaint knobs** (`/multimon`, `/multimon:force`, `/span`, `/gdi:sw|hw`, `/smart-sizing[:WxH]`, `/monitors:0,1`), each value-validated. These are the manual levers for the RAIL window-move corruption some multi-display setups hit (a remote app window blurs / breaks when dragged between monitors of different resolution or DPI). With `cfg.rdp.multimon` now defaulting to `span` (above), the input-loss case is handled out of the box; these flags remain for per-launch experimentation with the repaint / scaling knobs. +- **`winpodx doctor --fix` — idempotent auto-remediation for common findings (0.6.0 item K).** `winpodx doctor` stays read-only by default (it prints suggested commands and never mutates state), but the new `--fix` flag turns each finding that has a known fixer into an automatic, idempotent repair: doctor collects findings, runs the registered fixer for every warn/fail finding, then re-probes that single check and reports `[fixed]` / `[still failing]` (findings with no fixer print `[skip] no auto-fix available`). Every fixer is a no-op when the underlying state is already healthy, so `--fix` is safe to run repeatedly. Four remediations ship: (1) **dead agent** — when the pod is RUNNING but the in-guest agent `/health` is down, doctor kicks the in-guest `WinpodxAgentKeepAlive` scheduled task (over the agent transport, which falls back to FreeRDP when the agent itself is unreachable) and polls `/health` until the agent is back; (2) **stale lock files** — `.cproc` markers in `~/.local/share/winpodx/run/` whose owning PID is no longer a live FreeRDP process are purged (live sessions are left untouched); (3) **missing desktop entries** — apps present in the index but with no installed `.desktop` file are re-registered through the existing desktop-entry install path (entry + icon + MIME + cache refresh); (4) **oem-version drift** — when the host's `oem_bundle` stamp is newer than the guest's recorded version, doctor triggers `guest_sync.maybe_autosync` to push the refreshed guest scripts. `--fix` implies the slow container-health / guest-exec probes so the two guest-touching fixers are reachable. The stale-lock and missing-desktop-entry fixers are host-only; the dead-agent and oem-drift fixers touch the Windows guest. Without `--fix`, `winpodx doctor` behaviour is unchanged. See `docs/design/ROADMAP-0.6.0.md` item K. +- **Clean, interactive install progress (and `install.sh --verbose` for the raw firehose).** The Windows first-boot wait used to dump hundreds of raw dockur/wget lines (`…K …… 78% 4.55M 21m22s`) plus UEFI `BdsDxe:` boot-loader noise. By default `pod wait-ready --logs` now shows the ISO download as a **single self-erasing line** that updates in place — `Downloading Windows ISO [#########-----] 78% 4.55 MB/s ETA 21m22s` — with a `Windows is booting…` heartbeat for the non-download phases and UEFI/`mknod` noise hidden; only real dockur milestones stay on screen. The live line is written straight to `/dev/tty`, so it animates on the terminal without polluting `install.sh`'s `tee`-captured log (non-TTY consumers fall back to occasional percentage lines). `install.sh --verbose` / `-v` (or `winpodx pod wait-ready --logs --verbose`) streams the full raw container output instead. The installer also ends with a tidy summary box (version / backend / GUI / venv path) + concrete next-step commands. Deadline auto-extension on slow downloads is unchanged. +- **`install.sh` v2 — a pre-sudo system check, install modes, a mandatory private venv, and rollback on failure (resolves #271).** The `curl | bash` installer now scans the host *before* touching `sudo`: it prints distro + version, presence/version of podman / docker / libvirt / freerdp / python3, whether `/dev/kvm` exists, and whether `python3 -m venv` actually works (Debian/Ubuntu split `python3-venv`/ensurepip out, so it can be missing). It flags **podman older than major 4 as too old** — dockur/winpodx need rootless `group_add: keep-groups` + modern compose, and Ubuntu 22.04 ships podman 3.4 (#271) — and suggests either upgrading podman (Kubic / `devel:kubic:libcontainers`) or using the Docker backend. It then offers four modes (preselect with `--mode r|a|c|n` / `WINPODX_MODE` for piped/non-interactive runs): **[R]ecommended** (today's behaviour — Podman backend, install all missing deps), **[A]utomatic** (reuse what's installed, pick an already-working docker/podman/libvirt backend, minimal sudo), **[C]ustom** (choose backend + GUI yes/no), **[N]o** (cancel cleanly, no changes). Two new flags feed `winpodx setup`: `--backend podman|docker|libvirt|manual` (`WINPODX_BACKEND`) and `--no-gui` (`WINPODX_NO_GUI=1`, headless — skips PySide6). **Python now always runs from a private venv** under `~/.local/bin/winpodx-app/.venv` — the `winpodx-run` launcher execs the venv's interpreter instead of the system `python3` + `PYTHONPATH`, so there's no `--user`/`--break-system-packages` system-python pollution and the reverse-open icon deps (`cairosvg` + `pyxdg`) install cleanly into the venv (no more distro-package best-effort). And a **failed fresh install now rolls back** — it removes only winpodx's own artifacts created that run (the venv, `winpodx-run` + `winpodx` launcher/symlink, desktop entry + icon, the in-progress marker), never system packages and never a pre-existing `~/.config/winpodx` config; on an *upgrade* a failure leaves the working install untouched. `--manual`, `--skip-deps`, `--source`, `--ref`/`--main`, `--win-version`, and `--image-tar` all behave as before. +- **`winpodx-git` AUR package — install the latest `main` from source (#482, #483, #484).** Alongside the stable `winpodx` AUR package (tagged release tarballs), a new `winpodx-git` VCS package builds from the GitHub `main` branch: `yay -S winpodx-git`, with the version derived from git so `yay -Syu --devel` rebuilds it whenever `main` moves. It `provides`/`conflicts` `winpodx`, so install one or the other — not both. Recipe + maintainer notes live in `packaging/aur-git/`; documented in `docs/INSTALL.md`. + +### Changed + +- **GUI UX overhaul — clearer feedback, safer actions, less jargon.** A broad pass over the desktop app (no new telemetry, no network, no bloat): a shared toast/notification + "busy" dialog + inline warning-callout + actionable-error layer (`_widget_helpers`) now backs consistent feedback everywhere. Highlights: app launches and device attach/detach show a non-blocking toast ("Launching …" → success/fail) instead of silent RDP spawns; the app library shows a **context-specific empty state** (Windows not running / no search match / all hidden / none discovered) and a unified grid-vs-list card layout (labelled Launch, consistent Show/Hide, confirmed delete), a labelled search box, a "+N more" overflow for categories beyond the first 8, and an "X of Y" count that reconciles with the status bar; refresh failures use an actionable dialog (Start pod / Retry / View logs); the bring-up dialog disables Cancel during the non-interruptible phases (with a tooltip) and shows honest per-phase ETA hints + a clear "✓ Ready" finish; the status banner now distinguishes "running but transport degraded" (RDP/agent unreachable) from "stopped", and a down agent that still has working FreeRDP fallback shows amber (not red); pod state is no longer triplicated across chip/banner/info-bar; Settings marks which controls apply immediately, surfaces a wipe/recreate **warning callout** with a time estimate before destructive saves, shows an always-on RAM-budget line, and adds inline help for the power-user knobs; long maintenance ops (grow-disk / sync-guest / debloat / apply-fixes) show a busy dialog with an ETA; PCI passthrough spells out in plain language which host devices the IOMMU group gives up; the Add-app dialog explains its fields and lightly validates the Windows executable path; keyboard shortcuts (Alt+N to switch pages, Ctrl+F to search) were added; and the License page links each third-party project. (Funding stays in the repo `FUNDING.yml` / README — never surfaced in the app.) +- **Windows apps are now grouped under a single "winpodx" menu folder instead of being scattered across native categories.** Previously each installed app inherited its discovered categories (Office, Graphics, …) and landed loose among your native Linux apps; on a 50-app guest that buried the menu. winpodx now creates a dedicated **winpodx (Windows Apps)** submenu — the same mechanism Wine uses: a freedesktop `.directory` file names the folder, an `applications-merged/winpodx.menu` fragment maps a custom `X-winpodx` category into it, and every generated `.desktop` carries that one category. The folder is created on first app install and torn down when the last Windows app is removed (both idempotent + self-healing on `winpodx app refresh`). Honored by KDE Plasma, XFCE, Cinnamon, MATE and LXQt; GNOME's overview is a flat grid that ignores menu folders, so there the apps still appear but aren't grouped. Menu search still finds apps by name (`Keywords=windows;winpodx;`). Re-run `winpodx app refresh` to migrate an existing install. +- **`install.sh` prints an install plan before it touches the system.** Once the mode (R/A/C/N) and every dependency source are resolved, the installer shows a short plan that lists **every major component evenly** — `python3`, the `venv` probe, the container backend, FreeRDP, `/dev/kvm`, and the GUI — each with its detected state and the action this run will take (use existing / install / host-requirement), plus the exact packages it will install via the distro package manager and the Windows-VM provisioning steps — *before* any package install or `sudo`. The run is transparent instead of jumping straight from the mode prompt into installing things. +- **FreeRDP client source is now selectable; the launcher prefers the Flatpak client, with the native client as a fallback / opt-in (#269, #366, #393).** Previously `install.sh` always installed the native `freerdp3` package even when the Flatpak `com.freerdp.FreeRDP` was already there (redundant — #269). Now: (1) **the launcher prefers the Flatpak `com.freerdp.FreeRDP`** (`core/rdp.find_freerdp` auto order is flatpak-first) — it ships a self-contained FreeRDP 3+ with no host package skew. Its earlier RAIL multi-display rough edges (a remote app window losing input when dragged to a second monitor) are now handled by `cfg.rdp.multimon = "span"`, so the Flatpak is viable as the preferred client; the native `xfreerdp` is the fallback when the Flatpak is absent, or when explicitly pinned via `--freerdp-source native`. (2) **`install.sh` never installs a redundant client** — if any FreeRDP (native or Flatpak) is already present it installs nothing; when none is present `auto` installs the **native** package (lightweight, no Flatpak-runtime pull), and the launcher's auto order then prefers an existing Flatpak only when one is actually present. (3) **Custom install mode lets you choose each major dependency's source** — container backend (podman/docker/libvirt), **FreeRDP client (auto / native / flatpak)**, and the GUI — and `winpodx setup --freerdp-source ` persists the choice to `cfg.rdp.freerdp_source` (set `native` to pin the native client on hosts where the Flatpak sandbox is a problem). +- **Reverse-open refresh output now states its direction and the deprecation / summary plumbing is de-duplicated.** `winpodx host-open refresh` prints a `Reverse-open (host apps → Windows "Open with")` header so its `Discovered/staged/skipped` counts aren't confused with the opposite-direction Windows-app discovery that runs just above it during `provision`, plus a pointer to `winpodx host-open list` for the staged set. Separately, the `winpodx provision` summary no longer dumps the raw `apply_fixes` dict repr (it renders a compact `N/N fixes OK` / `k: v` line), and the two byte-identical `pod`-deprecation-notice helpers (`_deprecate_pod` / `_emit_deprecation`) are collapsed to one. +- **`install.sh --verbose` now reaches the upgrade path too.** `install.sh --verbose` (and `WINPODX_VERBOSE=1`) used to forward to `winpodx provision --verbose` on a fresh install but was silently dropped on the upgrade branch, because `install.sh` invoked `winpodx migrate --non-interactive` without a verbose flag and `migrate`'s injected `_rich_wait` hardcoded `verbose=False`. `winpodx migrate` now accepts `--verbose` / `-v`, `install.sh` forwards `$WINPODX_VERBOSE` to both `provision` and `migrate`, and the migrate wait-ready stage honours the flag so an upgrade run streams the raw container firehose just like a fresh install. No behaviour change when `--verbose` is off (the clean self-erasing line stays the default for both paths). +- **`agent_keepalive` no longer fails the migrate apply burst on a post-restart agent flicker.** During an upgrade `winpodx migrate`, the container restarts (OEM reboot pass) and the in-guest agent goes through the TermService cycle that rdprrap (re)activation triggers. A *single* `/health` OK during that window can be the agent momentarily up — it dies again right after, and the apply burst then hit a closed socket (`agent_keepalive: channel failure: /exec socket error: Remote end closed connection without response`, seen in 0.6.0 upgrade smoke). Two layers of defence now: **(A)** the `require_agent` settle stage waits for **3 consecutive** `/health` OK probes (≈ up to 30 s) before proceeding, so the chain starts only once the agent has actually stabilised rather than on the first flicker; **(B)** `_apply_via_transport` — the shared channel used by every `_apply_*` runtime fix (`agent_keepalive`, `oem_runtime_fixes`, `multi_session`, `vbs_launchers`, `max_sessions`, `rdp_timeouts`) — now retries a `TransportError` (closed socket / `/health` timeout) **twice with a 5 s backoff**, re-dispatching each attempt so a recovered agent is re-picked (or a still-dead one falls to FreeRDP). A genuine `rc != 0` from a payload that actually ran is still surfaced on the first try, unretried. No behaviour change on a healthy first try. +- **Provisioning unification (0.6.0 item B) restored four behaviours its first cut had homogenised away.** Folding the four post-create paths into `finish_provisioning` had conformed everything to a silent wait + a single agent gate, dropping behaviours each path had accumulated for specific issues: (1) the **dynamic wait** — the live self-erasing progress line + wget-ETA deadline auto-extension for slow links (#126) — so a fresh install no longer shows one line then a silent multi-minute hang; it's restored via an injected `wait_fn` that routes the wait-ready stage through the rich `pod wait-ready` machinery. (2) **agent-first install protection** (#271): `require_agent=True` now exports `WINPODX_REQUIRE_AGENT=1` across the apply + discovery stages (not just the one-shot settle re-probe), so discovery/apply defer instead of falling back to a FreeRDP RemoteApp connect that can kick install.bat's autologon session during first boot; a persistent agent-unavailable during discovery now defers cleanly to the pending machinery (exit 5) instead of recording a generic failure. (3) **upgrade → `winpodx migrate`**: `install.sh` now branches fresh vs upgrade — fresh runs `provision --require-agent`, upgrade runs `migrate` (which syncs the refreshed guest scripts / `agent.ps1` into the existing guest, pins the image, then runs the same apply → discovery → reverse-open chain); the first cut ran `provision` for both and left upgraded guests on stale guest scripts. (4) the **`pending.resume` "migrate" step** now runs `guest_sync` too, so a deferred upgrade resumed later also refreshes guest scripts. `migrate` additionally gained the reverse-open stage (it's now the upgrade path's sole driver). See `docs/design/PROVISION_UNIFY_FIDELITY_AUDIT.md`. +- **Command taxonomy reorganised — `guest`, `install`, and `doctor` are the new canonical homes (0.6.0 item G).** The `pod` junk-drawer carried 14 subcommands across three unrelated domains; the diagnostics surface had three partially-overlapping commands (`info`, `check`, `doctor`). As of this release: **`winpodx pod`** keeps only the lifecycle subcommands (`start`, `stop`, `status`, `restart`, `recreate`, `wait-ready`). **`winpodx guest`** is the new canonical home for guest-side operations: `apply-fixes`, `sync` (renamed from `sync-guest`), `sync-password`, `multi-session`, `recover-oem`. **`winpodx install`** is the new canonical home for install-progress and storage operations: `status` (from `pod install-status`), `resume` (from `pod install-resume`), `grow-disk` (from `pod grow-disk`), `disk-usage` (from `pod disk-usage`). **`winpodx doctor`** is the canonical diagnostic command; it gains two new flags: `--json` (machine-readable Finding list as a JSON array) and `--quick` (skip slow container-health / guest-exec probes, run only cheap local checks in < 1 s). **`winpodx info`** and **`winpodx check`** keep their exact current output and behaviour, but now print a one-line deprecation notice to stderr: `[deprecated] 'winpodx info' will be removed in 0.7.0; use 'winpodx doctor'` (and analogously for `check`). All old `pod ` 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`. See `docs/design/ROADMAP-0.6.0.md` item G. Deprecated aliases will be removed in 0.7.0. +- **Backend auto-selection unified through `backend/select.choose_backend`.** Three places used to decide which container backend to use, and they disagreed on Ubuntu 22.04: `install.sh`'s Automatic-mode picker walked `podman → docker → libvirt` and gated `podman < 4` as unusable (#271), but `cli/setup_cmd.py`'s non-interactive branch did a one-line `"podman" if which("podman") else "docker"` with no version gate — so the setup wizard happily picked the broken 3.4 podman the install path had just declined. `choose_backend(prefer, deps, podman_min_major=4)` is now the single Python source of truth: explicit `--backend` wins, otherwise walk `AUTO_PRIORITY = ("podman", "docker", "libvirt")` and the first usable wins (podman gated on `podman_major_version() >= 4`); fall back to `"podman"` when nothing is present so the recommended install path can install it. `install.sh`'s bash picker stays as the *one* intentional shell mirror (same pattern as the pre-venv deps probe — it runs before Python is installed) with a comment pointing back at the Python SoT, and the test suite pins both copies to the same priority order + minimum major so they cannot silently drift. Part of the 0.6.0 consolidation work (`docs/design/ROADMAP-0.6.0.md` item E). +- **Host dependency detection unified through `utils/deps.py:check_all`.** Pre-0.6.0 each consumer (`utils/deps.py`, `core/deps_quickcheck.py`, `cli/doctor.py`, `cli/setup_cmd.py`) carried its own `shutil.which()` list for FreeRDP binaries, and they had drifted — `deps_quickcheck` and `doctor` missed `sdl-freerdp3` and the Flatpak fallback, so a host with only those reported MISSING in the GUI Quick Start and in `winpodx doctor`. `check_all()` now also reports `/dev/kvm` (via a new `check_kvm()`), so `setup_cmd` drops its inline KVM probe and `doctor`'s `_check_kvm` delegates too. Every Python consumer now goes through `check_all()` / `check_freerdp()` / `check_kvm()`; the shell side of `install.sh` keeps a minimal pre-venv probe (genuinely shell-unique — it runs before Python exists) and that's the single intentional duplicate. A regression test pins it: an AST scan over `deps_quickcheck.py` and `doctor.py` fails CI the moment a hardcoded `shutil.which("xfreerdp...")` call comes back. Part of the 0.6.0 consolidation work (`docs/design/ROADMAP-0.6.0.md` item D). +- **Version + Windows-edition lists collapsed to a single source each.** `pyproject.toml` is now the only place the project version is declared; `src/winpodx/__init__.py` derives `__version__` via `importlib.metadata.version("winpodx")` (with a clearly-marked `0.0.0+source` fallback for a non-installed source checkout), so a release-prep bump can't go missing from one of the two files. The curated Windows-edition list moved to a `WIN_VERSION_LABELS` dict in `core/config.py`; the CLI help text (`winpodx setup --win-version`), the interactive `setup` prompt, and the GUI Settings dropdown all derive from it, so adding a new edition is a one-line change. The packaging-version CI guard (`scripts/ci/verify_versions.py`) drops the obsolete `__init__.py` literal check, picks up `packaging/rpm/winpodx.spec`, and adds a round-trip assertion against `importlib.metadata`; the spec's stale `Version: 0.1.5` literal is bumped to current and gains a pointer to the new guard. Part of the 0.6.0 consolidation work (`docs/design/ROADMAP-0.6.0.md` items F + N). +- **`winpodx.toml` now carries a `schema_version` marker (groundwork for future config migrations).** The on-disk config file has changed shape across versions (renamed keys, moved sections) and we've been relying on `_apply()`'s "unknown keys are silently ignored" fallback. That works for *adding* fields but loses data the moment we *rename* or *move* one. Adding `schema_version = 1` at the top of every saved file (and a `_migrate_config(data, from_version)` hook in `core/config.py`) gives a future 0.7.0+ rename a clean upgrade path without dropping user settings. The hook is intentionally a no-op today — 0.6.0 didn't change the layout — but the marker lands now so existing 0.5.x files get tagged on first load + save, and a later release that *does* restructure can transform them safely. A hand-edited file without the marker is treated as schema 0 (pre-0.6.0). Part of the 0.6.0 consolidation work (`docs/design/ROADMAP-0.6.0.md` item J). +- **Agent port 8765 is a single Python constant now.** `core/agent.AGENT_PORT` is the single host-side source of truth; the compose template, the urlacl strings pushed into the guest, and the `AgentClient` URL all derive from it. The guest-side files (`agent.ps1`, `install.bat`, `agent-keepalive.ps1`, `agent-respawn.ps1`) keep their own literal because PowerShell can't import a Python constant — that pairing is now documented and locked by tests, and `install.sh`'s `/health` curl carries a comment pointing back at the constant. Pure internal cleanup; no behaviour change. Part of the 0.6.0 consolidation work (see `docs/design/ROADMAP-0.6.0.md` item C). +- **The legacy FreeRDP host→guest fallback is now logged (groundwork for retiring it).** Before the in-guest agent existed, host→guest commands ran PowerShell over a FreeRDP RemoteApp; that path still exists as the silent fallback when the agent isn't reachable. It was logged at `debug`, so there was no way to tell how often it actually fires in practice. Every fallback now logs a `WARNING` to `winpodx.log` tagged `FreeRDP-fallback`, with the reason (agent `/health` detail) and the operation that fell back. Count them with `grep -c FreeRDP-fallback ~/.local/state/winpodx/winpodx.log`. No behaviour change — this is measurement so we can decide, from real usage, how aggressively to strengthen agent recovery and shrink the fallback (rather than removing it blindly and risking breakage). +- **Logging hygiene pass — one consistent logger variable name across modules (0.6.0 item L).** The `reverse_open/` package and `gui/reverse_open_panel.py` defined their module logger as `logger`, while the other ~50 modules use `log` (both via `logging.getLogger(__name__)`); the call sites are now standardised on `log`. A one-pass audit of log levels confirmed they are already consistent — failures / fallbacks / refusals log at `WARNING` (the console threshold), routine milestones at `INFO`, and diagnostics at `DEBUG` — so no level changes were needed. Pure internal cleanup with no behaviour change: the logger name is the module path regardless of the Python variable, so log output is byte-for-byte identical. Part of the 0.6.0 consolidation work (`docs/design/ROADMAP-0.6.0.md` item L). +- **First-run discovery retries trimmed from 6 to 2 (0.6.0 item M).** When the provisioning chain (`winpodx provision`, run by `install.sh` on a fresh install, and by the `setup` auto-provision path) scans the guest for installed apps, it retries discovery with exponential backoff if the guest agent is briefly not ready. The fixed 6× loop `install.sh` historically used predated the `WinpodxAgentKeepAlive` watchdog (#359); now that the keep-alive keeps the agent reliably up, 6 attempts is overkill and just slows a clean first run. The `winpodx provision --retries` default and the `finish_provisioning(retries=...)` default both drop to **2**, so discovery does at most one 2 s-backoff retry before reporting. The `migrate` / `pending.resume` upgrade paths keep their existing `retries=3` (deliberately set during item B and out of scope here). Override with `winpodx provision --retries N` if a slow guest needs more. Part of the 0.6.0 consolidation work (`docs/design/ROADMAP-0.6.0.md` item M). +- **Documentation refresh for 0.6.0 (0.6.0 item H).** `README.md`, `docs/USAGE.md`, `docs/ARCHITECTURE.md`, `docs/FEATURES.md`, `docs/INSTALL.md`, and every matching `docs/*.ko.md` mirror now reflect the 0.6.0 shape: the new `winpodx guest` / `winpodx install` / `winpodx doctor` command surface (with `pod ` aliases noted as deprecated and removed in 0.7.0), the Thin AppImage redesign (only FreeRDP + Python + Qt + winpodx; host container runtime required; #357 / #363 root-caused), and `winpodx provision` as the single post-pod-running chain. The `USAGE.md` cheat-sheet splits the old "Pod management" block into three sections (`pod` lifecycle / `guest` ops / `install` ops) and the System section gains the new `doctor` flags (`--json`, `--quick`, `--fix`), `provision`, and `migrate`. The version blurb in both `README.md` files is rewritten for 0.6.0. + +### Fixed + +- **UWP / Store apps now appear in the Linux taskbar (#472).** A UWP app's visible frame is owned by `ApplicationFrameHost` and arrives over FreeRDP RAIL marked `_NET_WM_STATE_SKIP_TASKBAR` / `SKIP_PAGER`, so it stayed off the panel even though its window class already matched the launcher. winpodx now re-lists the window via `wmctrl` after launch (best-effort, X11 / XWayland; no-op otherwise). +- **Opening a host file whose path contains a space now works (#473).** The UNC path (`\\tsclient\home\…`) handed to FreeRDP's RemoteApp command is now double-quoted, so the guest no longer splits it on spaces — previously the app received only the first token and reported "path not found". +- **A mixed-scale dual-monitor host no longer breaks app launch / leaves frozen windows (#474).** On KDE Plasma 6 Wayland (XWayland) with two monitors at *different* fractional scales, the default `/span` multi-monitor desktop was doubly broken: the compositor's sub-pixel rounding leaves the monitors' logical rectangles non-tileable (a 1 px gap, mismatched heights, `desktopScale: 0`), so FreeRDP rejected `/span` at `pre_connect` (`ERRCONNECT_PRE_CONNECT_FAILED`) and nothing launched; and even when a session did come up, a RAIL window dragged onto the differently-scaled monitor **froze and stopped taking input** — FreeRDP RAIL + XWayland can't remap a window across per-monitor fractional scales (an upstream limit no client flag fixes). winpodx now **detects the per-monitor scales** (`kscreen-doctor` on KDE, `swaymsg`/`hyprctl` on wlroots) and, when they differ, **pins the RemoteApp to the primary monitor** — so the app launches and stays responsive there — and logs the one real fix for using both monitors together: set them to the **same scale** (or, on KDE, Display → Legacy Applications → *Apply scaling themselves*, which makes XWayland hand xfreerdp a uniform pixel grid). With **uniform** scales `/span` is used exactly as before (a window moves freely across monitors). Single monitor / undetectable scales are unaffected; `cfg.rdp.multimon = "off"` forces single-monitor regardless. As a connect-time backstop, a launch that still dies with that pre_connect monitor error retries on a single-monitor desktop. +- **Terminate a running RDP session from the GUI and the tray (#450, #452, #453).** A **Running sessions** strip on the GUI Dashboard lists each live app session and terminates it on click; the tray gained a **Terminate Session** submenu. Terminating now signals the whole FreeRDP process group rather than just the leader (#458) and can force-kill a stuck session (#459); the tray also retries showing itself until the desktop's StatusNotifier host is up and re-asserts on its heartbeat so it survives a host restart (#455, #465). +- **The Dashboard resource centre now actually shows CPU / RAM / disk, and refreshes fast.** Three problems compounded on real machines: (1) `podman/docker stats` was queried by the configured container name, but podman-compose prefixes it (`winpodx_winpodx-windows_1`), so the probe hit "no such container" and every gauge stayed blank — the name is now resolved via ` ps --filter name=` first; (2) the CPU/RAM probe and the guest-disk probe ran sequentially, so a slow disk read stalled the whole snapshot — they now run concurrently with a hard cap, the disk is sampled on a slower cadence, and its last good value is cached instead of blanking between reads; (3) the disk probe fell back to a FreeRDP RemoteApp PowerShell that flashed a visible window in the guest on every poll — it's now **agent-only** (windowless `/exec`), so a passive dashboard never pops a console. Finally, rootless podman routinely reports CPU% (and sometimes MEM) as `--` in `stats`, leaving those gauges empty even with a healthy pod; winpodx now back-fills them by reading the container's **cgroup v2** files directly (`memory.current` for RAM, a `cpu.stat` `usage_usec` delta for CPU%), so CPU and RAM populate on the rootless setups where `stats` won't. Many rootless slices delegate only `cpu`/`pids` (not `memory`) to the user, so `memory.current` is absent even when `cpu.stat` reads fine — RAM then falls back to summing the `VmRSS` of every process in the container cgroup (kernel-accounted, controller-agnostic; the dockur QEMU process dominates), so the RAM gauge fills there too. +- **The Dashboard RAM gauge now reflects the guest's actual usage, and the panel paints faster.** Every host-side memory read (cgroup `memory.current` or summed process `VmRSS`) reported the *VM's* memory — for the dockur (QEMU) backend the host sees nearly all the guest RAM resident, so the gauge sat pinned near 100 %. RAM now comes from **inside Windows** via the guest agent (`Win32_OperatingSystem` total/free physical memory), the only figure that reflects what the guest is really using; it shares the disk probe's single agent round-trip (one `/exec` for both) and slower cadence, and the last value is cached so it doesn't blank between polls. CPU stays host-side but now reads the cgroup `cpu.stat` delta *first* (an instant file read) and only falls back to the multi-second `podman stats` sampling, so the panel populates without that stall on every tick. +- **The Dashboard now auto-refreshes from the moment the window opens.** The live-refresh timer was started only by the nav's *page-switch* handler, but the Dashboard is the default page shown at startup — no switch fires for it — so the panel painted once and then sat frozen until you navigated to another page and back. The timer now starts when the Dashboard is built, so resources and pod status update on their own from launch. +- **GUI pages no longer clip / grow a horizontal scrollbar on narrow or fractionally-scaled windows.** The "All apps" Pinned/Recent shelves were a non-wrapping row, so a long shelf forced the whole page wider than the viewport and clipped the app grid on its right edge (worst on hi-DPI fractional-scale displays). Every page's scroll area now pins the horizontal scrollbar off, the tile grids budget a touch more width per tile so the last column can't overflow, and the Dashboard/All-apps shelves cap to the number of tiles that actually fit (everything stays reachable in the grid below) and re-flow on resize. +- **Settings combo boxes no longer change value when you scroll *past* them.** Hovering a drop-down while scrolling the Settings page used to silently change its value (scale, backend, edition, …). Combo boxes / spin boxes now ignore the wheel unless they're focused — click one first to scroll its value — and the wheel falls through to scroll the page instead. +- **Upgrades restart the running GUI / tray so the new version takes effect immediately (#467).** The installer gracefully restarts a running `winpodx tray` / GUI at the end of an upgrade (via `setsid`, so it outlives the installer); the pod keeps running. A no-op on fresh installs. +- **File sharing now works on Fedora Atomic / Silverblue / Kinoite (#418, #420).** There `/home` is a symlink to `/var/home`, which made the UNC-path conversion report home-folder files as "outside shared locations". The home and media base paths are now resolved to their real targets before the comparison. +- **Setup verifies the container backend daemon is actually reachable, not just installed (#395, #419).** A podman / docker CLI on `PATH` backed by a dead daemon (e.g. a stale `DOCKER_HOST`) used to pass the dependency check and then fail at pod start; the check now probes reachability and reports a clear DAEMON DOWN status. +- **`manual` backend now reaches the guest agent at the VM's address instead of loopback (#426).** The agent client always targeted `127.0.0.1:8765`, even when the Windows machine lived elsewhere — so on the `manual` backend pointed at a VM (e.g. a VMware guest at `LTSC11P.local`), RDP worked but every agent-backed feature fell back to FreeRDP-only, and `winpodx check` reported `agent_health` unreachable even though the agent answered fine at the VM address. `AgentClient` now derives its host from `cfg.rdp.ip` — the same address the RDP reachability check uses — so it follows the VM on `manual` and stays on `127.0.0.1` for podman/docker (where the container publishes 8765 to host loopback and `cfg.rdp.ip` is already loopback). No per-call-site changes; one fix in the client constructor. +- **Reverse-open's Windows shim is no longer quarantined by Microsoft Defender (#425).** `winpodx-reverse-open-shim.exe` (a tiny, stripped, unsigned Rust binary) trips Defender's ML heuristic as `Trojan:Win32/Rafvartar!rfn` — a false positive. `install.bat` already excluded `C:\OEM` and `C:\winpodx`, but `register-apps.ps1` stages the shim and its per-slug `winpodx-.exe` copies under **`C:\Users\Public\winpodx`**, which wasn't excluded — so Defender quarantined them and reverse-open silently broke. The first-boot Defender-exclusion step now also covers `C:\Users\Public\winpodx` and the `winpodx-reverse-open-shim.exe` process. The shim is also now built with an embedded Windows VERSIONINFO resource (CompanyName / ProductName / FileDescription / version via a `winresource` build script) — a metadata-less stripped PE scores worse on AV heuristics, so stamping real publisher info is a cheap legitimacy signal that the per-slug copies inherit. (Longer-term the shim should be Authenticode-signed / submitted to Microsoft as a false positive; the exclusion is the turnkey fix for winpodx's own guest.) +- **The Setup tab's "Enable reverse-open" checkbox now starts/stops the listener live (#425).** Ticking the box only flipped `cfg.reverse_open.enabled` and refreshed the status label — the daemon stayed down until the next pod bringup, so users had to also click "Start daemon." The checkbox now starts the listener immediately when enabled and stops it when disabled (and persists the flag). It's best-effort + quiet: if the guest isn't up yet, the flag still persists and the listener comes up on the next pod start, with no error modal for that expected case. +- **Fresh install no longer hangs forever at `wait-ready` on hosts where dockur picks bridge-NAT networking (#269, #387).** The agent on guest port 8765 was unreachable from the host after Windows booted — `wait-ready` polled it indefinitely while noVNC showed a finished desktop. Root cause: the compose set `USER_PORTS: "8765"` but left dockur to auto-select its network mode. On hosts where dockur's default **bridge-NAT** path succeeds (typically rootful backends), that path *silently ignores `USER_PORTS`*, so QEMU never forwarded 8765 to the guest (the Podman host↔container publish was fine; the container↔guest hop was the gap). dockur only consults `USER_PORTS` in its user-mode (passt) / slirp paths — which is exactly why rootless hosts were unaffected (rootless can't build the bridge, so dockur already fell back to passt and forwarded the port). The compose now pins `NETWORK: "user"` so dockur always uses the port-forwarding user-mode path regardless of host. winpodx only ever reaches the guest over forwarded ports (RDP 3389, the web viewer 8006, the agent 8765) and never needs the guest on the host LAN, so user-mode is the correct mode here; on rootless hosts the change is a no-op (they were already on passt). +- **`install.sh` no longer blindly proceeds with a too-old podman in Recommended mode (#271).** Automatic mode already walked a `podman < 4` host (Ubuntu 22.04 ships 3.4) down to docker / libvirt, but Recommended mode and an explicit `--backend podman` skipped that fallback — so the install ran to completion and only failed later at provisioning, *after* installing packages. A guard now runs right after the backend is resolved (before any package install): when the chosen backend is podman but podman is too old, an interactive run offers to switch to an installed docker / libvirt (or continue at your own risk / abort), and a non-interactive run exits cleanly **without modifying the system**, pointing at `--backend docker|libvirt`, a podman upgrade, or `--allow-old-podman` / `WINPODX_ALLOW_OLD_PODMAN=1` to force. This completes the "graceful exit" part of #271 (the distro/version check and the runtime selector already shipped in the `install.sh` v2 rework). +- **Upgrades no longer prompt for an install mode or replay the old first-boot log.** Two upgrade/re-run UX warts: (1) `install.sh` showed the `[R]ecommended / [A]utomatic / [C]ustom / [N]o` mode menu even when a winpodx config already existed — pointless, since an upgrade reuses the existing backend/config and runs `migrate`. It now detects the existing install and resolves to Automatic without prompting. (2) `pod wait-ready --logs` (used by the upgrade's migrate path) tailed `podman logs --tail 100`, which on an already-running container replays the *original* first-boot ISO-download + image-build output — under `--verbose` that looked alarmingly like Windows was being re-downloaded on every update. wait-ready now replays no history (`--tail 0`) when the pod is already up (RDP reachable), keeping `--tail 100` only for a genuine fresh boot where the in-progress download is worth showing. +- **The Flatpak FreeRDP now does per-app RemoteApp instead of opening the full desktop.** `flatpak run com.freerdp.FreeRDP` runs the app's *default* command — the **SDL** client, which has no RAIL (FreeRDP #9078) — so launching a single app via the Flatpak opened the whole Windows desktop / login screen instead of one app window. winpodx now invokes the Flatpak as `flatpak run --command=xfreerdp … com.freerdp.FreeRDP`, forcing the X11 `xfreerdp` binary (the only client with working RAIL), and grants the sandbox the holes every winpodx RDP flag needs so the Flatpak behaves like a native client: `--share=network` (localhost RDP), `--socket=x11`/`--socket=wayland` (RAIL + clipboard), `--socket=pulseaudio` (sound), `--socket=cups` (printer), `--device=dri` (display), and `--filesystem=home` + the removable-media mount roots (`\\tsclient\home` + `\\tsclient\media` drive redirection). Surfaced after 0.6.0 started preferring the Flatpak when present. +- **The install-mode menu (R/A/C/N) now works under `curl … | bash`.** The interactive mode prompt — and the Custom-mode backend/GUI sub-prompts and the dependency-install confirm — were gated on `[ -t 0 ]` and read from stdin, so under the canonical `curl … | bash` install (where stdin is the script pipe, not a terminal) they were silently skipped and the install always defaulted to Recommended. install.sh now also detects a reachable controlling terminal via `/dev/tty` and reads every prompt from it, so a `curl … | bash` run in an attended terminal shows the mode menu and lets you choose (the same `/dev/tty` trick the live progress line already uses for output). Fully non-interactive runs (CI / cron / stdin and `/dev/tty` both absent) stay non-interactive and default to Recommended, and `--mode` / `WINPODX_MODE` still preselect without prompting. +- **Fresh-install provisioning now waits for a flaky guest agent until it actually recovers, instead of giving up on a timer.** The agent-first settle gate and the discovery stage used to give up after a fixed window (a ~30 s settle ceiling; 2 discovery retries over a few seconds), so when the in-guest agent flickered down right after the first-boot reboot — it can lag up to a keepalive-watchdog cycle (~60 s) — provisioning deferred and the install only finished later via `winpodx app refresh`. Both stages now wait **with no time cap, gated on pod liveness**: as long as the pod is `RUNNING`, the `WinpodxAgentKeepAlive` watchdog will bring the agent back, so they poll `/health` and proceed once it is stably up (settle requires 3 consecutive OK) — completing discovery **in-line** however long the agent takes. The wait ends only if the pod itself stops (no watchdog → genuinely unrecoverable), the same real-signal-bounded philosophy as the wget-ETA dynamic deadline in `pod wait-ready` (#126) rather than an arbitrary duration. The bounded retry budget (`--retries`, 0.6.0 item M) now applies only to non-agent transient discovery errors. `winpodx migrate` (the upgrade path) gets the same patient recovery. +- **A deferred fresh install no longer rolls itself back.** On a fresh `install.sh` run where Windows downloaded + booted fine but the agent-first discovery deferred (`winpodx provision` exit 5, e.g. the in-guest agent lagging a minute after the first-boot reboot), install.sh rolled back the entire install — throwing away ~15 minutes of ISO download + boot for a state that finishes itself. Root cause: bash fires the `ERR` trap on a failing *pipeline* even under `set +e`, so the rollback trap fired on the provision pipe before the rc-handling (which already treats a non-zero provision as "record pending, keep install") could run. The provision call now explicitly disarms the `ERR` trap (`trap - ERR` / re-arm after) instead of relying on `set +e`, and exit 4 (wait-ready ran long) / exit 5 (discovery deferred) are handled as an explicit **deferred-not-failed** case: the remaining steps are recorded as pending (they auto-resume on the next `winpodx` run, or `winpodx app refresh`) and the install is kept. A genuine early failure (deps / venv / container creation) still rolls back via the trap as before. +- **Install / provision now lists the apps it registered, not just a count.** The discovery stage's shared `_register_desktop_entries` printed only `Registered N app(s) in your desktop menu.` — during `winpodx provision` / `install.sh` / `winpodx migrate` the user never saw *which* apps landed, even with `--verbose`. It now lists each registered app name (matching `winpodx app refresh`), and when the discovery count and the registered count differ — discovered apps that have no bundled profile can't be registered — it prints a one-line `Note: N discovered app(s) had no bundled profile and were not added to the menu: …` so the gap is explained instead of silent. +- **`migrate --verbose` reached only one of its two apply paths (regression of the just-shipped verbose fix).** `run_migrate`'s cross-version upgrade branch called `_apply_runtime_fixes_to_existing_guest(non_interactive)` without `verbose=verbose`, so on a real version-crossing upgrade — exactly the path `install.sh` drives with `migrate --non-interactive --verbose` — the raw container firehose was still dropped (the `--verbose` local was dead on that branch). Both call sites now thread `verbose=verbose`. +- **The Windows-exec wrapper script is no longer written world-readable.** `run_in_windows()` wrote `~/.local/share/winpodx/windows-exec/.ps1` (and created its directory) at the process umask (commonly `0644` / `0755`). On the FreeRDP-fallback password-rotation path that script embeds the freshly-rotated cleartext Windows password, so it was briefly persisted readable by any other local user. The directory is now `0700` and the script `0600`, matching how the agent token, `compose.yaml`, config, and the rotation marker are already written. +- **The `.cproc` session lock is no longer briefly truncated to empty while a session launches.** `launch_app` opened the PID lock file with mode `"w"` (truncating it to zero bytes) before the FreeRDP child PID was known, leaving a window where a concurrent reader — `list_active_sessions` / the idle monitor — could see an empty file and unlink the live session's lock as corrupt. The lock fd is now opened without `O_TRUNC`; the PID is written as a single `ftruncate`+`write`+`fsync` mutation under `flock` once the child exists, so a reader only ever sees the prior or the new PID, never empty. +- **Discovered Windows executables with a comma in the path can no longer inject a FreeRDP `/app:` sub-key.** The FreeRDP-3 `/app:program:,name:` arg interpolated `app_executable` raw, while the adjacent `default_args` and the UWP AUMID were already sanitised/validated against exactly this. `app_executable` now gets the same comma→space treatment before the combined arg is built. +- **A failed pod resume now fails fast with a clear message instead of a long opaque RDP timeout.** `ensure_pod_awake` ignored `resume_pod`'s result, so a pod that didn't actually unpause was handed straight to `launch_app`. It now re-checks `is_pod_paused` after the resume attempt and raises `ProvisionError("failed to resume paused pod …")` when the pod is still paused. +- **First-boot discovery keeps a retry that fixes the UWP gap even when the total app count is unchanged.** `discover_apps`'s retry-on-empty only kept the retry result when it had strictly more apps total, so an equal-size retry that recovered the missing UWP (Store) apps — the exact signal that triggered the retry — was discarded. It now also keeps the retry when it has more UWP entries. +- **The AppImage no longer ships a bundled container stack that fights the host — Thin AppImage redesign (#357, #363).** The pre-0.6.0 fat AppImage bundled the entire podman stack (podman + podman-compose + conmon/crun/netavark/aardvark-dns/pasta/passt/slirp4netns + transitive `.so` deps) into `${APPDIR}/usr/bin` and `${APPDIR}/usr/lib`, with the entrypoint prepending both to `PATH` and `LD_LIBRARY_PATH`. That broke every host that already had a working podman: **#357, Ubuntu 26.04** — `podman-compose` resolved to the bundled copy, which probed for the bundled `podman` (can't run standalone: no host `/etc/containers` config, no subuid/subgid, no systemd integration) and died with `it seems that you do not have podman installed`, hiding the host's working podman 5.7 + podman-compose 1.5; **#363, Fedora Bluefin** — podman shelled out to the *host* `systemd-run` for rootless aardvark-dns, but the prepended `${APPDIR}/usr/lib` forced that host binary to load the AppImage's bundled `libcrypto.so.3` → `OPENSSL_3.4.0 not found (required by host libsystemd-shared)`, so aardvark-dns failed and container start died. PR #365's `_hostenv` helper patched around the symptoms; 0.6.0 item A **removes the root cause** by dropping the entire container stack from the AppImage. The AppImage now bundles only what is safe to bundle (FreeRDP 3, Python, Qt, winpodx — leaf binaries that don't spawn host helpers) and requires a host container runtime (`podman` recommended, `docker` / `libvirt` supported) installed via the distro package manager, same model as `install.sh`. Dropping the container stack on its own only saved ~20 MB, though (≈296 MB fat → ≈274 MB): the real bulk is PySide6, which bundles the entire Qt6 stack (QtWebEngine alone is ~195 MB) while winpodx links only QtCore/QtGui/QtWidgets/QtSvg/QtDBus, so a companion pass (`packaging/appimage/slim-pyside6.sh`) strips the unused Qt6 modules + their plugins/resources + the now-orphaned FFmpeg libs, bringing the AppImage to **~110 MB**. `_hostenv` collapses to a single job — strip `${APPDIR}` from `LD_LIBRARY_PATH` for every container-backend subprocess so the host runtime + the host helpers it spawns load HOST libcrypto / libssl, not the bundled ones still on the AppImage's own `LD_LIBRARY_PATH` (the #363 mitigation that survives because bundled FreeRDP / Python / Qt still need those bundled libs). `host_path()` and `resolve_backend_bin()` are gone — nothing in `${APPDIR}/usr/bin` shadows the host container runtime anymore, so standard `subprocess` PATH resolution finds the host `podman` / `docker` directly. A regression test (`tests/test_appimage_recipe.py`) fails CI the moment the recipe re-introduces any of `podman` / `podman-compose` / `conmon` / `crun` / `netavark` / `aardvark-dns` / `passt` / `pasta` / `slirp4netns` / `fuse-overlayfs`. #357 and #363 are root-cause-fixed but stay open until the original reporters smoke-verify the rebuilt AppImage on the next 0.6.0 release. Outside an AppImage this is a strict no-op (same binaries, environment inherited unchanged), so the ~99% of non-AppImage installs are byte-for-byte unaffected. See `docs/design/ROADMAP-0.6.0.md` item A. +- **The guest agent now survives session churn instead of dying until the next reboot.** The in-guest HTTP agent (`agent.ps1`, the `:8765` listener the host uses for windowless `/exec`) had exactly one autostart: an `HKCU\Run` entry that fires once per interactive logon, with the agent running as a child of the autologon session. When that session was torn down — RDP single-session enforcement kicking it when a FreeRDP connection arrives before rdprrap multi-session is active, or the TermService cycle during rdprrap (re)activation — the agent died with the session and `HKCU\Run` did **not** re-fire, so the agent stayed dead until a pod reboot (`/health` timed out on a pod up for hours; `pod restart` revived it). A new **`WinpodxAgentKeepAlive` scheduled task** is now the persistent watchdog `HKCU\Run` never was: it runs an idempotent `agent-keepalive.ps1` **AtLogOn and every 1 minute**, which (re)launches the agent via the existing `hidden-launcher.vbs` wrapper (no console flash) **only when no `agent.ps1` process is running** — it never kills a healthy agent. The task runs as the **interactive autologon user** (not SYSTEM/S4U) so the agent's `/exec` keeps the user's HKCU + Start Menu context that app discovery and per-user reverse-open registration depend on; the World-SID `:8765` urlacl reservation and the World-readable `C:\OEM\agent_token.txt` stay reachable unchanged. It's registered at OEM time (install.bat, OEM bundle bumped 25 → 26) **and** staged by the apply chain (`winpodx pod apply-fixes` / migrate / guest-sync) so already-provisioned pods get it without a container recreate. A crashed-but-session-alive agent is back within ~1 min; the case of a session kick with no re-logon is prevented by the already-idempotent rdprrap activation (the keep-alive's 1-minute repetition is also the backstop after a TermService cycle settles). +- **`install.sh` now installs the reverse-open icon dependencies, so far fewer apps get a placeholder icon.** The `curl | bash` installer runs winpodx under the system `python3` without a venv, so winpodx's declared icon deps — `cairosvg` (SVG → PNG) and `pyxdg` (full freedesktop icon-theme resolution) — weren't present, and every Linux app whose icon is an SVG or lives in a non-Hicolor theme (Papirus, breeze, …) fell back to a generic placeholder in the Windows "Open with" menu (e.g. 13 of 18 placeholders in one smoke were just missing `cairosvg`). The installer now installs `python3-cairosvg` + `python3-pyxdg` via the distro package manager when they're missing — best-effort and non-fatal (apps still launch; the feature degrades to a placeholder if the packages or sudo aren't available). The AppImage already bundles both, so it was never affected. +- **Guest sync no longer flashes console windows or strands the post-upgrade chain.** When guest sync first actually fires (e.g. a 0.5.8 → 0.5.9 upgrade — stamp older than host), its OEM-pull / urlacl / agent-restart steps went through FreeRDP RemoteApp (`run_in_windows`), which pops a visible PowerShell/console window per call — the console-flash regression. They now ride the windowless agent `/exec` channel (`run_via_transport`) like the rest of guest sync; the agent is up for all three (the restart's `/exec` returns before its scheduled task kills the agent). Separately, the agent restart was fire-and-forget, so the install's downstream work (migrate apply chain, app discovery, reverse-open) raced the relaunch, found the agent unreachable, and degraded to a pending-resume. `sync_guest` now waits — generously, bounded — for the agent's `/health` to answer again after the restart before returning, so those steps see a live agent. Pending-resume stays the backstop if the relaunch is unusually slow. +- **Reverse-open icons that only ship as a multi-colour XPM (e.g. veracrypt) no longer fall back to a blank placeholder.** Pillow's bundled XPM decoder only handles one char per pixel (≤256 colours), so veracrypt's icon — its *only* icon is `/usr/share/pixmaps/veracrypt.xpm` at 1770 colours / 2 chars per pixel — raised `KeyError` and the app got a generic placeholder in the Windows "Open with" menu. winpodx now decodes such XPMs with a small **pure-Python** reader (parse the colour table + pixel rows, build the RGBA image directly), so those apps get their real icon. No new dependency and no external tool — it works identically on every install method including the AppImage. The remaining placeholder case is genuinely unavoidable (an app that ships no icon at all). + +### Contributors + +Thanks to everyone who reported the issues addressed in this release: @ismikes (#269, #357, #387, #393, #450), @vlombardino (#271), @vkkindia (#286), @vw72 (#319), @jmayniac (#363), @MirzaAyBaig12 (#366), @urbantigerau (#395), @notnotno (#418, #425), @sundaysfantasy (#426), @mhmdzaien (#473), @nemonein (#474) — and @ismikes for a code contribution (#423). + +## [0.5.9] - 2026-05-27 + +### Highlights + +**Multilingual UI, self-growing disk, and a guest that updates without a reinstall.** + +- The whole tray / GUI / CLI is now translatable and **fully translated to 7 languages** (en/ko/zh/ja/de/fr/it) — picked from your system locale, switchable with `winpodx language` or the GUI dropdown (fixes the stray-Korean reports, #335). +- The Windows C: drive **grows on its own** when it fills up — and can be grown by hand with `winpodx pod grow-disk` — including past the WinRE recovery partition dockur leaves behind (#318). +- **Guest sync**: upgrading winpodx on the host now pushes the new agent / fixes / binaries into a running guest with no wipe-reinstall (`winpodx pod sync-guest`, automatic on pod start). +- **Pod auto-start on login** is now a real, opt-in feature (`winpodx autostart on`). +- Fixes: USB shortcut erroring with no media mounted, RemoteApp painting the logon screen (#332), AppImage `podman-compose ModuleNotFoundError` on atomic distros (#322), and a too-tight install agent-wait that gave up early. + + + +### Added + +- **Selectable, fully translated UI — 7 languages (en/ko/zh/ja/de/fr/it).** winpodx's own tray / GUI / CLI text is now translatable, and all ~814 user-facing strings are wrapped and **fully translated to all six non-English languages** (placeholders, backtick command snippets, and product names preserved verbatim). `[ui] language` (default `auto`) picks the language from the host locale (`$LANG`) and falls back to English; override it from the CLI (`winpodx language ko`, or `winpodx language` to show current) or the **GUI Settings → winpodx UI language** dropdown (applies on next launch). English is the **source/baseline** — always 100% complete — and any string a future language hasn't translated falls back to English (no blanks). Catalogs live at `winpodx/locale/.json` as flat `{english: translation}` maps, so adding a language is a file drop with no code change. Addresses the stray-Korean reports (#335). +- **Pod auto-start on login (opt-in).** `cfg.pod.auto_start` was a stored-but-unused flag; now it works — but it's **off by default and explicitly opt-in** (booting Windows on every login is heavy, so a plain install never forces it). Turn it on with `winpodx autostart on` (or the GUI Settings checkbox): that installs the tray autostart entry *and* sets `auto_start`, so the tray — launched on login — starts the pod (or resumes it if suspended). `winpodx autostart off` / `status` manage it. Background + best-effort (a failure just leaves the pod stopped, logged; never crashes the tray); no-op when already running. +- **Guest sync — apply host updates to a running guest without a reinstall.** Upgrading winpodx on the host used to leave the guest's `agent.ps1`, urlacl reservation (#269), rdprrap/shim binaries, and registry fixes stale until a wipe-reinstall. `/oem` is a live bind mount of the host's `config/oem`, so after a host upgrade the container already has the new files; winpodx now delivers them into the running guest (same channel as `pod recover-oem`, but automated over the agent `/exec`), re-applies the idempotent fixes, restarts the agent (via a one-shot scheduled task so it doesn't kill the `/exec` it's serving), and stamps the guest version (`C:\winpodx\install-state\guest_version.json`). Runs automatically once per pod start when the guest stamp is older than the host (`guest_autosync`, default on); also triggered by `winpodx migrate` (so a package / AppImage / flatpak upgrade — whose update path never runs `install.sh` — gets the full guest refresh from a single command), manual via `winpodx pod sync-guest [--force]`, and a GUI **Tools → Sync Guest** action. No agent change. See `docs/design/GUEST_SYNC_DESIGN.md`. +- **Windows disk auto-grow + manual grow (#318).** The Windows C: drive now grows on its own: when the system volume fills past `disk_autogrow_threshold_pct` (default 80%) and the pod is idle, winpodx grows the virtual disk *enough to restore `disk_autogrow_target_free_pct` free space* (default 30%, in whole `disk_autogrow_increment` steps — not a flat bump), recreates the container so dockur grows the image, then extends C: to fill via `Resize-Partition` — no manual Disk Management step. The grow is bounded by the host's free space (minus a safety reserve); `disk_max_size` is now an *optional* explicit ceiling (empty by default) rather than a fixed cap. The same operation is exposed manually: `winpodx pod grow-disk` (add one increment), `winpodx pod grow-disk 128G` (absolute target), `winpodx pod grow-disk --extend-only` (just extend C: into existing unallocated space), and `winpodx pod disk-usage` (size / free / used%). The GUI Tools page gains a **Grow Disk** action. Auto-grow runs only while idle so it never interrupts a live RemoteApp session, and the guest-side work reuses the agent `/exec` path (no agent change). Default-on; set `disk_autogrow = false` to manage size manually. Note: dockur has no online disk resize, so each grow recreates the container (a quick guest reboot) — winpodx schedules auto-grows during idle for exactly this reason. dockur's Windows layout puts a WinRE **Recovery partition right after C:**, so the grown space lands behind it and a plain `Resize-Partition` can't reach it; the extend step detects this, detaches WinRE, removes the blocking recovery partition, extends C: to fill, and re-enables WinRE (in `C:\Windows` when no dedicated partition remains). Reported by @drjwhitty (Linux Mint 22.2). + +### Fixed + +- **Upgrade re-install hung at `[4/4]` waiting for the OEM reboot pass.** Re-running `install.sh` over an already-provisioned guest froze `pod wait-ready --logs` at phase 4. Two causes: (1) `--logs --tail 100` replayed the *original* first-boot download log, and a stale wget ETA line inflated the wait deadline to tens of minutes; (2) phase 4 (`_wait_for_oem_reboot`) blocked for that whole inflated window when the agent was still transitioning, because the "marker never reappears → this is an upgrade" exit only fired on a clean probe, never while the agent was rejecting connections. Fixes: the deadline no longer extends once the container is running (any ETA line after that is replayed history); phase 4 is hard-capped at 180s (it's a marker poll, not a download); and the upgrade-path exit now triggers on the appear-grace clock regardless of probe outcome. +- **USB desktop shortcut errored when no removable media was mounted.** install.bat always drops a `USB` shortcut pointing at `\\tsclient\media`, but winpodx only redirected that drive when a removable-media base (`/run/media/$USER` etc.) existed — so clicking USB with nothing plugged in failed with "`\\tsclient\media` is not accessible … Attempt to access invalid address." winpodx now always redirects a `media` drive: the real base when mounted, otherwise an empty placeholder dir, so the shortcut opens to an empty folder instead of erroring. +- **Stray Korean text in the UI (#335).** The tray "Quit" confirmation dialog and the CPU/RAM tier preset labels (`Low`/`Mid`/`High`) were hardcoded in Korean, so an English-locale user got a Korean prompt on exit. Both are now English (winpodx's UI is English; a full selectable-UI-language i18n layer is planned separately). Reported by @camegone (CachyOS). +- **Install: agent-readiness wait is now dynamic + generous, with no misleading errors.** On a slow first boot (cold cache / slow disk / a long ISO download that pushes the whole install late) the post-RDP agent `/health` wait was capped at a hard 180s, so it gave up early — leaking a scary `agent didn't answer within 180s` WARN and then skipping apply-fixes / discovery even though the agent was just minutes from coming up. The wait now honors the caller's full (dynamic, ISO-ETA-extended) deadline — `pod wait-ready` phase 3 already passes the auto-extending deadline, so the agent gets as long as the install legitimately needs. Separately, the guest version stamp read/write now go through the windowless agent `/exec` channel (`run_via_transport`) instead of FreeRDP (#346) — so a transitioning agent on a fresh first boot fails clean and the stamp is re-attempted on the next pod start, rather than leaking a scary `FreeRDP timed out after 30s` / `ERRCONNECT_ACTIVATION_TIMEOUT` warning mid-install. The stamp is best-effort and surfaces at info level (*"deferred; will retry next start"*), never a warning. The post-install app-discovery retry budget was also raised from 3 to 6 attempts (~50s) so it outlasts the apply chain's TermService/rdprrap cycle instead of falling through to pending-resume on the next launch. +- **First-run setup prompt fired on every invocation (#341).** "winpodx has not been set up yet" is gated on an internal `initialized` flag, not on the config file existing. The non-interactive existing-config path printed "skipping setup" and returned *before* flipping that flag, so a config with it still `false` (created via install.sh's own skip path, or written before the flag existed) made the prompt loop forever — pick "Auto", setup short-circuits, prompt returns next time. The skip path now marks the install initialized too. Reported by @ntruhan (Fedora 44). +- **RemoteApp launched into the logon/lock screen → corrupted render (#332).** Apps sometimes came up showing the Windows login background instead of the app, with `xf_Pointer: Invalid appWindow` spam — the app *had* launched, but FreeRDP's RAIL window was created while the guest session was still transitioning through the logon/lock screen (dockur's autologon session briefly re-spawns), so FreeRDP painted the stale logon framebuffer and never repainted the app. Version-independent (reproduced on current FreeRDP). winpodx now waits — best-effort, **agent-only** so it never flashes a FreeRDP probe window — until the guest console is interactive (`explorer.exe` up, no `LogonUI.exe`) before firing the RemoteApp connection; if the agent is unreachable or it times out, the launch proceeds as before. Reported by @tolistim (Mint 22.3). +- **AppImage: `podman-compose` `ModuleNotFoundError` (#322).** The fat AppImage bundled Fedora's `/usr/bin/podman-compose` *launcher script* but not the `podman_compose` Python module it imports (it's pure Python, so `ldd` can't pull it in), and the script's shebang fell through to the host's `python3` — which lacks the module on atomic distros. Pod creation failed with `ModuleNotFoundError: No module named 'podman_compose'`. Now the build pip-installs `podman-compose` into the AppImage's bundled interpreter and ships a `usr/bin/podman-compose` wrapper that runs it via `python3 -m podman_compose` (a pip console script can't be used directly — its shebang is the dead build-time path, same reason AppRun uses `python3 -m winpodx`). Reported by @jmayniac (Bluefin / Fedora atomic). + +## [0.5.8] - 2026-05-24 + +Reliability + reach release: fixes the two big fresh-install failures users reported (#269 agent can't bind 8765, #287 install.bat never runs), ships a distro-agnostic fat AppImage, and rounds out the setup wizard so a standalone `winpodx setup` provisions end-to-end. + +### Highlights + +**Fresh-install reliability + a self-contained AppImage.** Two fresh-install dead-ends fixed, slow connections + slow hosts no longer time out, and a single-file AppImage now bundles FreeRDP + Podman for immutable distros. + +- **#269 fixed** -- the agent's `http://+:8765/` urlacl reservation is now created for the World SID so the non-admin guest User can bind it; agent.ps1 retries + logs the urlacl state on failure (thanks @ismikes). +- **Fat AppImage (#227)** -- `winpodx-fat-x86_64.AppImage` bundles Python + Qt + FreeRDP + Podman + podman-compose; `winpodx setup-host` handles the host-side kvm-group / subuid bits via a single pkexec prompt (requested by @leandromqrs). +- **Dynamic + generous timeouts (#126)** -- `pod wait-ready` auto-extends from dockur's download ETA, compose-up is activity-based, and every fixed timeout was widened so slow links / hosts don't fail mid-op (reported by @xiyeming). +- **`winpodx pod recover-oem` (#287)** -- re-stages `C:\OEM` + runs install.bat when dockur's first-boot OEM copy fails (driver @ankranidiotis). +- **Setup wizard completion (#255)** -- `--customize` walks edition / language / region / keyboard / tuning, and a standalone `winpodx setup` now runs the full provision flow (wait-ready + discovery + reverse-open) instead of stopping at "container created". +- **`podman-compose` required up-front (#288)** -- no more silent `keep-groups` failure when `podman compose` delegates to docker-compose (reported by @magicdiablo). + +### Added + +- **`winpodx pod recover-oem` -- manual OEM recovery for failed dockur first-boot copy (#287).** When dockur's `$OEM$ -> C:\OEM` copy silently fails (so `install.bat` never runs, the agent never installs, and port 8765 RSTs), this command tars `/oem` out of the container, serves it over a short-lived HTTP server on container port 8766, and prints the exact noVNC PowerShell to download + extract + run `install.bat` from the full OEM tree. podman/docker only. Driver: @ankranidiotis (Linux Mint 22, Podman 4.9.3). +- **`pod wait-ready` dynamic deadline + `winpodx setup-host` (#126, #227).** `pod wait-ready` parses dockur's wget ETA and extends its deadline (no upper bound -- a genuinely stalled download stops producing ETA lines and the wait expires naturally; +60min slack absorbs blips) so slow ISO downloads don't trip the 60min default. `winpodx setup-host` (also `python -m winpodx.setup_wizard`) detects + pkexec-fixes the host bits an AppImage can't do from user space: `kvm` group membership, `/etc/subuid` + `/etc/subgid` for rootless podman, persistent kvm module load. Reported by @xiyeming. +- **Fat AppImage with bundled FreeRDP + Podman + pkexec wizard (#227).** Initial lean AppImage in #302 (Python + winpodx + Qt only) layered up to a fat bundle in this release: `packaging/appimage/` recipe downloads a python-build-standalone portable Python 3.11, installs the winpodx wheel + `gui` + `reverse-open` extras into it, then overlays a Fedora 41 binary bundle (xfreerdp / wlfreerdp / sdl-freerdp, podman, podman-compose, conmon, crun, netavark, slirp4netns, passt, pasta) with all transitive `.so` deps that aren't on the host-critical exclude list (glibc / libX11 / libGL / libwayland / libxkbcommon stay on host -- bundling them would break desktop integration or hard-crash on glibc mismatch). Single `winpodx-fat-x86_64.AppImage` (~290 MB squashfs / ~920 MB AppDir) self-contained for everything user-space can carry. New `winpodx setup-host` subcommand (plus `python -m winpodx.setup_wizard`) wraps the host-side bits that AppImages legitimately can't do from user space -- `kvm` group membership, `/etc/subuid` + `/etc/subgid` for rootless podman, persistent kvm module load -- in a single `pkexec` polkit prompt. Target audience: immutable distros (Fedora Silverblue / Kinoite / Aeon, Steam Deck) where layering system packages is heavy or unavailable, plus locked-down environments where `curl install.sh | bash` isn't an option. CI bypasses python-appimage entirely (its bundled appimagetool runs without honouring our PATH wrapper and silently fails on FUSE-less GitHub runners); the new pipeline builds the AppDir layout from scratch, layers the binary overlay via a Fedora 41 docker container, and packs with a preinstalled extract-and-wrap `appimagetool` at `/usr/local/bin`. The bundled third-party license texts (FreeRDP / Podman / podman-compose / ...) are vendored in `packaging/appimage/licenses/` so they travel inside the AppImage independent of CI's `dnf` nodocs policy, and the build fail-closes if a required dir is absent. README + INSTALL.md (en + ko) updated. Driver: @leandromqrs. +- **Setup wizard completion -- `--customize` prompts + full standalone provision (#255).** The wizard now prompts for Windows edition, UI language, regional format, keyboard layout, and tuning profile (previously CLI-skipped, GUI-only). A standalone `winpodx setup` runs the same post-create flow install.sh drives -- wait-ready, apply-fixes, app discovery, reverse-open -- so it finishes like a complete install. New `--create-only` flag keeps install.sh's create-then-orchestrate path intact. +- **CLI + GUI first-run setup prompt (#255 PR 1).** When `cfg.pod.initialized` is False (or no config exists), the first `winpodx ` invocation surfaces a three-way prompt: `[Y]es` (auto -- host-detected defaults, no prompts), `[C]ustom` (wizard -- pick every knob), `[n]o` (skip). Skip-list bypasses introspection / config / uninstall / gui / tray commands and non-TTY stdin. GUI shows the same modal on first launch. After a successful setup, `initialized` flips to True and the prompt stops firing. +- **`winpodx setup --customize` flag (#255 PR 1).** Opts into the wizard mode (existing interactive prompts; full multi-step wizard with debloat / tuning / anti-detection knobs lands in PR 7). Default `winpodx setup` is now non-interactive (host-detected defaults). +- **`install.sh --manual` flag (#255 PR 2).** Skips `winpodx setup` + `pod wait-ready` + app discovery + reverse-open setup. Binary + desktop entry + icon land normally; provisioning is deferred to the first-run prompt that fires on the next `winpodx` invocation (CLI Y/C/n or GUI modal). Useful when you want to pick custom knobs (edition / language / debloat / tuning / anti-detection) without sitting through the auto path first. Equivalent: `WINPODX_MANUAL=1` env var. `docs/INSTALL.md` + Korean mirror gain a "Manual install" section. +- **`winpodx doctor` subcommand (#255 PR 6).** Read-only diagnostic. Walks ~9 checks (install source detected, freerdp present, /dev/kvm present, backend on PATH, config + binary state, container health, pending setup marker, autostart entry integrity, `cfg.pod.initialized` flag) and prints per-check `[OK]` / `[WARN]` / `[FAIL]` findings with the suggested next command. Read-only -- never mutates state; users copy the suggestions. Exit 1 when any FAIL finding; useful in CI / install scripts as a quick health gate. Designed to be cheap (< 2s on a healthy install): every subprocess probe has a short timeout, no network calls. +- **`winpodx setup` registers the GUI launcher `.desktop` (#255 G7).** Standalone `winpodx setup` invocations (pip install, dev checkout, or any path where install.sh / packaging didn't drop the GUI entry) now copy `data/winpodx.desktop` to `~/.local/share/applications/winpodx.desktop`, mirroring what install.sh does for curl installs. Skips when `/usr/share/applications/winpodx.desktop` already exists -- package installs own that path and a user-level copy would shadow the package version on upgrade. `install_gui_launcher_desktop()` lives next to `install_winpodx_icon()` in `winpodx.desktop.icons`. +- **`winpodx uninstall` consolidated to a single bash script (#255 follow-up).** The pre-this-PR #255 PR 3 landed a ~470-line Python implementation in `winpodx.cli.uninstall` alongside the existing ~300-line `uninstall.sh`. Two parallel implementations drifted (10+ cleanup steps diverged: MIME cache, gtk-update-icon-cache, guest-registry scrub, final pkill sweep, volume glob, storage bind-mount wipe, GUI launcher .desktop removal, ...), repeatedly producing bin/winpodx-symlink-survives-purge bugs (#278 / #279). Consolidation: `uninstall.sh` is now the canonical implementation. `winpodx uninstall` is a ~50-line Python wrapper that locates `uninstall.sh` (in `/usr/share/winpodx/`, `~/.local/bin/winpodx-app/`, `~/.local/share/winpodx/`, `sys.prefix/share/winpodx/`, or the dev checkout) and `os.execvp`'s it -- so uninstall keeps working even when the Python install is half-broken. The same `uninstall.sh` now ships in every channel (debian/winpodx.install, rpm %files, AUR PKGBUILD package(), pyproject shared-data for pip wheels, curl install bundle dir) at one well-known path: `/usr/share/winpodx/uninstall.sh`. Install-source detection (dpkg / rpm / pacman) moved into bash; if a package manager owns the binary, the script prompts to run `sudo apt remove` / `sudo dnf remove` / `sudo pacman -Rns` *first*, then re-enters the same script via the package's postrm hook with `--from-postrm` to do user-side cleanup -- the only ordering that keeps dpkg/rpm db consistent with disk state. `postrm-common.sh` simplified: delegates to `/usr/share/winpodx/uninstall.sh --from-postrm --yes [--purge]` instead of calling the Python CLI. Flags normalised: `--yes` (was `--confirm` -- kept as silent alias), `--purge`, `--from-postrm` (internal, set by postrm hooks). The `--no-package-prompt` flag introduced by PR 3 is removed (its behaviour folds into `--from-postrm`). Tests: deleted `tests/test_uninstall_consolidation.py` (Python helpers no longer exist); added `tests/test_uninstall_wrapper.py` (10 tests: candidate-path coverage, execvp argv forwarding, SystemExit on script-not-found) and `tests/uninstall_smoke.sh` (sandbox $HOME smoke that asserts every expected path is gone post-run, shellcheck-clean). +- **`uninstall.sh` detects pip / source installs (#255 G6).** Previously fell through to `unknown` -- pip users saw no hint. Now identifies site-packages or dev-checkout paths and prints `pip uninstall winpodx` after user-side cleanup as the canonical removal command for that install kind. Detection is heuristic only (the script doesn't know the venv prefix to drive `pip uninstall` directly), so the hint is informational, not auto-exec'd. +- **Packaging post-install / post-remove hooks across debian / rpm / aur (#255 PR 4).** Every package install path now ends with a banner pointing at `winpodx setup` (or just opening winpodx -- first-run prompt covers it). Every package remove path runs a tiny cleanup that pkill's tray / GUI / helper processes + stops the reverse-open listener via `winpodx host-open stop-listener`, so users aren't left with orphan processes after `apt remove` / `dnf remove` / `yay -Rns`. debian's `apt purge` additionally runs `winpodx uninstall --purge --yes --no-package-prompt` per user with a winpodx config dir, matching `apt purge` semantics. rpm and aur lack a purge concept; their post-remove hooks tell users how to do the full wipe (`winpodx uninstall --purge --yes`). Shared logic lives in `packaging/scripts/postrm-common.sh`, installed to `/usr/share/winpodx/packaging/postrm-common.sh` by every distro. New files: `debian/postinst`, `debian/postrm`, `debian/winpodx.install`, `packaging/aur/winpodx.install`. Modified: `packaging/rpm/winpodx.spec` (%post + %postun added), `packaging/aur/PKGBUILD` (install=winpodx.install + scripts install), `.github/workflows/aur-publish.yml` (push winpodx.install alongside PKGBUILD). +- **`winpodx --version` install-source suffix.** Output now reads `winpodx 0.5.8 (installed via apt)` / `(curl install)` / `(pip install)` / `(install source not detected)` so users see provenance at a glance. +- **`winpodx info [System]` install-source line.** Same detection surfaced in the info command's System block alongside winpodx / OEM bundle / rdprrap / distro / kernel lines. +- **`winpodx.utils.install_source`** -- new helper. Detects install provenance via `dpkg -S` / `rpm -qf` / `pacman -Qo` (3 s timeout each), with `~/.local/bin/winpodx-app` curl-install heuristic + `site-packages` source-checkout fallback. Returns `InstallSource(kind, label, package_name, removal_command)` for downstream uninstall / version / info / doctor consumers. +- **Extended Windows-on-KVM tuning profile (#245).** `auto` and `safe` now apply Hyper-V enlightenments (`hv-relaxed`, `hv-vapic`, `hv-vpindex`, `hv-runtime`, `hv-synic`, `hv-reset`, `hv-frequencies`, `hv-reenlightenment`, `hv-tlbflush`, `hv-ipi`, `hv-spinlocks=0x1fff`, `hv-stimer`, `hv-stimer-direct`) plus `virtio-rng-pci` backed by `/dev/urandom`. Significant scheduling / VM-exit win on multi-vCPU guests; entropy pool fills quickly so first-boot CryptoAPI / TLS handshakes don't stall. `auto` additionally exposes nested-virt CPU features (`+vmx` Intel / `+svm` AMD) and `hv-evmcs` (Intel) when `/sys/module/kvm_intel/parameters/nested` (or `kvm_amd`) reads `Y` -- enabling Hyper-V / WSL2 / Docker Desktop inside the Windows guest. No-op when the host kernel hasn't opted into nested KVM. `safe` excludes nested-virt + `hv-evmcs` (those need explicit host-side opt-in). New CLI override: `winpodx pod start --tuning {auto,safe,off,manual}` for one-shot per-run profile selection (does not persist to winpodx.toml). GUI Settings gains a Tuning Profile dropdown plus a live `format_tuning_summary()` panel showing what the resolved profile applies on this host. New `TuningCapability.nested_kvm` probe + `TuningProfile.apply_hv_enlightenments` / `apply_virtio_rng` / `apply_evmcs` / `apply_nested_virt` flags in `winpodx.utils.specs`. +- **`performance` tuning profile + Settings card relocation (#245 follow-up).** New profile that behaves like `auto` but bypasses the `dedicated_host` gate -- CPU pinning + no-balloon flip on regardless of current host idle CPU / free RAM. Use when the box is mostly dedicated to winpodx and you want minimum guest latency at the cost of other host workloads. Hard-gated knobs (`+invtsc`, `io_uring`) still respect capability detection so the profile can't force a CPU flag QEMU would reject. CLI: `winpodx pod start --tuning performance`. GUI: Settings now has a dedicated "Performance Tuning" card -- dropdown at top, `format_tuning_summary()` detection panel below in the same card frame. +- **Settings UI polish + design tokens.** Split the old "Container / VM" card (10 form rows, taller than the 7-row RDP card next to it) into a "Hardware" card (Backend / Edition / CPU / RAM / Idle / Max Sessions = 6 rows) plus a full-width "Localization" card (Language / Region / Keyboard / Timezone = 4 rows) so the top row reads as a height-balanced two-column layout. Added a design-token layer to `winpodx.gui.theme` -- `SPACE_XS/S/M/L/XL/XXL`, `RADIUS_XS/S/M/L/XL/XXL`, and `FONT_CAPTION/BODY/SUBHEAD/HEADER/TITLE/HERO` -- so the patchwork of magic numbers (`24, 22, 14, 12, 11, ...`) across cards, headers, summary panels, and form labels can converge on a single named scale. Settings page + tuning card converted to the new tokens; remaining pages will migrate in a follow-up. + +### Changed + +- **`winpodx setup` default flipped to non-interactive auto (#255 PR 1).** Previously interactive with host-detected defaults + a handful of prompts. Now defaults to fully auto (= existing `--non-interactive` behaviour). Use `winpodx setup --customize` for the wizard. The `--non-interactive` flag stays as a deprecated alias for install.sh and other scripted callers. +- **Timeouts widened across the board + activity-based compose-up (#298, #126).** Slow hosts / slow links no longer fail ops that are still making progress: `compose up` is now activity-based (kills only after 5min of silent output, not a 120s wall clock) with a 4h hard cap; `wait_for_windows_responsive` 90 -> 300s default / 180 -> 600s at call sites; agent `/health` + FreeRDP RDP-port probes 2 -> 5s; `transport.exec` / `run_in_windows` guest calls bumped 2-3x (sync-password 30 -> 90s, multi-session 20/45 -> 60/120s, etc.); `compose down` 60 -> 180s; daemon suspend/resume/inspect 30/10 -> 90/30s. Tight TCP poll-loop probes (`check_rdp_port` inside `pod_status`) intentionally left short -- the budget there is the poll interval. +- **CPU sub-flags moved from `ARGUMENTS` to dedicated `CPU_FLAGS` + `VMX` env vars; hv-* delegated to dockur.** Three problems collapse into one refactor: (1) PR #289's `-msg timestamp=on` workaround for dockur `proc.sh:137` bash slice on empty post-strip ARGUMENTS, (2) PR #281's hv-* enlightenments duplicating dockur's own `hv_passthrough` (default `HV=Y`) causing QEMU 10's `Ambiguous CPU model string. Don't mix both "-hv-evmcs" and "hv-evmcs=on"` warning, (3) PR #281's explicit `+vmx` / `+svm` nested-virt injection duplicating dockur's `VMX` env handling. winpodx now uses dockur's documented env-var interface: `CPU_FLAGS: "arch_capabilities=off,+invtsc"` on x86 (the two sub-flags dockur doesn't add itself), `VMX: "Y"` when nested-virt is wanted (dockur picks the right `+vmx` / `+svm` per host CPU). ARGUMENTS carries only the virtio-rng device pair now. proc.sh:137 strip code path is never triggered (no `-cpu host,` in ARGUMENTS) so the `-msg timestamp=on` marker is removed. The ambiguous-string warning goes away (we don't add `hv-evmcs` -- dockur owns the entire hv-* set). And winpodx is decoupled from QEMU sub-flag deprecation: when QEMU 11/12 changes hv-* syntax, dockur handles it, we don't. + +### Fixed + +- **Agent 8765 bind failure -- urlacl reserved for the wrong owner (#269).** install.bat ran to completion (RDP listener up, HKCU\\Run registered, agent spawned) but the agent FATAL'd on every boot: `HttpListener.Start() failed: ... 'http://+:8765/' ... conflicts with an existing registration on the machine`. The agent runs as the non-admin autologon User, so binding the strong-wildcard prefix needs a urlacl reservation it's permitted to use. install.bat's old `netsh http add urlacl ... user=Everyone listen=yes >nul 2>&1` either created a wrong-owner reservation or hit `ERROR_ALREADY_EXISTS` (183) -- both masked by the `>nul` redirect. Now: delete all overlapping 8765 reservations first, then `netsh http add urlacl url=http://+:8765/ sddl=D:(A;;GX;;;WD)` (World SID directly, locale-proof), with every result logged to setup.log + a `netsh http show urlacl` dump after. agent.ps1 retries the bind 5x (absorbs the reservation-landing race) and dumps the live urlacl state to agent.log on persistent failure. OEM bundle 24 -> 25. Diagnosed end-to-end with @ismikes (Kubuntu 26.04) -- three earlier test branches ruled out the install.bat changes before his agent.log screenshots pinpointed the urlacl conflict. +- **dockur `proc.sh:137` ARGUMENTS-strip crash, agent never installs (#287, #269).** dockur (via the qemus/qemu base image) parses the compose `ARGUMENTS:` env, strips out `-cpu host,` for its own CPU_FLAGS pipeline, then runs `ARGUMENTS="${args::-1}"` on the leftover. When the user's tuning profile produces nothing else (`tuning_profile = off` for example), the leftover is an empty string and the bash slice fails with `proc.sh: line 137: -1: substring expression < 0`. proc.sh exits on that line; `/oem` is never copied into the Windows guest; `install.bat` never runs; the winpodx agent service is never installed; port 8765 accepts TCP but RSTs every handshake; `winpodx pod wait-ready` times out at 60 min. Reported by @ankranidiotis on v0.5.7 (Linux Mint 22) and @ismikes (Kubuntu 26.04) -- both fresh installs with default tuning. Workaround in `_qemu_arguments_for_host()`: when no extra QEMU args would be emitted, append `-msg timestamp=on` (timestamps QEMU log lines, otherwise a no-op) so proc.sh's strip leaves at least one space-separated token in ARGUMENTS and the bash slice sees a non-empty string. The `auto` / `safe` / `performance` tuning profiles already emit hv-* / virtio-rng extras (#245) and aren't affected; the workaround triggers for `off` and any future profile that produces a pure `-cpu host,...` line. +- **`podman-compose` required up-front instead of trusting `podman compose` (#288).** On Fedora-family systems with the docker-compose CLI plugin installed, `podman compose` delegates to docker-compose, which doesn't understand the `group_add: [keep-groups]` magic value winpodx uses for rootless `/dev/kvm` passthrough -- the container fails to start with `Unable to find group keep-groups`. install.sh's old presence check accepted that delegation (`podman compose version` succeeds via it). Now install.sh requires the standalone `podman-compose` binary, and `backend.podman._compose_cmd` raises a clear per-distro install hint instead of silently delegating. Reported by @magicdiablo (Nobara). +- **`-no-hpet` removed -- QEMU 10 (dockur v5.15+) rejects it as invalid option.** PR #281's hv-enlightenments work appended `-no-hpet` to the QEMU ARGUMENTS when the auto / safe / performance profiles selected the Hyper-V machine timer. dockur v5.15 ships QEMU 10.x via the qemus/qemu v7.30 base image; QEMU 10 dropped the `-no-hpet` flag entirely (replacement is `-machine ...,hpet=off`, which would override dockur's machine type). Container start now bombs with `qemu-system-x86_64: -no-hpet: invalid option`. Dropping the flag has no functional impact -- the `hv-stimer` + `hv-stimer-direct` enlightenments already steer Windows off HPET as the clock source. Reported by @ismikes on Kubuntu 26.04 against main HEAD after PR #289 merged. +- **Structured `[agent-install]` markers in install.bat (#269 diagnostics).** Each agent-install step (`hkcu-run-register`, `spawn`, `post-spawn-probe`, `urlacl`) is fenced with `[agent-install] step= status=` lines, the HKCU\\Run write + Start-Process are wrapped in try/catch that log the exception, and a 5s post-spawn probe checks the 8765 listener + tails agent.log -- so a failed agent install localises itself in setup.log without another round-trip. +- **RPM spec `License:` tag widened to `MIT AND Apache-2.0` (#301).** `packaging/rpm/winpodx.spec` declared `License: MIT` but `config/oem/rdprrap-*-windows-x64.zip` bundles stascorp/rdpwrap (Apache-2.0). Fedora packaging guidelines require the SPDX expression to enumerate every license that applies to the redistributed binary content. `debian/copyright` and `THIRD_PARTY_LICENSES.md` already broke this out correctly; the RPM spec now matches. + +## [0.5.7] - 2026-05-21 + +Feature release wrapping up #247 (per-item debloat picker, four phases) and #254 (locale + edition save flow, three phases), plus the OEM-perm regression #266/#267 caught at the same time. + +### Highlights + +**Per-item debloat with CLI + Qt + TTY pickers, Windows-guest timezone wiring via dockur's TZ env var, GUI Settings → Container/VM Localization dropdowns + auto-recreate on save, and `winpodx pod recreate [--wipe-storage]`.** + +- `winpodx debloat` -- per-item catalog driven by `data/debloat/items.toml` (11 items, 4 presets). `--list` / `--preset` / `--items` / `--undo` / `--menu` on the CLI plus a Qt picker dialog with checkboxes + risk badges (#247). +- Windows guest timezone is now host-autodetected and passed to dockur via the `TZ` env var; `cfg.pod.timezone` GUI dropdown + `winpodx config set pod.timezone --auto` + setup-wizard prompt all wired (#254). +- Settings → Container/VM gains Language / Region / Keyboard / Timezone dropdowns alongside the existing Edition picker, with a unified save flow that auto-runs `_recreate_container` (or `_wipe_pod_storage` first when language/edition changed) on dirty fields (#254 P3). +- `winpodx pod recreate [--wipe-storage]` -- regenerates `compose.yaml` + destroys/recreates the container (Windows disk preserved by default; `--wipe-storage` triggers a fresh Windows install for language/edition changes) (#254 P1). +- `Config.save()` now persists `pod.language` / `pod.region` / `pod.keyboard` / `pod.timezone` / `pod.tuning_profile` -- pre-this fix those five fields were loaded but never written back, silently dropping `winpodx config set` on the next save. + +### Added + +- **`winpodx debloat --menu` text-mode picker (#247, phase 4).** Headless-friendly alternative to the Qt dialog for users on SSH / TTY-only installs. Pure `input()` + `print()` -- no curses dependency, no display server, no terminal capability sniffing. Loop renders the catalog as a numbered list with current selection state + preset name + risk badges + `(one-way)` tags, then prompts for a command: `` toggles item N, `` toggles by name, `p` lists presets, `p ` switches preset, `a` applies, `q` quits, `h` shows help. `run_menu` accepts injectable `input_fn` / `print_fn` so the same code path drives interactive shells and CI integration tests with mocked stdin. +- **GUI debloat picker dialog (#247, phase 3).** Tools → Debloat now opens a modal dialog (`gui/debloat_picker.py`) instead of running the normal preset blind. Every catalog item is a row with a checkbox + colored risk badge (green / amber / red) + tooltip showing the description; preset radios (Normal / Full / Performance / Speed / Custom) seed the checkbox state, and toggling any item flips the radio to Custom. Apply runs the selection via the existing `run_via_transport` channel; Cancel exits without touching the guest. One-way items (onedrive, startup_programs) carry a "(one-way)" tag in the row so users see at a glance which selections can't be reverted via #247 P2's `--undo` path. +- **`winpodx debloat --undo` + per-item undo scripts + state JSON tracking (#247, phase 2).** Each catalog entry can carry an optional `undo_script` field; reversible items (9 of the 11) now ship an `undo/.ps1` sibling that reverts the apply. `winpodx debloat --undo --items ` (or `--undo --preset `) runs the undo path; mixed selections containing one-way items (`onedrive`, `startup_programs`) abort with a clear "items have no undo path" error so a partial undo doesn't leave the user thinking the one-way item was reverted too. The apply orchestrator now writes a per-item entry to `%ProgramData%\winpodx\debloat-applied.json` after each successful apply, and the undo orchestrator removes it -- groundwork for the GUI picker (phase 3) to show "currently applied" state next to each checkbox. +- **`winpodx debloat --list` / `--preset` / `--items` (#247, phase 1).** Refactors the previously-monolithic `winpodx debloat` (which always ran the same 70-line `debloat.ps1` script) into a per-item catalog + selection resolver. The catalog ships at `data/debloat/items.toml` with 11 items (telemetry, ads, onedrive, sysmain, web_search, widgets, scheduled_tasks, startup_programs, visual_effects, search_indexing, transparency), each backed by a small per-item `.ps1` under `scripts/windows/debloat/`. Four presets (`normal`, `full`, `performance`, `speed`) seed the most common selections; bare `winpodx debloat` (no args) keeps the previous behaviour by defaulting to `normal` (telemetry + ads). `--list` prints the full catalog + presets for inspection without touching the guest. `--items` accepts a comma-separated explicit name list and overrides `--preset` when both are given. Out of scope for this phase (tracked under #247): per-item undo, GUI picker dialog, TTY-menu (`--menu`). +- **GUI Settings → Container / VM card gains Localization dropdowns (#254, phase 3).** Four new rows alongside the existing Edition / CPU / RAM picks: Language, Region, Keyboard, Timezone. Each row's first option is `Auto (...)` and maps to an empty TOML string (= host autodetect at compose time); the timezone row's auto-label surfaces the live `detect_timezone()` result so the user can see what would land on the guest. Save handler routes the recreate prompt through one of two paths: a plain "Restart now?" when only timezone / CPU / RAM / port / user are dirty (Windows disk preserved, OEM tzutil re-applies on every recreate), or a louder "Wipe and reinstall now?" when Edition or any of language / region / keyboard is dirty (dockur honors those env vars only on the initial Windows install, so a plain recreate would silently no-op). Accepting the wipe prompt calls the same `_wipe_pod_storage` helper as `winpodx pod recreate --wipe-storage`. +- **`winpodx config set pod. --auto` shorthand (#254, phase 2).** Resolves the host-detected value for the named key instead of taking it from the positional `` argument. Currently wired: `pod.timezone` (IANA name from `utils.locale.detect_timezone`). Other locale keys (language / region / keyboard) print a clear "not yet supported" error pointing at #254 and exit non-zero; their detectors land in a follow-up. `--auto` and a positional `` are mutually exclusive. +- **`winpodx setup` interactive timezone prompt (#254, phase 2).** Wizard now prompts for `cfg.pod.timezone` after the CPU / RAM rows, defaulting to the host-detected IANA zone. Non-interactive setup (install.sh's call path) leaves `cfg.pod.timezone` at its default empty string so the compose generator's host autodetection still fires at first boot. +- **`cfg.pod.timezone` — Windows guest timezone wiring (#254, phase 1).** New config knob. Empty string (the default) triggers host autodetection at compose time via `timedatectl show --property=Timezone --value`, falling back to `readlink /etc/localtime` then `/etc/timezone` then `UTC`. The IANA result is translated to a Windows TZ ID through the CLDR-derived `data/locale/windows_zones.toml` table (~150 entries from the `001` wildcard subset). The resolved Windows TZ ID lands in `/timezone.txt`; the OEM `install.bat` reads it on first boot and runs `tzutil /s ""` instead of the previous unconditional `tzutil /s "UTC"`. Explicit IANA names or bare Windows TZ IDs in the TOML are honored; detection-failure UTC skips the file so the guest stays on its current zone rather than being forced onto UTC. Host autodetection covers the @ismikes report (#204) that fresh installs default the Windows guest to UTC regardless of host locale. +- **`winpodx pod recreate [--wipe-storage]` subcommand (#254, phase 1).** Regenerates `compose.yaml` from the current config, then destroys and re-creates the container -- a cleaner primitive than `winpodx pod restart` for picking up first-boot env knob changes (timezone, edition, backend). `--wipe-storage` also destroys the Windows disk volume / bind-mount so dockur re-runs the full install, which is required for language / region / keyboard / edition changes to actually reach the guest (dockur honors those env vars only on the initial Windows install). The wipe path prompts for an explicit `WIPE` confirmation. +- **`config/oem/install.bat` reads `C:\OEM\timezone.txt` if present (#254, phase 1).** Falls back to the previous `tzutil /s "UTC"` behaviour when the file is absent. +- **`data/locale/windows_zones.toml` — IANA -> Windows TZ ID mapping table (#254, phase 1).** Shipped under `share/winpodx/data/locale/` via the pyproject shared-data layout. Refresh helper (`scripts/ci/refresh_windows_zones.py`) deferred to a follow-up. + +### Changed + +- **Timezone wiring redesigned around dockur's native `TZ` env var (#267, refactor of #254 P1).** The original P1 design dropped a `timezone.txt` file into the OEM bind-mount dir and added a `tzutil` block to `install.bat` to consume it. That forced an always-copy OEM dir path so per-config files could land without polluting the source bundle, which re-introduced the parent-dir traversal problem PR #95 originally dodged: dockur's in-container OEM cp runs as a non-root sub-UID that can't traverse a 0700 `~/.config` or `~/.local` ancestor, producing `cp: cannot stat /oem/./: Permission denied` for every OEM file at first boot. Switching to dockur's `TZ` env var (which already drives the `` element in its Sysprep unattend.xml) removes the OEM-file requirement entirely, lets `_find_oem_dir` go back to its pre-#254 two-regime layout (bundle-direct when user-writable / copy when read-only), and eliminates the umask-077 perm trap. `install.bat`'s old `tzutil` block was removed; `utils/locale.py` (host detection + IANA→Win mapping table) stays in place for GUI display and the wizard's auto-default. + +### Fixed + +- **OEM cp `Permission denied` cascade on fresh installs with umask 077 (#267).** Symptom: dockur's first-boot OEM-copy step failed with `cp: cannot stat '/oem/./': Permission denied` for every OEM file -- container's non-root sub-UID couldn't traverse the 0700 `~/.config/` ancestor introduced by #254 P1's always-copy OEM dir path. Fixed by reverting to bundle-direct OEM mounting when the user owns the bundle (see Changed entry above for the deeper refactor). +- **`Config.save()` now persists `pod.language` / `pod.region` / `pod.keyboard` / `pod.timezone` / `pod.tuning_profile` (#254, phase 1).** Pre-this fix those five fields were loaded from `winpodx.toml` but never written back -- programmatic changes (`winpodx config set pod.language ...`) were silently dropped on the next save. Hand-edited values still survived because nothing was overwriting them. Now all five fields round-trip through save/load. + +## [0.5.6] - 2026-05-21 + +Hotfix release driven by @ismikes (#214). Fixes the "Launching... but no RDP window appears" symptom on modern rootless podman + pasta (Ubuntu/Kubuntu 26.04 default), corrects the `winpodx info` VNC reachability false negative, and adds a belt-and-braces tray sweep at the end of `uninstall.sh`. + +### Highlights + +**RDP launches that hung at "Launching..." on Kubuntu 26.04 work again, and `winpodx info` stops lying about VNC reachability (#214).** + +- `core/rdp.py` no longer wraps FreeRDP in `podman unshare --rootless-netns` — the wrap put xfreerdp3 inside the container's net ns where the host-side publish was invisible. Reported by @ismikes, diagnosed by @smoore100, both confirmed the fix. +- `winpodx info` VNC reachability probe switched from RDP X.224 handshake to plain TCP accept (VNC speaks RFB, not RDP). +- `uninstall.sh` adds a final tray/GUI sweep so processes that came up *during* the uninstall window (other-terminal GUI launches, XDG-autostart races, dbus activation) still get killed before the script exits. + +### Fixed + +- **App launch no longer hangs at "Launching..." on modern rootless podman + pasta (#214).** Reported by @ismikes on Kubuntu 26.04 (Wayland + Plasma 6 + FreeRDP 3.24.2); diagnosis from @smoore100. `core/rdp.py` previously wrapped every FreeRDP invocation in `podman unshare --rootless-netns`, putting xfreerdp3 *inside* the container's network namespace where the host-side port publish is invisible — so "Launching..." printed but no RDP window appeared. The wrap is removed; FreeRDP now runs on the host loopback directly, where `127.0.0.1:` is reachable via the existing podman publish. Side benefits: removes an implicit dependency on the `podman` binary being on PATH for the FreeRDP launch path (flatpak-FreeRDP-only setups now work), and the Plasma 6 / Wayland / FreeRDP 3.24 edge case from #214 is unblocked. +- **`winpodx info` no longer falsely reports "VNC unreachable" while NoVNC is serving (#214).** The VNC port (8007 by default) was probed with `check_rdp_port`, which sends an X.224 Connection Request and waits for a TPKT reply — meaningless against an RFB-speaking VNC server, so the probe always returned False. VNC now uses `check_tcp_port` (plain TCP accept), which matches what "is the NoVNC endpoint accepting connections" actually means. +- **`uninstall.sh` final tray/GUI sweep before exit.** `WINPODX_NO_TRAY_SPAWN=1` + the section-0a `pkill` cover the case where uninstall.sh is the only thing touching the tray, but they miss tray/GUI processes that came up *during* the uninstall window — a GUI window opened in another terminal that already spawned a tray, a KDE/GNOME XDG-autostart race, a dbus-activated launch that bypasses `cli/main.py`'s spawn check. A quiet final `pkill -f 'python.*winpodx'` + `pkill -f winpodx-app` pass right before the summary catches those. + +### Internal + +- **`packaging/obs/obs-publish.yml` anchors the download grep to the current tag version** so stale older RPMs left in the OBS publish index by rolling-release repos (Tumbleweed / Slowroll lazy GC) don't get re-uploaded as release assets. v0.5.5 was the first release to surface this — two stale 0.5.4 RPMs landed in the Release page and had to be deleted by hand. + +## [0.5.5] - 2026-05-21 + +User-report follow-up release driven by @tolistim (#216) and @ismikes (#215). Fixes a setup-rerun lockout that desynced the host config from the Windows guest password, lands a host-adaptive Windows-on-KVM tuning profile so safe perf tweaks turn themselves on without user configuration, and ends the "tray stuck on starting forever" symptom that long-idle pods used to show. + +### Highlights + +**`winpodx setup` rerun is safe again, the dockur compose auto-tunes to the host, and idle pods no longer freeze the tray on `starting` (#215, #216).** + +- `winpodx setup` rerun on an existing install preserves the working Windows credentials instead of silently overwriting `cfg.rdp.password`; the lockout that produced `LOGON_FAILED_BAD_PASSWORD` is gone. Reported by @tolistim (#216). +- New `cfg.pod.tuning_profile` (default `"auto"`): winpodx probes the host once at compose time (invariant TSC, kernel ≥ 5.6 for io_uring, `vm.nr_hugepages`, idle CPU + RAM headroom) and turns on every safe Windows-on-KVM tweak the host can support. `+invtsc` lands in the x86_64 QEMU args on supported CPUs. `winpodx info` / `winpodx setup` print a new `[Tuning]` block so the auto-applied set is never hidden. Suggested by @ismikes (#215). +- `safe` / `off` / `manual` escape hatches for the tuning profile; host-side prerequisites (hugepages, CPU pinning, VFIO) stay documented operator tasks rather than wired into winpodx automatically. +- **New `PodState.UNRESPONSIVE`** plus auto-recovery flow: `pod_status()` now distinguishes a still-booting container (`STARTING`, container uptime under 600 s) from a long-running one whose Windows guest has gone unresponsive on the RDP port (`UNRESPONSIVE`, container uptime past 600 s). The tray observes the `RUNNING → UNRESPONSIVE` transition, fires a desktop notification, and spawns a background worker that cycles `TermService` in-guest via the agent transport. On recovery a "pod recovered" notification fires; on failure the user gets a "needs manual restart" notification pointing at `winpodx pod restart`. OEM install.bat also tightens every `powercfg` timeout to `0` and clears `PlatformAoAcOverride` so Modern Standby can't drop the NIC out from under a long-idle session in the first place. + +### Added + +- **`PodState.UNRESPONSIVE` + auto-recovery flow.** `pod_status()` resolves to the new `UNRESPONSIVE` value when the container has been running longer than 10 minutes but the RDP port isn't answering — historically misreported as `STARTING` forever. The tray driver caches the previous state and on a `RUNNING → UNRESPONSIVE` transition fires `notify_pod_unresponsive` + a background worker that calls `try_recover_rdp` (new `core/pod/recovery.py`). Recovery asks the agent to `Restart-Service -Force TermService` and re-probes the RDP port for 30 s; success fires `notify_pod_recovered`, failure fires `notify_pod_needs_manual_restart` with the failure mode (`agent unreachable` / `rdp still down`) so the user knows to run `winpodx pod restart`. `Backend.uptime_secs()` is the new ABC method; Podman + Docker implement it via `inspect -f '{{.State.StartedAt}}'`; libvirt + manual return `None` so they fall back to the legacy `STARTING` answer. + +- **`config/oem/install.bat` disables every Windows idle timeout that can kill RDP.** The Ultimate Performance plan alone wasn't enough — Modern Standby (S0 low-power idle) can still drop the virtio NIC and stall TermService. Sets `standby` / `hibernate` / `monitor` / `disk` timeouts to `0` on AC + DC, clears `HKLM\SYSTEM\CurrentControlSet\Control\Power\PlatformAoAcOverride` to force Desktop class power behaviour, and forces `STANDBYIDLE 0` on the current scheme as a belt-and-braces guard. Eliminates the root cause behind the `RUNNING → UNRESPONSIVE` transition on idle pods. + +- **Host suspend / resume no longer leaves the tray frozen on `starting`.** Two-pronged: the guest gets a SYSTEM-level scheduled task that subscribes to `Win32_PowerManagementEvent` (`config/oem/power-monitor.ps1`) and restarts `TermService` 5 s after EventType 7 / 18 (resume from suspend / modern-standby), so the in-guest RDP listener is rebuilt before the next probe. The host tray subscribes to `org.freedesktop.login1`'s `PrepareForSleep(active=False)` signal via QtDBus and refreshes pod state 5 s after the resume event, triggering the existing UNRESPONSIVE auto-recovery path immediately instead of waiting for the 30 s poll. Both halves degrade gracefully — D-Bus subscription is best-effort; the scheduled task is installed only on fresh OEM apply (existing pods pick it up via `winpodx pod apply-fixes` in a follow-up). + +- **OEM install ends with a scheduled reboot so registry edits actually take effect.** `PlatformAoAcOverride = 0` (Modern Standby off) and several NIC binding tweaks land in `HKLM` but the running session keeps the old values until next boot. install.bat now writes a `C:\winpodx\oem_reboot_pending.txt` marker, registers a `RunOnce` key that deletes the marker on the second Windows boot, and issues `shutdown /r /t 15` as its last action. `winpodx pod wait-ready` gains a `[4/4] Waiting for OEM reboot pass...` phase that polls the marker via the agent transport; an upgrade-path install without the marker short-circuits through a 30-second appear-grace window so the existing flow keeps working unchanged. + +- **Tray UX overhaul: always-running tray, Dashboard shortcut, confirmed Quit.** The tray is now auto-spawned from the GUI window and from every CLI subcommand that touches the pod (everything except `setup` / `gui` / `tray`), so a user who only ever runs `winpodx app run` still gets the UNRESPONSIVE auto-recovery driver. The tray icon now renders properly (bundled `winpodx-icon.svg` set before `tray.show()`; previously KDE Plasma / GNOME suppressed the indicator entirely because no icon was assigned). New "Open Dashboard" menu item at the top of the tray context menu spawns the main GUI window. Quit now confirms via a QMessageBox ("winpodx 를 완전히 종료할까요?") and on confirmation runs `stop_pod` + `pkill -f 'winpodx gui'` + `app.quit` so a stray click can't cycle the pod's ~30 s restart. `tray.lock` flock under `$XDG_RUNTIME_DIR/winpodx/` prevents stacked instances when the user manually re-launches the tray. + +- **`ensure_ready` two-stage RDP recovery on CLI launch.** CLI launches (`winpodx app run`, GUI app clicks, etc.) now self-heal a stalled-RDP guest before spawning FreeRDP: stage 1 calls `try_recover_rdp` (agent-driven TermService cycle, ~5-30 s, keeps Windows uptime), stage 2 falls back to `recover_rdp_if_needed` (whole-container restart, ~30 s, resets Windows uptime). The old behaviour was to fail the launch with a connection-refused that the user had to debug; now the cheap path tries first, only escalates when the agent isn't reachable. + +- **GUI Settings: "Launch winpodx tray at login" checkbox.** New toggle in the Settings page writes / removes `~/.config/autostart/winpodx-tray.desktop` via the XDG autostart spec — portable across KDE / GNOME / XFCE / Cinnamon without per-DE branching. File existence is the source of truth (no `cfg.toml` field, no daemon to manage), and the user can drop the .desktop by hand to opt out. `X-GNOME-Autostart-enabled=true` is included so GNOME's session manager honours it after DE-default resets. Toggle applies immediately — no Save Settings click needed. + +- **`uninstall.sh` covers the new tray + autostart surfaces.** Two new sections: (0a) pre-removal `pkill -f` against `winpodx gui` and `winpodx tray` plus 1-s grace, so the killed processes release their FDs into the install / runtime / config dirs we're about to `rm -rf` and stop firing auto-recovery notifications against a soon-to-be-gone container; (6b) always-remove `~/.config/autostart/winpodx-tray.desktop` regardless of `--purge` because the .desktop is useless without the binary and leaving it triggers a `winpodx: command not found` at next login. + +- **Host-adaptive Windows-on-KVM tuning profile (#215).** `cfg.pod.tuning_profile` controls how aggressively winpodx tunes the dockur compose for the host. Default `"auto"` probes the host once at compose time (constant_tsc + nonstop_tsc, kernel ≥ 5.6 for io_uring, `vm.nr_hugepages > 0`, idle CPU + RAM headroom) and enables the matching subset of standard tweaks — currently `+invtsc` on x86_64 hosts that expose invariant TSC. Other profiles: `"safe"` (Tier-1 only — `+invtsc` + Windows `platform_tick`, no host-side prerequisites), `"off"` (dockur defaults only), `"manual"` (`safe` shape; future per-knob overrides). `winpodx info` and `winpodx setup` both print the detected capability + resolved profile so users can see exactly what was auto-applied. Suggested by @ismikes (#215); standard sysguides.com Windows-on-KVM tuning items that need host-side setup (hugepages, CPU pinning) are documented in `docs/USAGE.md` as user-host tasks rather than wired into winpodx automatically. + +### Fixed + +- **`winpodx setup` rerun no longer locks out users by silently rewriting the Windows password (#216).** Re-running setup on an existing install (e.g. to bump cores/RAM) used to reprompt for the Windows password and write whatever was entered into `winpodx.toml`. Because dockur honors `USERNAME` / `PASSWORD` env vars only on first boot, the host config silently desynced from the Windows guest account and the next RDP launch failed with `LOGON_FAILED_BAD_PASSWORD`. The interactive prompt now detects an existing config with a populated `cfg.rdp.user` + `cfg.rdp.password`, preserves them, and prints guidance to use `winpodx rotate-password` (which goes through the Windows-side password change mechanism). Non-interactive `install.sh` setup and fresh installs are unchanged. Reported by @tolistim on Linux Mint 22.3 / v0.5.4. + +- **Docs: Fedora 42 / 43 / 44 install snippet uses dnf5 syntax (#228).** Old `sudo dnf config-manager --add-repo ` fails on Fedora 41+ because dnf5 changed the subcommand shape. Snippet now reads `sudo dnf config-manager addrepo --from-repofile=`; the dnf4 form is noted below for Fedora ≤40 (EOL). Updated in `README.md` / `docs/README.ko.md` / `docs/INSTALL.md` / `docs/INSTALL.ko.md` / `packaging/obs/README.md`. Reported by @payayas. + +- **`install.sh` no longer falsely reports success when hardware virtualisation is off (#220).** Reported by @pnogaret2019-code on Linux Mint LMDE 7. The install loop treated `qemu-system-x86` already being present as "all dependencies installed successfully" while `/dev/kvm` stayed absent because the BIOS had VT-x disabled. install.sh now (a) emits a much more prominent pre-install warning that walks the user through the three real causes (BIOS, kernel module, kvm group) with diagnostic commands, and (b) re-verifies `/dev/kvm` after the install loop and aborts with live `lscpu` / `lsmod` / `id` output when it's still missing. README + Korean mirror gain a new "Minimum requirements" section with the same three-row check table, so users see the gating requirements before they ever copy-paste the curl-install line. Catches the largest single source of "install ran fine but Windows never boots" bug reports. + +- **`install.sh` Ubuntu 24.04+ / Debian 13 picks the real `qemu-system-*` package instead of the now-virtual `qemu-kvm` (#200).** Reported by @n-osennij on xubuntu 26.04 inside VirtualBox. apt errored with `E: Package 'qemu-kvm' has no installation candidate` because newer Ubuntu / Debian made `qemu-kvm` a virtual package whose providers are `qemu-system-x86` (or `qemu-system-x86-hwe`). `pkg_name()` now uses the same `apt-cache show` probe pattern as the freerdp2 → freerdp3 selector from PR #198: probes `qemu-system-x86` first (or `qemu-system-arm` on aarch64), falls back to `qemu-system-x86-hwe`, then `qemu-kvm` for older distros. The VirtualBox-side nested-virtualisation requirement is host-environment and outside winpodx's scope — covered by the new "Minimum requirements" README section. + +- **dockur/windows image pin bumped to v5.15 (#223, #224).** `DOCKUR_IMAGE_PIN` / `DOCKUR_IMAGE_ARM_PIN` + `config/oem/VERSIONS.txt` track the new digests (`sha256:32abe0836aee...` x86_64, `sha256:5775bcfd335b...` aarch64). Picks up upstream's accumulated fixes since v5.14 without changing the winpodx-side compose surface. + +- **`install.sh` aborts cleanly on Ctrl+C instead of marching past `Pod is starting, not running. Start the pod now and apply? [Y/n]`.** The old install loop used a single `EXIT INT TERM` trap that only cleaned up the install marker; SIGINT then fell through to the next step. Split into three traps (`EXIT` cleanup-only, `INT` cleanup + `exit 130`, `TERM` cleanup + `exit 143`) and wrapped the `wait-ready | tee` pipeline with `set +e` so `PIPESTATUS[0]` survives the `|| true` rescue and the SIGINT-vs-real-failure branch can act on the real wait-ready rc. + +- **`winpodx setup --non-interactive` flag for install.sh-driven migration.** install.sh calls setup migration after the initial install; without `--non-interactive` the migration would block on the storage-path prompt and stall the curl-install. Setup gains the flag and treats every interactive prompt as "accept default" when set. + +- **`uninstall.sh` tray no longer respawns mid-uninstall.** Section 0a `pkill`'d the tray, then section 0b ran `winpodx host-open stop-listener` / `unregister-guest` to tear down reverse-open. `cli/main.py` auto-spawns the tray for every subcommand outside the `{setup, gui, tray}` skiplist, so `host-open` triggered a fresh tray spawn and section 0a's work was immediately undone. `uninstall.sh` now exports `WINPODX_NO_TRAY_SPAWN=1`; `maybe_spawn_tray()` short-circuits when set, so every `winpodx` CLI subprocess uninstall.sh runs inherits the env and skips the spawn. + +## [0.5.4] - 2026-05-19 + +User-report follow-up release. Fixes slow-link compose timeout (#212), eliminates the silent "launched but no window" failure mode that #213 / #214 surfaced, lands Ubuntu 26.04 packaging in the CI matrix (#206), Windows language / region / keyboard configurability (#201), and the dynamic desktop resolution feature (#197). + +> **Note:** v0.5.3 was tagged earlier the same day but its `.deb` build failed on Debian/Ubuntu hosts due to a Trove-classifier whitelist mismatch in older `hatchling`. v0.5.4 supersedes it with the same feature set plus a packaging fix; the v0.5.3 release page was removed to avoid two releases on the same day. The v0.5.3 tag stays for history. + +### Highlights + +**Pull-friendly compose timeout, visible FreeRDP launch errors, Ubuntu 26.04 in CI, and a packaging fix that makes the `.deb` build green on every supported distro.** + +- `podman-compose up -d` / `down` default timeout 120s → 1800s; honors `WINPODX_COMPOSE_TIMEOUT_SECS` (`0` = no cap). Fixes #212 reported by @jimed-rand. +- `launch_app` polls FreeRDP 500 ms after spawn and raises `FreeRDP exited immediately with rc=...` with the stderr log path. Fixes #213 reported by @mozjacksson + #214 reported by @ismikes. +- Ubuntu 26.04 packaging + Python 3.14 in the CI matrix. Closes the untested-platform gap behind #213 / #214 (by @juampe, #206). +- Windows `[pod] language / region / keyboard` keys; same YAML-injection hardening as `user` / `password` / `home` (by @juampe, #201). +- Desktop sessions now resize dynamically to the FreeRDP client window size (by @Zeik0s, #197). +- LICENSE / README ship to `/usr/share/winpodx/` in the `.deb`; GUI License tab works again on Debian/Ubuntu (by @juampe, #201). +- Qt stylesheet parser warnings at GUI startup silenced (by @juampe, #203). +- **Packaging fix:** minor-version Python Trove classifiers (`:: 3.9` … `:: 3.13`) dropped from `pyproject.toml`. Older `hatchling` (Debian 12 ships 1.12.0, Ubuntu 24.04 ships 1.18.0) bundles its own classifier whitelist and rejects any minor-version classifier added after that release; that's what broke the v0.5.3 `.deb` build. Replaced with the version-agnostic `:: 3 :: Only`. Supported-version coverage still comes from `requires-python = ">=3.9"` and the CI matrix. + +### Added + +- **Windows language / region / keyboard configuration.** The pod's installation language, regional format, and keyboard layout are now configurable via `[pod] language / region / keyboard` in `winpodx.toml`. Defaults to `English` / `en-001` / `en-US` (backward compatible with existing configs); set to e.g. `Spanish` / `es-ES` / `es-ES` for a Spanish Windows install. All three fields run through `_DANGEROUS_YAML_CHARS` sanitisation in `PodConfig.__post_init__` and `_yaml_escape` at compose render — same defense-in-depth as the existing `user` / `password` / `home` fields. `docs/INSTALL.md` and `docs/INSTALL.ko.md` document the 10 most common language tuples. Applies only to fresh Windows installations; existing pods need a volume reset + `winpodx setup` re-run. (by @juampe, #201) + +### Changed + +- CI matrix now tests Python 3.14 and packages for Ubuntu 26.04 (#206). Closes the untested-platform gap that surfaced in #213 / #214. (by @juampe) + +### Fixed + +- **Compose up/down timeout is now pull-friendly.** Default compose timeout bumped 120s → 1800s; honors `WINPODX_COMPOSE_TIMEOUT_SECS` env var (`0` = no cap). Fixes #212 reported by @jimed-rand. + +- **LICENSE and README.md missing from `.deb` packages.** `pyproject.toml` now ships `LICENSE` and `README.md` to `/usr/share/winpodx/` via the `[tool.hatch.build.targets.wheel.shared-data]` table. The GUI's License tab no longer hits `FileNotFoundError` on Debian/Ubuntu installs. (by @juampe, #201) + +- **Qt stylesheet parser warnings at GUI startup.** `main_window.py` combined a bare property declaration (`background: ...`) with full QSS selector rules (`QPushButton { ... }`), which Qt's stylesheet engine rejects. The central widget now carries an `objectName="centralRoot"` and the background rule is scoped to `QWidget#centralRoot { ... }`. No visual change; just silences the `Could not parse stylesheet of object QWidget(...)` warnings in the logs. Library page Launch/Edit buttons also got their inline QSS converted to multi-line blocks for readability while tracking parser warnings. (by @juampe, #203) + +- **Dynamic Desktop Window Resolution** — creating a desktop session now resizes the resolution dynamically to the size of the FreeRDP client window. Flag added as default behavior for desktop sessions. Covered by tests. (by @Zeik0s, #197) + +- **Launch surfaces FreeRDP stderr on quick-exit; eliminates silent "launched but no window" failure mode.** Helps diagnose Plasma 6 / Wayland and Python 3.14 edge cases. Fixes #213 reported by @mozjacksson + #214 reported by @ismikes. + +- **Debian/Ubuntu `.deb` build fails with `Unknown classifier: Programming Language :: Python :: 3.X`.** Older `hatchling` shipped by Debian/Ubuntu maintains its own Trove-classifier whitelist and rejects fine-grained Python version classifiers added after that hatchling release. The minor-version classifiers were cosmetic — actual Python version coverage comes from `requires-python` and the CI matrix. Removed in favour of `Programming Language :: Python :: 3 :: Only`, which every hatchling release accepts. + +## [0.5.2] - 2026-05-14 + +Same-day install-path hot-fix release. Two `install.sh` regressions that blocked new installs on common 2025+ distros (Debian 13 Trixie, Ubuntu 24.10+) are fixed, and Atomic Fedora support lands as a single-transaction OBS-layered install path. + +### Highlights + +- **`install.sh` works again on Debian 13 / Ubuntu 24.10+** — the hardcoded `freerdp2-x11` dependency was unresolvable on those distros (only `freerdp3-x11` is in stock repos). The Debian/Ubuntu branch in `pkg_name()` now probes `apt-cache show` and picks `freerdp3-x11` when available, falling back to `freerdp2-x11` only on older systems. New users on Debian 13 / Ubuntu 24.10+ could not previously install at all. (reported by @basti189, #198) +- **Atomic Fedora install path** (Silverblue / Kinoite / Sericea / Bluefin / Bazzite) via OBS `rpm-ostree install --apply-live` — single transaction, no per-package reboot loop. winpodx's RPM `Requires:` pulls FreeRDP / podman / python3 / tomli transitively, so layering just `winpodx` is enough. Falls back to staged install + reboot prompt if live apply is rejected by the booted deployment. (by @Zeik0s, #163) + +### Added + +- **Atomic Fedora install support** (Silverblue / Kinoite / Sericea / Bluefin / Bazzite). `install.sh` autodetects `rpm-ostree` and switches to a single-transaction OBS-layered install instead of the per-package dnf loop: probes the OBS repo for the host's Fedora `VERSION_ID` (currently published: Fedora_42 / Fedora_43 / Fedora_44), drops the `.repo` file into `/etc/yum.repos.d/`, and runs `rpm-ostree install --apply-live --idempotent winpodx` to land the layer in the booted deployment without a reboot. If the running deployment can't accept the live apply (kernel/init-touching layers), staging falls back to `rpm-ostree install --idempotent winpodx` with a reboot prompt. winpodx's RPM `Requires: freerdp >= 3.0` + `python3-tomli` and `Recommends: podman` + `python3-PySide6` so layering just `winpodx` pulls everything transitively — no separate dependency list to maintain. README "Supported distros" gains an Atomic Fedora row and a Fedora Atomic Desktops "Works on" badge; `docs/INSTALL.md` documents the hand-install steps for users who'd rather skip `install.sh`. Korean mirrors updated. (by @Zeik0s, #163) + +### Fixed + +- `install.sh` no longer fails with `freerdp2-x11 not found` on Debian 13 (Trixie) and recent Ubuntu (24.10 / 25.04 / 25.10) where stock apt repos only ship `freerdp3-x11`. The Debian/Ubuntu branch in `pkg_name()` now probes `apt-cache show` and prefers `freerdp3-x11` when available, falling back to `freerdp2-x11` only on older systems (Debian <=12, Ubuntu 22.04 stock). FreeRDP 3 is the recommended target anyway — the v0.5.1 launcher detects the major version at startup and emits the matching `/app:` syntax for either. (reported by @basti189, #198) + +## [0.5.1] - 2026-05-14 + +Maintenance + ergonomics release. Wider curated Windows edition support, headless install ergonomics, FreeRDP 2 compatibility fix for Ubuntu 22.04 LTS users, and an internal Qt refactor that future-proofs the GUI codebase. Nine PRs land between v0.5.0 and this tag. + +### Highlights + +**Pick LTSC IoT, Win10 LTSC, Tiny, Server, or your own ISO — and `winpodx app run ` now works on Ubuntu 22.04's default FreeRDP 2.11.** This release is mostly a follow-up to user-reported pain points on v0.5.0: more Windows editions selectable from one place, a real `--win-version` flag on `install.sh` and `winpodx setup`, and a RemoteApp launch bug that opened Microsoft Store instead of the real app on FreeRDP 2.x hosts. + +- **Windows edition picker** (#178, #183, #185, #186) — `cfg.pod.win_version` now accepts the full Win10+ curated set (11, 10, ltsc11, ltsc10, iot11, tiny11, tiny10, 2025, 2022, 2019, 2016) from the GUI Settings → Container/VM card, `winpodx setup --win-version`, or `install.sh --win-version`. Custom ISOs documented as a manual workaround in `docs/ARCHITECTURE.md`. (reported by @gabe39, #178) +- **FreeRDP 2 / Ubuntu 22.04 LTS compatibility** (#189) — `winpodx app run ` no longer opens Microsoft Store on FreeRDP 2.11.x. Win32 RemoteApp launches now use FreeRDP's separate `/app:` + `/app-name:` + `/app-cmd:` flags instead of FreeRDP 3's combined `/app:program:X,name:Y,cmd:Z` syntax. (reported by @poetman, #158) +- **ARM64 (aarch64) host support — Pi 5 and Ampere systems install cleanly** (#176, #177). Host arch is detected at install / setup time; aarch64 hosts default to `dockurr/windows-arm` and the compose template emits `-cpu host` without the x86-only `arch_capabilities=off` sub-option that previously crashed QEMU on `host-arm-cpu` with `Property 'host-arm-cpu.arch_capabilities' not found`. New aarch64 CI job catches future regressions. Remaining Phase 4 work — Windows-on-ARM RDP listener stabilisation — tracked under #141 / #140. (reported by @tslpre, #140) +- **Discovery cleanup** (#182) — `winpodx app refresh` no longer re-imports the reverse-open Linux-app shims as Windows apps, and a self-heal pass purges any existing polluted entries on the next refresh. +- **Weekly Docker Hub digest watcher** (#180) — picks up silent dockur rebuilds (security patches that don't get a release tag) that the release-tag-only watcher misses. +- **Internal**: `WinpodxWindow` Qt class decomposed from 2745 lines into a 148-line orchestrator + 10 single-responsibility mixins (#181). Zero behaviour change; maps cleanly to a future Rust port. +- **Always-on bottom log bar + live log-level picker.** Two new pieces working together: + - A persistent 2-line log ticker at the very bottom of the main window — visible regardless of which page is active. Latest two ``log_signal`` lines (winpodx logger + pod tail when ``cfg.logging.level == "RAW"``) flash there in real time. + - Terminal tab gets a `DEBUG / INFO / WARNING / ERROR / CRITICAL / RAW` dropdown that retargets the running logger AND persists to `cfg.logging.level`. **RAW** also enables the parallel `podman logs -f` stream (with `[pod]` prefix), so dockur / QEMU / Windows-side messages flow through both the Terminal full view and the bottom bar — useful for "Windows isn't booting" / "ISO download stuck" / agent-down triage. + - Tails are started once at GUI launch and stay running, so the bottom bar updates regardless of which page you're on. The Terminal tab's `Live (app)` / `Live (pod)` / `Stop tail` buttons were removed (now redundant with the always-on design). +- Settings save with container recreate now auto-runs the full bring-up (wait Windows boot → agent settle → apply runtime fixes → discover apps → push reverse-open manifest) with a progress dialog. No more "I changed the edition and now my apps don't work" workflow. + +### Added + +- `winpodx setup --win-version EDITION` and `install.sh --win-version VER` flags for headless edition selection on fresh installs. New `WINPODX_WIN_VERSION` env var equivalent. Documented in `docs/INSTALL.md` "Choosing the Windows edition" section. (#178, #185, #186) +- GUI Settings → Container/VM card gains a "Windows Edition" combo populated from `_KNOWN_WIN_VERSIONS`. Read-only dropdown; custom dockur tags outside the curated list are settable via direct `winpodx.toml` edit (see `docs/ARCHITECTURE.md` "Advanced: Custom Windows ISO"). (#178, #183) +- `_KNOWN_WIN_VERSIONS` constant in `core/config.py` (12 curated editions). `PodConfig.__post_init__` normalises whitespace + case, warns on unknown values without rejecting them. (#178, #183) +- **ARM64 (aarch64) Linux host support — Phase 1+2+3 of #141.** `install.sh` and `core/config.py:_default_pod_image` detect host architecture via `uname -m` and route aarch64 hosts to the `dockurr/windows-arm` image (matching the dockur project's separate ARM build). `core/pod/compose.py` drops `arch_capabilities=off` from the `-cpu host` flag on aarch64 — that sub-option is x86-only KVM CPUID hardening and QEMU's `host-arm-cpu` machine model rejected it with `Property 'host-arm-cpu.arch_capabilities' not found`, the exact symptom Pi 5 users hit at `winpodx pod start`. The new aarch64 CI matrix entry (#177) runs the test suite on `ubuntu-24.04-arm` so architecture-aware codepaths don't regress silently. Remaining Phase 4 work — Windows-on-ARM RDP listener stabilisation, rdprrap compatibility on `xtajit` x86 emulation — tracked separately under #141 + #140. (reported by @tslpre, #140 — Pi 5 / RaspiOS Trixie repro with full QEMU error) +- `check-windows-updates` workflow gains a `:latest` digest drift detector — opens a "silent rebuild" tracking issue when digest changes without a release-tag bump. Two new fields in `config/oem/VERSIONS.txt`: `dockur-digest=` and `dockur-arm-digest=`. (#180) +- `docs/ARCHITECTURE.md` "Advanced: Custom Windows ISO" section documents the manual `win_version = "custom"` + `compose.yaml` mount workaround for users with their own pre-loaded ISOs. Korean mirror updated. (#178, #184) +- `CONTRIBUTING.md` "Crediting contributors in Highlights" section codifies the inline `(by @user, #PR)` / `(reported by @user, #issue)` convention used in CHANGELOG Highlights. Korean mirror updated. (#187) +- New `[logging]` config section with `level` field (`DEBUG | INFO | WARNING | ERROR | CRITICAL | RAW`). `setup_logging()` reads it on startup so all winpodx CLI / GUI runs pick up the chosen level. Default `INFO`; unknown values fall back to `INFO` with no rejection. Terminal tab dropdown changes the live logger AND persists to TOML. `RAW` is a winpodx-only meta-level: pins the Python logger to `DEBUG` AND turns on a parallel `podman logs -f` tail in the Terminal so dockur / QEMU / Windows-side output interleaves with winpodx's own log lines (pod-log lines are prefixed `[pod]` and dimmed for distinguishability). +- New **License** GUI tab. Shows the MIT license text inline + a "Third-party components" section listing the upstream projects winpodx ships or depends on (dockur, FreeRDP, PySide6/Qt, rdprrap, stascorp/rdpwrap, rcedit, Catppuccin) with their respective licenses. Resolves the LICENSE file via `bundle_dir()` so it renders correctly across every install mode (source checkout, wheel, FHS, installer drop). +- **Auto bring-up after container recreate.** Changing CPU / RAM / port / user / Windows edition in the GUI Settings page used to call `_generate_compose` + `_recreate_container` and stop — the freshly-recreated guest had no booted Windows (different ISO if edition changed -> fresh download), no agent, no rdprrap multi-session apply, no app discovery, and no reverse-open manifest, so users had to run several CLI commands manually after every recreate. The save handler now chains the full bring-up automatically on a worker thread: (1) wait for `pod_status == RUNNING` AND `check_rdp_port` (bounded by `cfg.install.wait_ready_stage2_secs`, default 900s), (2) wait for `AgentClient.health()` + host token ready (bounded by `cfg.install.wait_ready_stage3_secs`, default 1800s — long because fresh-ISO download + Sysprep dominate this stage), (3) `winpodx.core.provisioner.apply_windows_runtime_fixes` for rdprrap activation + RDP timeouts + OEM baseline, (4) `discovery.scan` + `persist_discovered`, (5) `winpodx host-open refresh` for the reverse-open manifest. A new `BringUpProgressDialog` (window-modal, indeterminate progress bar, Cancel button) renders each phase's label + sub-detail; the same lines also feed the always-on bottom log bar via the existing `log_signal` fan-out. Cancel is best-effort — phase 1/2 polling loops honour the event within their 2 s cadence, but long blocking apply / discover / sync calls in phase 3-5 cannot be interrupted mid-flight (the button disables itself with "Cancelling..." text so the user knows the request landed). Implemented as a new `BringUpMixin` in `src/winpodx/gui/_main_window_bringup.py`, three new signals on `WinpodxWindow` (`bringup_phase`, `bringup_done`, `bringup_started`), and a kick at the end of the existing `_recreate` worker in `_main_window_settings.py`. +5 tests (`test_main_window_bringup.py`) cover happy-path ordering, phase-1 cancellation, phase-3 failure surfacing, non-blocking entry point, and fresh-cancel-event-per-run. + +### Changed + +- **Qt `WinpodxWindow` decomposed into mixins.** `src/winpodx/gui/main_window.py` shrinks from 2745 lines to 148 (-95%); page builders, page behaviour, chrome construction, pod control, and worker orchestration each move to their own private `_main_window_*.py` mixin module. Pure structural refactor — zero functional change, all method resolution preserved via MRO. (#181) +- `core/discovery._is_junk_entry` now rejects executables under `C:\Users\Public\winpodx\reverse-open\bin\` (queried via the new `reverse_open.sync.is_guest_shim_path` helper) so the host's reverse-open Linux-app shims aren't mistakenly imported as Windows apps. `persist_discovered()` runs a `_purge_reverse_open_entries` self-heal pass that drops any existing polluted entries on the next refresh. `scripts/windows/discover_apps.ps1` `Add-Result` filters the same path on the guest side as defense in depth. (#182) +- `docs/USAGE.md` `win_version` TOML example updated to list the new curated set instead of the pre-#183 subset. Korean mirror updated. (#184) +- **License tab third-party acknowledgments completed.** Added `libvirt-python` (LGPL-2.1+), `docker` / docker-py (Apache-2.0), `tomli` (MIT), and the `getrandom` Rust crate (MIT OR Apache-2.0) used in the reverse-open shim. The optional `[libvirt]` and `[docker]` extras now have their LGPL/Apache attribution surfaced in the GUI per upstream license terms; the reverse-open shim's dual-licensed Rust dependency is acknowledged for parity with what's statically linked into the vendored `.exe`. +- **BringUpProgressDialog detail.** The auto bring-up modal now shows a 5-phase checklist with per-phase elapsed times, attempt counters during the polling phases (Pod ready / Agent ready), and an expandable live tail of the dockur container's `podman logs -f` output so users can see what Windows is actually doing during the long ISO-download + Sysprep wait (Phase 2 can take 30+ minutes on a fresh edition). The pod-log expander spawns its own short-lived `podman logs -f` subscription bounded to the dialog's lifetime so the live tail is visible regardless of `cfg.logging.level`, and also receives the always-on `[pod]`-prefixed lines that flow through `WinpodxWindow.log_signal` (the same fan-out introduced for the always-on bottom log bar). `bringup_phase` now carries a stable phase-ID slug (`phase_1_pod` / `phase_2_agent` / `phase_3_fixes` / `phase_4_discovery` / `phase_5_refresh`) as its first arg so the dialog can route emissions to the correct checklist row independent of presentational copy. +6 dialog-side tests added. + +### Fixed + +- `winpodx app run ` no longer opens Microsoft Store on FreeRDP 2.x hosts (Ubuntu 22.04 LTS), AND no longer fails with `Unexpected keyword` on FreeRDP 3.x hosts (Tumbleweed, Fedora 42/43 with FreeRDP 3 PPA, etc.). Win32 RemoteApp now branches on the detected FreeRDP major version: FreeRDP 3 keeps the combined `/app:program:X,name:Y,cmd:Z` syntax (FreeRDP 3 parses `/app:` as `:,...` and rejects bare paths with `Unexpected keyword` at the `C:` drive prefix); FreeRDP 2 uses the separate `/app:PATH` + `/app-name:NAME` + `/app-cmd:CMD` flag form (FreeRDP 2 parses the combined string as a literal program path, fails the launch, and Windows shell handler falls back to Microsoft Store). Version is detected once per process via `xfreerdp --version` and cached. UWP path is unchanged. (reported by @poetman, #158) +- `__version__` in `src/winpodx/__init__.py` was stale at `0.4.3` and didn't track the package version. Bumped to `0.5.1` with this release; `winpodx --version`, the migrate version comparison, and the install marker now match `pyproject.toml`. +- **YAML-injection hardening** on the four `[pod]` config fields that land inside the compose template's double-quoted scalars. A hand-edited `winpodx.toml` with `win_version = '11"\nEVIL: "x'` (or the same shape in `container_name` / `image` / `disk_size`) used to break out of its YAML scalar and inject arbitrary env keys into the dockur service. Two-layer fix: `core/config.py:PodConfig.__post_init__` rejects dangerous chars (`"`, `\\`, `\n`, `\r`, `$`, `` ` ``) and validates `disk_size` against a regex; `core/pod/compose.py:_build_compose_content` now runs all four values through `_yaml_escape` as defense in depth. `cli/setup_cmd.py` re-runs `__post_init__` after the `--win-version` flag assignment so direct attribute mutation can't bypass the validation either. Five new tests cover dangerous-char reject, disk_size regex, and the YAML escape boundary. +- **License tab acknowledgments table corrected.** Smoke + legal review found three mistakes: `rdprrap` was listed as GPL-3.0 (it's MIT, same maintainer); `Catppuccin Mocha` was credited as the GUI palette (theme is actually GitHub Primer Dark — see `src/winpodx/gui/theme.py`); `PySide6` license string was imprecise. Catppuccin removed; rdprrap moved to MIT; GitHub Primer Dark added with the GitHub Inc. 2013 copyright; PySide6 tightened to `LGPL-3.0-only WITH Qt-LGPL-exception-1.1`. Three additional rdprrap-NOTICE upstreams surfaced in the GUI for parity with what the OEM zip ships: `stascorp/rdpwrap` (Apache-2.0), `llccd/TermWrap` (MIT), `llccd/RDPWrapOffsetFinder` (MIT). Optional-extras dependencies (Pillow, cairosvg, pyxdg) also added with their licenses. + +### Changed + +- `.gitignore` extended for AI-tooling-footprint hygiene per CLAUDE.md Section 8 (`.mcp.json`, `AGENTS.md`, `CLAUDE.local.md`, `demo.png`) plus standard scratch / editor / OS patterns and a defensive secret-shape denylist (`*.env`, `*.pem`, `*.key`, `id_rsa*`, etc. — currently no such files in tree). +- **Arch Linux status accuracy.** The `README.md` "Supported distros" row claimed `pacman / AUR | Supported`, and `docs/INSTALL.md` instructed `yay -S winpodx`. Neither is currently true — the AUR publish workflow is wired but secret-gated on `AUR_SSH_PRIVATE_KEY`, which has not been provisioned, so no `winpodx` AUR package has shipped. Updated the README row to "Source install supported · AUR package pending maintainer onboarding", replaced the `INSTALL.md` "Arch Linux (AUR)" section with `install.sh` guidance, and clarified the native-package-manager intro. Korean mirrors updated. + +## [0.5.0] - 2026-05-13 + +Reverse-open (#48) ships end-to-end and is the headline feature of this release. Linux apps now appear in the Windows guest's right-click "Open with…" menu by default — no opt-in flag, no Settings toggle required — with correct per-app icons in both the short menu and the long "Choose another app" dialog. Phase 2 series complete (a / b / c / d), plus the full fix-forward stack: per-app `.cmd` wrappers, Rust `.exe` shim, short-menu visibility, Firefox handoff, uninstall `--purge` scope, multi-resolution ICOs, Desktop folder shortcut + Quick Access pin, and the embedded-EXE-icon chooser fix. + +The chooser icon problem deserves a callout: Win10/Win11 Explorer reads chooser entry icons from the .exe's embedded PE resource, not from `Applications\\DefaultIcon` nor from a per-slug ProgID's `\DefaultIcon`. PR #171 ships the only path that actually works — embed the per-slug `.ico` directly into each per-slug `.exe` via vendored rcedit (electron/rcedit v2.0.0, MIT). PR #165's hard-link inode-sharing optimisation is intentionally dropped (~500 KB × N apps on disk) since there is no chooser-icon path that also preserves shared inodes on stock Windows. + +### Highlights + +**Reverse-open ships end-to-end (#48).** Linux apps now appear in the Windows guest's right-click "Open with…" menu by default, with correct per-app icons in both chooser surfaces. Selecting one round-trips the file open back to the host's `xdg-open`. + +- Reverse-open Phase 2 series complete (#157, #159, #160, #161, #162) — host-side discovery, listener daemon, Qt Settings card, guest handlers + sync transport, default-on. +- Reverse-open fix-forward stack (#164–#171) from real-Windows smoke — culminating in #171's embedded EXE icons via vendored rcedit (the working chooser-icon fix). +- PR #165's hard-link inode-sharing is intentionally dropped (~500 KB × N apps on disk); no chooser-icon path also preserves shared inodes on Win10/Win11. +- `cfg.reverse_open.enabled` defaults to `True`; turn off with `winpodx host-open disable` or the GUI Settings panel. + +### Added + +- **Reverse-open Phase 2 chooser icon fix-forward (#164 → #171).** The Phase 2c registration landed in #161 but the real-Windows smoke uncovered three classes of issues — entries collapsing to a single chooser item because Windows dedupes by EXE path, entries missing from the short menu, and entries rendering with the generic .exe glyph. The fix-forward stack addresses each: + - **#171 — Embed the per-slug icon into the per-slug `.exe` via vendored rcedit.** Each `winpodx-.exe` is now an independent copy of the shim whose PE resource section carries the matching `.ico`. The earlier ProgID `DefaultIcon` surface from #170 is ignored by the chooser; the embedded EXE icon is the only surface Win10/Win11 honours. `rcedit.exe` (1.36 MB) is committed under `config/oem/reverse-open/shim/bin/` and pushed to the guest alongside the shim via base64 in the sync payload. `register-apps.ps1` gains a `-RcEditExe` parameter; staging a slug is now `Copy-Item` + `rcedit --set-icon`. `unregister-apps.ps1` mirrors the rcedit cleanup. + - **#170 — Scaffolding (superseded by #171).** Adds the per-slug ProgID at `HKCU\Software\Classes\winpodx-` (anchoring the `\OpenWithProgids\winpodx-` value link in the long dialog), a `SHChangeNotify(SHCNE_ASSOCCHANGED)` P/Invoke call to invalidate the shell icon cache, and a one-line INFO log at the listener's spawn site capturing `slug=…` + the substituted `argv=…` for post-hoc debuggability of misbehaving spawns. The ProgID `DefaultIcon` writes from this PR were dropped in #171 (redundant once the EXE carries the icon); the ProgID itself, the SHChangeNotify call, and the spawn-argv log line remain. + - **#169 — Desktop folder shortcut + Quick Access pin.** Drops a `Desktop\Linux Apps.lnk` folder shortcut pointing at the Start Menu `Linux Apps` folder, plus a Quick Access pin via the `Shell.Application` "pintohome" verb. Both surface in Win11's "Choose another app → Look for another app on this PC" file-browse dialog so the user doesn't have to navigate to `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Linux Apps\` by hand. + - **#168 — Multi-resolution ICOs + full freedesktop mimeapps walk.** Icon conversion produces ICOs containing 16/24/32/48/64/128/256 px frames so the chooser, taskbar, and Settings → Default apps all pick a non-blurry image. Discovery now walks every `mimeapps.list` per the freedesktop spec (XDG_CONFIG_HOME, then XDG_CONFIG_DIRS, then `$XDG_DATA_HOME/applications`, then `$XDG_DATA_DIRS/applications`), not just the user's primary `mimeapps.list`, so default-handler detection matches what `xdg-mime query default` resolves. + - **#167 — `uninstall.sh --purge` skips guest reverse-open scrub.** `--purge` is for "nuke the host, container is also going away" cases; reaching into the guest with `unregister-apps.ps1` is pointless and slow if the pod is about to be deleted anyway. + - **#166 — Short-menu visibility + chooser cache + Firefox handoff.** Fixes the `OpenWithList` write so the entry surfaces in the short Open With menu (sub-key, not value — Windows treats values under `OpenWithList` as the MRU list and sub-key names as the population list). Calls `ie4uinit.exe -show` after registry writes to invalidate the chooser cache without a logoff. Routes the Firefox open-file handoff through a dedicated path so multi-instance launches dispatch to the existing instance correctly. + - **#165 — Rust `.exe` shim + NTFS hard links.** Replaces the original PowerShell/.cmd shim with a `windows_subsystem = "windows"` Rust binary so launching a chooser entry doesn't flash a console window. Per-slug entries originally landed as NTFS hard links of the source shim binary — same inode, flat on-disk footprint (this optimisation is undone in #171; see above). + - **#164 — Per-app `.cmd` wrappers + Linux-default scope + Start Menu shortcuts.** Earlier ProgID-based registration collapsed multiple apps to a single chooser entry because Windows dedupes by EXE path; the per-app `.cmd` shape gives each slug a distinct path. Registration scope narrowed to "apps the user has actually set as their Linux default" (via `is_default_for`) — registering every MIME-handling app flooded the chooser. `install.sh` auto-triggers reverse-open sync; `uninstall.sh` includes a `host-open unregister-guest` step. Start Menu shortcuts created for every discovered Linux app (regardless of default-handler status) so non-default apps remain reachable via "Choose another app → Look for another app on this PC". + +- **Reverse-open Phase 2c — guest-side handlers + sync transport + default-on.** Closes #48's end-to-end loop: Linux apps now appear in the Windows guest's right-click "Open with…" menu without any user opt-in. `cfg.reverse_open.enabled` defaults to **True**. Three new guest-side scripts (`config/oem/reverse-open/register-apps.ps1`, `unregister-apps.ps1`, and the PowerShell shim under `shim/winpodx-reverse-open-shim.ps1`) plus a new host-side push module (`src/winpodx/reverse_open/sync.py`). The shim is base64-embedded into the sync payload alongside the apps manifest and ICO blobs, so the feature works on existing pods that were created before this PR shipped — no dependence on dockur having staged the OEM bundle. The sync transport rides over the existing bearer-authed `/exec` endpoint with a 180s budget; a successful sync persists `cfg.reverse_open.last_synced_at`. `winpodx pod start` now auto-spawns the host-side listener daemon (idempotent — no-op if already running), `winpodx pod stop` tears it down before stopping the pod. `winpodx host-open refresh` now also pushes to the guest when the agent is reachable and reports `pushed N app(s) + M icon(s) → registered` in its summary. The PowerShell shim writes an atomic `.json.tmp` to `\\tsclient\home\.local\share\winpodx\reverse-open\incoming\` then renames to `.json` so the host listener never picks up a partial request. registry shape on the guest: per-app `HKCU\Software\Classes\winpodx-` ProgID with shim invocation in `shell\open\command`, plus `HKCU\Software\Classes\\OpenWithProgids\winpodx-` for every extension the app's MIME types map to (built-in curated table covers the top ~25 unambiguous mappings; extras fall back to a per-MIME guess from the type string). +13 tests (`test_reverse_open_sync.py`). Reverse-open Phase 2 series is complete; remaining follow-ups are a Go port of the shim (faster cold-start than PowerShell's ~300-500 ms; tracked but not blocking). References #48. + +- **Reverse-open Phase 2d — Settings-page card in the Qt GUI.** Surfaces every Phase 2a/2b CLI affordance — enable toggle, allowlist + denylist (with `+ Add` / `− Remove` buttons that route through the same `validate_slug` grammar the CLI uses), live daemon status (running / pid, cached app count, generated-at timestamp), and four action buttons that dispatch the existing `host_open` handlers (`refresh & sync`, `start daemon`, `stop daemon`, `refresh status`). The widget builder (`winpodx.gui.reverse_open_panel.build_panel`) imports Qt lazily so unit-testable pure-Python helpers (`PanelStatus`, `build_panel_status`, `validate_slug`, `add_slug`, `remove_slug`, `format_status_line`) can be exercised in a headless test environment; the slug + list mutation helpers share semantics with the CLI's `add` / `remove` so a slug round-trips between GUI and `winpodx host-open` without surprises. Settings page wires the panel into the existing column layout next to the RDP / Pod cards and folds save into the existing `_save_settings` flow — no separate "Save" button for reverse-open, the panel writes to the shared `cfg.reverse_open` and the page-wide save persists everything atomically. Panel construction failures don't block the Settings page (logged + skipped), so a Qt regression in this module can't take down the rest of Settings. +17 tests (`test_reverse_open_panel.py`). Phase 2c (Go shim + guest `register-apps.ps1` / `unregister-apps.ps1` + agent `/reverse-open/sync` endpoint) is the only remaining piece and stays gated on the real-Windows smoke gate. References #48. + +- **Reverse-open Phase 2b — inotify-equivalent polling listener, daemon lifecycle, app-DB hot-reload, and the four new `host-open` subcommands.** Phase 2a (PR #157) staged the apps.json manifest + per-app `.ico`; Phase 2b lights up the host-side daemon that will eventually consume guest-written request files. New modules: (1) `reverse_open/apps_db.py` — :class:`AppsDatabase` + :class:`AppEntry` with schema-validated load from apps.json + :func:`substitute_path` argv builder (first ``%f``/``%u``/``%F``/``%U`` wins, subsequent placeholders dropped, never re-shelled). (2) `reverse_open/listener.py` — :class:`Listener` with a 500 ms polling loop over the incoming dir, pre-flight refusing group/world-writable dirs and dirs owned by another uid, 64 KB size cap + 8-level JSON depth cap to defend against parser-exhaustion attacks, replay defence via Phase 1's `SeenUUIDs` ring buffer, schema validation (`version=1`, slug-shape `app`, NUL-free `path`, `\\\\tsclient\\` prefix), `safe_open_unc` TOCTOU-safe path resolution before spawn, 200-request in-flight cap with oldest-first triage, 300 s janitor cycle for stale files, and 9 counter stats surfaced via `stats_snapshot()`. Subprocess spawning is parameterised so tests capture without forking. (3) `reverse_open/lifecycle.py` — fork-fork-pipe daemon spawn with 5 s ready-sentinel, atomic 0o600 pid file under `$XDG_RUNTIME_DIR/winpodx/reverse-open.pid`, SIGTERM/SIGINT graceful shutdown, **SIGHUP triggers apps.json re-load** (so `host-open refresh` doesn't restart the daemon), stale-pid recovery (process gone but file lingering → cleanup on next status query), idempotent `start_listener`, `os._exit` on the daemon body to avoid leaking SystemExit back through the fork-without-exec parent's stack. Four new `winpodx host-open` subcommands: `start-listener`, `stop-listener`, `daemon-status`, all with `--json`; plus `refresh` now sends SIGHUP automatically when a daemon is running and reports `daemon_reloaded` in the JSON summary. +60 tests across `test_reverse_open_apps_db.py` (12), `test_reverse_open_listener.py` (27 including a real fork-and-process integration test through Phase 1's `safe_open_unc`), `test_reverse_open_lifecycle.py` (14 including a live daemon-spawn-and-process round-trip), `test_cli_host_open.py` (+5). Phase 2c (Go shim + guest `register-apps.ps1` / `unregister-apps.ps1` + agent `/reverse-open/sync` endpoint — the one that needs the real-Windows smoke gate) and Phase 2d (GUI Settings card) remain deferred. References #48. + +- **Reverse-open Phase 2a — host-side `.desktop` discovery, ICO conversion, and the `winpodx host-open` CLI.** Lifts the dormant Phase 1 foundations into a usable host-only flow: a new `winpodx host-open refresh` command walks `$XDG_DATA_HOME/applications` + `$XDG_DATA_DIRS` like `xdg-open` does, applies the existing allow / denylist filter from `cfg.reverse_open`, rasterises each app's freedesktop icon to a 16/24/32/48/64/128/256 px Windows `.ico` (Pillow for raster, cairosvg → svglib fallback for SVG, generic placeholder if neither backend is installed), and stages an `apps.json` manifest under `~/.local/share/winpodx/reverse-open/` ready for the Phase 2b sync layer to push to the guest. Discovery hardens the input heavily — rejects entries whose `Exec=` line contains shell metacharacters, drops Wine / winapps / winpodx wrappers (recursion defence), respects `Hidden` / `NoDisplay` / `OnlyShowIn` / `NotShowIn`, validates `TryExec=`, marks the user's `mimeapps.list` defaults — so a malicious `.desktop` in `~/.local/share/applications/` can't smuggle a shell command through the staged manifest into Phase 2b's spawn path. The CLI also exposes `status`, `list` (live-scan or `--cached`), `enable` / `disable`, and `add` / `remove` for allow / denylist management (with `--deny` toggle and slug-shape validation). New optional extra `winpodx[reverse-open]` pulls in `Pillow`, `cairosvg`, and `pyxdg`; without it `host-open refresh` still produces the manifest but writes a placeholder ICO per app and logs a warning. +60 tests across `test_reverse_open_discovery.py` (25), `test_reverse_open_icons.py` (14), `test_cli_host_open.py` (21). Phase 2b (inotify listener + `lifecycle` daemon + agent `/reverse-open/sync` endpoint), Phase 2c (Go shim + guest-side `register-apps.ps1` / `unregister-apps.ps1`), and Phase 2d (GUI Settings card) are deferred. References #48. + +## [0.4.4] - 2026-05-10 + +Patch release. Restores real-time `pod wait-ready --logs` output that was inadvertently buffered in v0.4.3, plus the half-uninstalled-state self-heal, the cachyos rotation/sync-password agent-first fix, the codec flag allowlist correction, the `--extra-args` escape hatch, the btrfs warning suppression, and dormant Phase 1 foundations for the agent-first install + reverse-open initiatives. + +### Fixed +- **`install.sh`'s `pod wait-ready --logs` output flows live again.** v0.4.3 piped the wait-ready invocation through `tee` to capture stderr for the half-uninstalled-state diagnostic, which had the unrelated side effect of flipping Python's stdout from line-buffered (terminal) to 4 KB-block-buffered (pipe). All `[1/3] container ...`, `[container] ...`, `[2/3] RDP`, `[3/3] activation` lines silently accumulated in the buffer and flushed in one batch when wait-ready exited — a fresh-install user saw nothing for the entire ISO download, then a wall of text at the end. `install.sh` now prepends `PYTHONUNBUFFERED=1` to the wait-ready call, which forces line-buffered stdout regardless of pipe presence. The `tee`-captured file contents are unchanged, so the `no such container` discrimination keeps working. Reported during smoke testing on 2026-05-10. + +- **Half-uninstalled state where config exists but the container is gone now self-heals on `winpodx setup --non-interactive` (called by `install.sh`).** Previously a non-purge `uninstall.sh` (which strips winpodx files but should keep the container) followed by a re-run of `install.sh` would print `Existing config found, skipping setup.` and proceed straight to `pod wait-ready`, which then exploded with `Error: no container with name or ID "winpodx-windows" found: no such container` because the container had also been wiped (either by the user picking `--purge` interactively without realising, or by some external cleanup that took winpodx's container alongside the rest). The only recovery path was a full `--purge` reinstall, which a user shouldn't have to figure out from a stack trace. Setup now detects the missing-container state in the non-interactive existing-config branch — when `cfg.pod.backend in ("podman","docker")`, `pod_status` reports anything other than RUNNING/PAUSED/STARTING, AND a direct ` ps -a` probe doesn't find `cfg.pod.container_name` in its output — and runs `ensure_ready(cfg)` to recreate the container from the existing `compose.yaml` idempotently. `install.sh`'s `pod wait-ready` failure path now also discriminates on stderr: if the captured output contains `no such container` it prints a clear "container is missing — likely from a partial uninstall" hint with the exact `--purge` recovery commands instead of the generic 60-minute-timeout message that previously masked the real cause. + +- **`winpodx rotate-password` and `winpodx pod sync-password` now try the in-guest agent first and fall back to FreeRDP RemoteApp on their own.** On cachyos, xfreerdp3's bidirectional drive redirect is broken for the headless RAIL session winpodx uses for `run_in_windows`: the script runs on the guest, but the result file written to `\tsclient\home\...\windows-exec\` never surfaces back to the host, so both `_change_windows_password` (auto-rotation + the explicit `rotate-password` command) and the `sync-password` recovery path would block until their 45 s timeout and report a channel failure. Same code paths work on openSUSE Tumbleweed where the redirect is healthy, which is why this was missed for so long. `sync-password` is the more critical of the two — it's the rescue command users reach for when rotation has already failed and cfg.rdp.password no longer matches Windows; if it also hangs, there's no exit. Both `core/rotation/_change_windows_password` and `cli/pod/_sync_password` now call `dispatch(cfg, prefer="agent")` first; if the agent isn't installed yet (`TransportUnavailable`) they fall back to the FreeRDP path exactly as before, and an agent auth failure (`TransportAuthError`) is reported without a fallback because that signals config drift, not a transient channel state. As a bonus, when the agent is reachable `sync-password` no longer needs to prompt for a recovery password at all — the agent uses bearer-token auth, so a user who can't remember their original install.bat password is no longer locked out. The pre-rotation `_ROTATION_PENDING_MARKER` recovery contract is unchanged — it still triggers on any mid-rotation disconnect regardless of which transport ran. Supersedes `docs/TRANSPORT_ABC.md` rule #6 ("NEVER use Transport for password rotation"); the rule's two original arguments — needing the OLD password to authenticate FreeRDP, and not exposing the NEW password to the agent process memory — both no longer hold (AgentTransport authenticates via bearer token; both transports expose the new password equally via PowerShell argv and `net user` argv on the guest, with one in-memory HTTP buffer on the agent path vs one on-disk script file on the FreeRDP path). + +- **Removed invalid OPTIONAL-typed codec flags from the `_BARE_FLAGS` allowlist (`+/-gfx-h264`, `+/-rfx`, `+/-nsc`, `+/-jpeg`, `+/-avc444`).** Hours after the v0.4.3 stack landed, xiyeming's first test of the new escape hatch (`winpodx app run notepad-exe --extra-args="-gfx-h264"`) failed at FreeRDP's own cmdline parser with `[ERROR][com.winpr.commandline]: Failed at index 15: Unexpected keyword`. Root cause: FreeRDP 3.x splits flags into `COMMAND_LINE_VALUE_BOOL` (genuine `+/-name` toggles) vs `COMMAND_LINE_VALUE_OPTIONAL` (only `/name:value` accepted) — the codec flags above are all OPTIONAL, not BOOL, so bare-toggle syntax was always going to fail. The original allowlist patch shipped them as bare anyway, which let unsafe-looking input pass winpodx's filter only for FreeRDP itself to reject with a confusing log line. Now those forms are dropped at the filter layer with a clear `Blocked unsafe extra_flag` warning, and the GUI tooltip / CLI help text recommend the correct workaround syntax: `--extra-args="/gfx:RFX"` (force RemoteFX, skips H.264 negotiation entirely — already accepted by the existing `/gfx` value-regex). Genuine BOOL toggles (`+/-gfx-progressive`, `+/-gfx-thin-client`, `+/-gfx-small-cache`, `+/-wallpaper`, etc.) keep working as before. + +### Added +- **Agent-first install Phase 1 foundations — host-side state model, config schema, redactor, JSON Schema, CLI stubs.** First of five phases rolling out the agent-first install architecture described in `docs/design/AGENT_FIRST_INSTALL_DESIGN.md` (PR #144). All foundational code; zero behaviour change while `cfg.install.agent_first = False` (default). New modules: (1) `core/install_state.py` — `GuestInstallStep` / `GuestInstallState` dataclasses and `fetch_install_state(cfg)` that reads `C:\winpodx\install-state\*.done` markers via the agent and falls back to a `$XDG_STATE_HOME/winpodx/last_install_state.json` cache when the agent is unreachable, never raises. (2) `core/agent_install_state.py` — atomic marker file primitives (`atomic_write_marker`, `read_marker`, `list_completed_steps`), a `RetryCounter` class wrapping `retry_counts.json` with corruption-tolerant ACID updates, and the install-failure redactor (`redact_log_line` / `redact_payload`) that strips `net user ` argv, `Authorization: Bearer ...` headers, `password=` / `token=` / `apikey=` query-style patterns, and base64 blobs > 40 chars before they ever reach disk via `write_install_failure`. (3) `core/config.py` extended with `InstallConfig` (`agent_first`, `wait_ready_stage2_secs`, `wait_ready_stage3_secs`, `auto_resume`, `watchdog_max_respawns`, `watchdog_probe_debounce_count`, `watchdog_probe_debounce_secs`) — defensive `__post_init__` clamps every numeric range and coerces malformed TOML to safe defaults without raising. (4) `cli/pod_install_status.py` + `cli/pod_install_resume.py` — argparse stubs wired into `winpodx pod`'s subcommand router with the full Phase-3 flag surface (`--json`, `--no-color`, `--logs`, `--non-interactive` for status; `--non-interactive`, `--yes/-y`, `--force` for resume), each printing "Phase 3 implementation pending" and exiting 0. (5) `docs/design/install_failure.schema.json` — JSON Schema draft-07 with `additionalProperties: false`, slug-shape `failed_step` / `error_class`, capped string lengths, and a 50-line `last_log_lines` cap. +95 new tests across `test_install_config.py` (17), `test_install_state.py` (22), `test_agent_install_state.py` (32 incl. a Hypothesis property test asserting the redactor's round-trip invariant — for any random string input, the output never contains a substring matching the redaction patterns), `test_install_cli_stubs.py` (23). Test count: 937 passed, 1 skipped (was 842 / 2). Phases 2 (install.bat refactor as state machine), 3 (host-side `wait-ready` 5-stage + `install-status` / `install-resume` implementations + GUI integration), 4 (default flip to `agent_first = True`), and 5 (legacy code path removal) are deferred. + +- **Reverse-open Phase 1 foundations — `src/winpodx/reverse_open/` module skeleton (paths, config, mime, seen_uuids).** First of four phases rolling out the guest→host file-open path described in `docs/design/REVERSE_OPEN_DESIGN.md` (#48). Four foundation modules land with no behaviour change for existing users: (1) `paths.py` translates UNC paths from the guest's `\\tsclient\...` namespace into POSIX paths under the configured share roots, with a TOCTOU-safe `safe_open_unc` atomic context manager (rewritten from the original two-function validate-then-open API after security review caught a swap window where a symlink could be substituted between the validate call and the open call — the new shape opens with `O_NOFOLLOW` and validates the realpath against the allowed roots while still holding the fd, so there is no window an attacker can race); (2) `config.py` defines the `ReverseOpenConfig` schema with a baked-in dangerous-app denylist (cmd/powershell/wscript/etc.) and `cfg.reverse_open.enabled = False` default — this PR adds zero user-visible behaviour change; (3) `mime.py` ships a curated 86-entry MIME→extension mapping covering the file types the guest is most likely to hand back, with a runtime fallback to `xdg.Mime` for anything outside the curated table; (4) `seen_uuids.py` is a persistent ring buffer of recently-handled request UUIDs for replay defence — capped size with LRU eviction, atomic write via tmp+rename so a crash mid-update can't corrupt the file. +154 tests across 4 new test modules, including a hypothesis property test covering `paths.translate_unc_to_posix` traversal invariants (random component sequences, mixed separators, encoded `..`, percent-encoded null bytes — all must either resolve inside an allowed root or raise). Test count: 814 passed, 2 skipped (was 660). Phase 2 (transport), Phase 3 (host dispatcher), and Phase 4 (guest agent integration) are deferred. References #48. + +- **`winpodx app run --extra-args` per-launch FreeRDP override + GUI Settings → Extra FreeRDP args.** Diagnostic / workaround escape hatch for distro builds that ship a broken xfreerdp3. Per-launch `--extra-args` is appended AFTER `cfg.rdp.extra_flags` so a one-shot debug flag wins over the global default; both paths run through the same `_filter_extra_flags` allowlist so the new entry point can't smuggle anything unsafe. The Settings page in the GUI now has an "Extra FreeRDP args" input bound to `cfg.rdp.extra_flags`, with placeholder + tooltip listing the most useful toggles. The allowlist itself was expanded with the codec / cache / RAIL toggle pack — `+/-gfx-h264`, `+/-gfx-progressive`, `+/-gfx-thin-client`, `+/-gfx-small-cache`, `+/-nsc`, `+/-jpeg`, `+/-avc444`, `+/-wallpaper`, `+/-themes`, `+/-decorations`, `+/-grab-keyboard`, `+/-grab-mouse`, `+/-mouse-relative`, `+/-async-update`, `+/-async-channels`, `+/-auto-reconnect`, `+/-bitmap-cache`, `+/-offscreen-cache`, `+/-glyph-cache` — without these in the allowlist they were silently dropped before reaching xfreerdp3. Critical near-term use case: `-gfx-h264` is the workaround for cachyos's xfreerdp3 build (`WITH_VAAPI_H264_ENCODING=ON` flagged experimental by FreeRDP itself), where xiyeming hit silent RemoteApp failure during RAIL post_connect — now they can set `extra_flags = "-gfx-h264"` in winpodx.toml or via the GUI to force RemoteFX fallback while we wait for `winpodx app run notepad-exe --extra-args="-gfx-h264"` to confirm the hypothesis. + +### Changed +- **Suppress dockur's cosmetic "you are using the BTRFS filesystem for /storage" warning in `winpodx pod wait-ready --logs`.** dockur's `proc.sh` fires this warning unconditionally on every btrfs host based purely on `df --output=fstype`, without checking whether NoCoW (`chattr +C`) has been applied. v0.4.3 ships per-user bind-mount + auto chattr +C migration, so on a post-migration host the warning is a false positive — the actual fragmentation signal (`COW (copy on write) is not disabled for disk image file ...`) is gone. The streamed log line is now rewritten to `(btrfs warning suppressed: NoCoW bind mount in use)` when `cfg.pod.storage_path` is set, so install.sh output stops looking alarming. Legacy users still on the named volume (`storage_path = ""`) keep seeing the original warning so they're prompted to run `winpodx setup --migrate-storage`. + +## [0.4.3] - 2026-05-06 + +The btrfs Copy-on-Write fragmentation fix. The `0.4.x` line's headline storage rework: a per-user bind mount with automatic NoCoW so Windows VM disk image writes (every pagefile / boot / swap byte) stop forking new btrfs extents on every overwrite. On btrfs hosts that means pod recreates that previously took many minutes — and frequently timed out on the 300 s budget — finish in 30 s like they always did on ext4. Reported by @xiyeming in #121, #122 (and indirectly the actual-failure cause behind #126's "Unable to open apps and desktop" cascade on cachyos). + +**Highlights — five PRs landing the fix end-to-end.** The migration design started at "chattr +C the entire podman graph root" (PR #124 — closed without merging because it would have flipped every container/volume/image on the host to NoCoW, which surprises btrfs snapshot users), pivoted to a per-user bind mount with `chattr +C` applied only to that one directory, and then took four follow-up patches as the smoke tests revealed each successive silent failure: + +1. **PR #125** — bind mount + `chattr +C` + named-volume migration tool. Fresh installs default to `~/.local/share/winpodx/storage` (chattr'd before compose-up so dockur's first raw-disk write inherits NoCoW). Existing users keep the named volume; `winpodx setup --migrate-storage` (or auto-trigger via `winpodx migrate`) moves it to the bind mount path without reinstalling Windows. +2. **PR #127** — compose-prefixed volume name resolution. `podman-compose up` materialises named volumes with the project name prefix (`winpodx_winpodx-data`, not `winpodx-data`); the migration's volume probe now tries the prefixed form first and falls back to bare. +3. **PR #128** — chattr +C outcome surfacing + post-rsync NoCoW verification. `print()` the apply / already-off / failed outcome so the user sees it during install.sh; re-`lsattr` both the dir and a sample `.img` after rsync to catch the silent-no-op case where chattr returned 0 but the flag never persisted. +4. **PR #129** — auto-migration refuses to re-run when the bind-mount target is already populated. Defends against the install.sh-runs-twice destructive scenario where a stale `cfg.pod.storage_path` would let `_maybe_auto_migrate_storage` rsync 64 GiB over the already-migrated NoCoW disk image with a fresh CoW copy. +5. **PR #130** — the actual root cause for "chattr never applies on fresh installs." `plan_migration` calls `detect_path_fs(target)` BEFORE the dir is `mkdir`'d. On openSUSE Tumbleweed `findmnt --target ` returns `rc=1` with empty stdout (the docs lie) — `detect_path_fs` returned `"unknown"`, `chattr_will_run` evaluated False, the entire chattr branch silently no-op'd, and PR #128's surfacing print never fired because the branch wasn't entered. Fix walks up to the nearest existing ancestor (max 64 steps; `/` always exists) before invoking findmnt. + +**Smoke-tested end-to-end** on openSUSE Tumbleweed btrfs: clean wipe → v0.4.2 fresh install (creates `winpodx_winpodx-data` named volume) → upgrade to main → install.sh prints `chattr +C applied to /home/.../winpodx/storage (NoCoW for new files)` between `Migrating storage...` and `OK: migrated 71 GiB`; afterwards `lsattr -d` and `lsattr data.img` both show the `C` flag, dockur's `Warning: COW (copy on write) is not disabled for disk image file /storage/data.img` no longer appears in the journal, the named volume is removed, and the next `podman-compose up` mounts the bind mount and Windows boots without any CoW warning. + +### Added +- **Per-user storage bind mount with automatic btrfs NoCoW.** `winpodx setup` on a fresh install now mounts the Windows VM disk image from a user-owned host directory (`~/.local/share/winpodx/storage` by default) instead of the legacy `winpodx-data` named volume. When the path lands on btrfs, setup runs `chattr +C` on the empty directory before compose-up — so dockur's first write of the Windows raw disk image inherits NoCoW from the parent and bypasses Copy-on-Write fragmentation entirely. The flag affects only this specific directory; other podman containers/volumes/images on the host are completely untouched (a deliberate design choice over a graph-root-wide flag, see PR #124's closed comment for the rationale). Existing users keep the named volume and see a one-line note pointing them at the migration command if their volume happens to be on btrfs. Reported by @xiyeming in #121, #122. +- **`winpodx setup --migrate-storage`.** Move an existing `winpodx-data` named volume to the per-user bind mount path without reinstalling Windows. Pre-flight plan surfaces the source/target paths, source size, target filesystem, whether chattr will run, and free space on the target. Cost: ~5-10 min on NVMe (`rsync -aS` of ~60 GiB). The Windows install is preserved — no Sysprep redo, no ISO redownload. Idempotent: pod is stopped, btrfs `chattr +C` applied to the empty target so the copy lands NoCoW, contents rsync'd, `cfg.pod.storage_path` persisted, compose regenerated, old named volume removed only on success, pod restarted. On copy failure the source volume stays intact and the target is wiped so a retry has a clean slate. Override the destination with `--migrate-storage-target PATH`; skip the confirmation prompt with `--yes`. +- **Auto-migrate on upgrade.** `winpodx migrate` (which `install.sh` calls on every upgrade) now detects the same condition — `winpodx-data` named volume, mountpoint on btrfs, `cfg.pod.storage_path` empty — and runs the migration automatically. Interactive runs prompt for confirmation (default Yes); non-interactive runs (install.sh post-upgrade path) just go ahead because the user is already in the degraded state and the whole point of running `migrate` is to apply fixes. On failure the original named volume is preserved and the user is pointed at `winpodx setup --migrate-storage` for manual retry. +- **`cfg.pod.storage_path` config field.** Empty string preserves the legacy named-volume mode; an absolute path switches compose to a host bind mount at that path. `Config.load` validates the field and falls back to empty on bad values; `_render_storage_blocks` defends against unsafe values (newlines, quotes, brace characters) by reverting to the named volume so a hand-edited `winpodx.toml` can't corrupt compose YAML. + +### Fixed +- **Auto-migration was silently never running on compose-managed installs.** `storage_migration.named_volume_exists("podman", "winpodx-data")` always returned False on installs created via `podman-compose up` because the actual volume on disk is `winpodx_winpodx-data` — podman-compose namespaces volumes by the compose project name (we declare `name: "winpodx"` in the template). Auto-migration in `winpodx migrate` and the Case-2 warning in `winpodx setup` both keyed off the bare name and therefore quietly classified every compose-managed btrfs install as "fresh-install" / "nothing to migrate". kernalix7 caught this on opensuse Tumbleweed (2026-05-06): the named volume was sitting at `winpodx_winpodx-data`, the auto-migrate hook had been dormant for hours. New `resolve_named_volume(backend)` probes the prefixed form first and falls back to the bare form, so both compose-managed and hand-created volumes resolve correctly. `MigrationPlan.source_volume` carries the resolved name through so the `volume rm` step at end of migration targets the same volume that was migrated. +- **`detect_path_fs` walks up to an existing ancestor before invoking `findmnt`.** kernalix7's 2026-05-06 third smoke test (clean wipe → install v0.4.2 → upgrade to main) revealed the SILENT root cause of "chattr +C never applies": `plan_migration` calls `detect_path_fs(~/.local/share/winpodx/storage)` BEFORE the target directory is mkdir'd. On openSUSE Tumbleweed `findmnt --target ` returns `rc=1` with empty stdout (despite docs claiming it falls back to the nearest existing parent), so the helper returned `"unknown"`. That made `chattr_will_run = (target_fs == "btrfs")` evaluate to **False**, the entire chattr branch in `execute_migration` silently no-op'd, the post-rsync NoCoW verification (PR #128) had nothing to flag because `chattr_will_run` was False there too, AND PR #128's surfacing `print()` never fired because the branch wasn't entered. Three migrations in a row landed CoW-fragmented disk images while reporting "OK: migrated N GiB" with zero indication that NoCoW had been silently skipped. Fix walks up to 64 steps from the input path to the nearest existing ancestor (terminates at `/` which always exists) before passing the path to `findmnt`, so a not-yet-mkdir'd target still resolves to the fs that will contain it. `chattr_will_run` is now True for fresh-install migration plans on btrfs targets, which means the `chattr +C` apply, the inline `print()` of the outcome, and the post-rsync `lsattr` verification all actually run. +- **Auto-migration refuses to re-run when the bind-mount target is already populated.** kernalix7's 2026-05-06 second smoke test on opensuse Tumbleweed exposed a confusing reproducer: install.sh ran a second time after PR #127 + #128 had merged, found `cfg.pod.storage_path` empty (root cause still under investigation — one possibility is install.sh's git-update step leaving a stale install dir whose `PodConfig` dataclass lacked the `storage_path` field, so `Config.load` silently dropped the value when round-tripping), AND found the named volume `winpodx_winpodx-data` recreated by `podman-compose up` with a 64 GiB raw disk. The auto-migration then ran a SECOND `rsync -aS` of 64 GiB into `~/.local/share/winpodx/storage` — which was already populated with the FIRST migration's output (including the `chattr +C`-applied `data.img` from the manual recovery `cp --reflink=never`). The second rsync silently overwrote the NoCoW disk image with a fresh CoW-fragmented copy, undoing the entire perf benefit AND any data divergence between the named volume and the bind mount that had accumulated since the first migration. New defence: `_maybe_auto_migrate_storage` now checks `default_target_path()` (`~/.local/share/winpodx/storage`) BEFORE the named-volume probe; if that path exists and is non-empty, the hook bails out with a notice telling the user how to either restore `storage_path` in `winpodx.toml` or move the populated dir aside before retrying. The bind-mount data on disk is the source of truth — if a migration already happened, re-running cannot be safe. +- **`chattr +C` outcome is now visible inline AND verified post-migration.** kernalix7's 2026-05-06 smoke test on opensuse Tumbleweed succeeded the auto-migration ("OK: migrated 64 GiB"), but `lsattr -d` afterwards showed **no `C` flag** on either the bind-mount directory or the freshly rsync'd 64 GiB `data.img` — the entire NoCoW benefit of the migration was silently missing because the chattr outcome only flowed through `log.warning` (filtered out of the install.sh foreground) and there was no post-rsync sanity check. `execute_migration` now (a) `print()`s every chattr outcome — `disabled` / `already_off` / `failed` — so the user sees inline whether NoCoW was applied, and (b) re-runs `is_cow_disabled` after rsync against both the target directory AND a sample `*.img` file from inside it; any discrepancy ("dir has +C but new file did not inherit", "chattr returned 0 but flag never persisted") is surfaced as a warning in `MigrationResult.detail` with a recovery recipe (`cp --reflink=never` of the affected file with the pod stopped). `cp --reflink=never` reads + rewrites the file inside the now-`+C` parent, so the new file is born NoCoW from inception and inherits the flag — the only safe way to retroactively fix a 64 GiB CoW disk image without reinstalling Windows. + +## [0.4.2] - 2026-05-05 + +Patch release. Two small fixes on top of 0.4.1. + +### Fixed +- **`wait-ready --logs` rewrites dockur's container-internal VNC URL to the host's mapped port.** dockur prints `visit http://127.0.0.1:8006/ to view the screen` from inside its container, where `8006` IS the listening port. The host maps that to `cfg.pod.vnc_port` (default `8007`, see `core/pod/compose.py`'s `127.0.0.1:{vnc_port}:8006` spec), so the URL streamed to the user's terminal pointed at a port only reachable inside the container. `_drain` in `_wait_ready` now substitutes `127.0.0.1:8006` → `127.0.0.1:{cfg.pod.vnc_port}` per streamed line, using the user's actual configured port (so a custom `vnc_port` in `winpodx.toml` is honored). Reported by @xiyeming. Closes #118. +- **Packaging workflows tolerate the create-release race.** `debs-publish` and `rhel-publish` both ran an `Ensure release exists` step that did `view || create`. On `v0.4.1` tag push both jobs hit the view-failed branch in parallel and both successfully called `gh release create`; GitHub's release-create POST isn't atomic on tag uniqueness, so two GitHub Releases appeared at the same tag and `release.yml`'s subsequent `softprops/action-gh-release` failed with `tag_name: already_exists`, leaving the release page without sdist/wheel attached. The create call is now idempotent: try create, swallow any `already_exists` error, then verify the release is reachable via `view`. Whichever workflow wins the race creates the release; the loser's create call no-ops; both proceed to upload assets. + +## [0.4.1] - 2026-05-05 + +The `0.4.x` line's first stable release. `0.4.0rc1` (2026-05-05) was the soak preview; `0.4.1` is the same code path plus the polish that landed during smoke-test triage. Functionally a continuation of `0.4.0`'s stability + UX focus, all of it on the install / migrate path that fresh users actually exercise. + +**Highlights — `agent never installs` regression closed for good.** kernalix7 spent ~3 days reproducing a fresh-install failure on openSUSE Tumbleweed where `setup.log` was missing entirely, `C:\OEM\agent.ps1` was never copied, and `qwinsta` showed disconnected User sessions piling up — agent never came up, menu never populated. Three independent failure modes converged on the same symptom: (1) Windows Defender real-time scan locking files in `C:\OEM\` during install.bat's first PowerShell engine load (deadlocking `PS Expand-Archive` on the rdprrap zip); (2) host-side `migrate` apply chain falling back to FreeRDP RemoteApp when the guest agent wasn't up yet, kicking install.bat's autologon session via Windows session-takeover; (3) a transient agent-respawn window after the apply chain that made the inline `app refresh` race on every smoke test. All three are fixed, with a layered defence so any single layer regressing doesn't bring back the deadlock. + +**Smoke-tested end-to-end** on openSUSE Tumbleweed: install.bat completes cleanly, `Password sync OK` via agent transport (no `ERRINFO_LOGOFF_BY_USER` cascade), apply chain via agent transport, discovery completes inline (`Discovered N app(s)` with retry-on-respawn-race) — menu populated before `install.sh` exits. Upgrade from `v0.3.0-RTM1` produces the same clean result: image-pin migration runs, apply chain succeeds via agent, interactive discovery prompt populates the menu inline. + +### Changed +- **Release trigger switched from `*RTM*` glob to a separate `REL-` marker tag.** "RTM" was the original release marker (`v0.3.0-RTM1`) but the project moved to PEP 440 / SemVer-hyphen versioning (`0.4.0`, `0.4.0rc1`) and no longer ships any RTM-suffixed tag. Mixing the marker INTO the version (e.g., `v0.4.0-REL`) would have polluted the version scheme since that's not a valid PEP 440 version, so the new design splits the two: the version tag stays clean and a SECOND tag at the same commit (`REL-v0.4.0`, `REL-v0.4.0rc1`) acts purely as the release-trigger marker. `release.yml` now matches `REL-*` and strips the prefix when extracting the version, so the public Release page is titled `v0.4.0` (not `REL-v0.4.0`) and the changelog lookup hits `## [0.4.0]` correctly. Prerelease detection covers both PEP 440 (`0.4.0rc1`) and SemVer (`0.4.0-rc1`) forms. Workflow: `git tag v0.4.1 && git tag REL-v0.4.1 && git push origin v0.4.1 REL-v0.4.1`. Plain version tags still trigger the four packaging workflows (`.deb` / RPM / OBS / AUR) via their `v*.*.*` globs; the REL marker is opt-in for the GitHub Release page only. Existing v0.3.0-RTM1-era releases stay published; the trigger change only affects future tags. +- **Release notes now include unreleased intermediate sections automatically.** `v0.4.0rc1`'s release page initially missed all of @Mic92's contributions (PRs #62-#65) because they were credited under `## [0.3.1]` — a CHANGELOG section that was written but never tagged and released, so the awk extractor (which matched exactly one `## []` and stopped at the next `## [`) skipped it entirely. The workflow now finds the most recent CHANGELOG section that corresponds to a real git tag (= a published release) and emits everything from the current section down to but not including that previous-published one, so credits in unreleased intermediate sections automatically surface in the next release page. The v0.4.0rc1 release page itself was patched manually via `gh release edit` to backfill the missing credits. + +### Fixed +- **install.bat no longer hangs at PS Expand-Archive on first boot (the `agent never installs` root cause).** `Add-MpPreference -ExclusionPath C:\OEM,C:\winpodx -ExclusionProcess rdprrap-installer.exe` is now the very first step of install.bat (before DNS / RDP / firewall etc.), so nothing under our staging dirs ever gets real-time scanned. `PS Expand-Archive` replaced with Windows-native `tar -xf` (System32\tar.exe, ships in Win10 1803+) — bypasses the PowerShell engine entirely (no module load, no .NET assembly resolution, no AMSI hookpoints), so the PS-script analysis layer can't intercept extraction even on machines with stricter security tools. `_probe_password_sync` now honors `WINPODX_REQUIRE_AGENT=1` (set by install.sh around its post-install block) and skips the probe entirely when the agent is down — the one host-side FreeRDP path PR #104 missed. Diff against v0.3.0 (last known-working baseline): install.bat's pre-extract code is essentially unchanged and the rdprrap zip blob hash is identical; what changed was the OEM bundle growing from 6 files to 13+ alongside the dockur image pin to a Windows 11 build with stricter Defender real-time policy — combination triggered the deadlock. OEM bundle 22 → 23. +- **Fresh install no longer kicks `install.bat`'s autologon session via host-side FreeRDP fallback.** `_apply_runtime_fixes_to_existing_guest` now requires the agent's /health to respond before invoking the apply chain; if it doesn't, migrate prints "Agent not yet up — Skipping runtime apply" and skips entirely. The OEM bundle on freshly-created pods already applies the same registry / service state at first boot, so this is a no-op for fresh installs. `discover_apps` honors `WINPODX_REQUIRE_AGENT=1` and raises `DiscoveryError(kind="agent_unavailable")` instead of falling back to FreeRDP. `wait_for_windows_responsive` bumps its agent /health budget from 60 s → 180 s — install.bat OEM v22+ regularly takes longer than 60 s to reach its agent-spawn step on first boot, and the previous cap was missing /health by 30-60 s every smoke test. Same regression PR #82 (release/0.3.1) introduced when it removed the `inst_cmp < (0, 1, 9) <= cur_cmp` gate around the apply chain — pre-0.3.1 the chain only ran on a 0.1.x → 0.1.9 boundary crossing, so most upgrades didn't fire FreeRDP at all and the race never surfaced. +- **install.bat no longer dies mid-script when rdprrap activates TermService.** In dockur's autologon flow, the Sysprep first-logon User session is itself managed through TermService — `net stop TermService /y` at the end of `rdprrap-activate.ps1` killed the autologon session and install.bat's cmd.exe died with it, silently, with no error. The cycle is now SKIPPED in rdprrap-activate.ps1's OEM mode; install.bat does the cycle as its LAST action after every other step has committed (launchers, `HKCU\Run`, agent spawn, `setup_done.txt`). When the cycle then kills install.bat's session, all the setup work is already on disk; autologon retry creates a fresh user session with the new TermService loaded (`termwrap.dll`), `HKCU\Run` fires, agent starts cleanly. OEM bundle 21 → 22. +- **`app refresh` and migrate's interactive discovery prompt now retry on agent-respawn race.** `_apply_vbs_launchers` (part of the apply chain) ends by spawning `agent-respawn.ps1` detached — the respawn waits ~3 s, kills the running agent, then starts a new one. If `app refresh` (or the user accepting the migrate `Run app discovery now?` prompt) hit the agent during that kill-then-restart window, `/exec` returned `Remote end closed connection without response` and discovery deferred to pending. Both call sites now retry up to 3 times with 10 s spacing; the menu populates before `install.sh` exits and migrate's interactive path completes inline. +- **install.sh waits for the agent to settle between migrate and app refresh.** A `curl /health` poll between the two steps gives the agent up to 60 s to come back after migrate's `_apply_multi_session` cycles TermService. Combined with the 3-attempt retry above, the menu reliably populates before `install.sh` exits instead of needing a follow-up CLI / GUI launch to fire pending-resume. +- **`setup.log` no longer self-locks on `Add-Content` from inside redirected PowerShell blocks.** The HKCU\Run reg-add and agent-spawn PowerShell blocks redirect stderr to setup.log via `2>>"%SETUP_LOG%"`, and used to also call `Add-Content -LiteralPath '%SETUP_LOG%'` from inside. Windows holds the redirection handle exclusively → `Add-Content`'s second open raised a sharing violation → the .NET exception then logged via the same stderr redirect → into setup.log itself, in a loop. Replaced every inner `Add-Content` with `Write-Output` and switched cmd-level redirect from `2>>` to `>>"%SETUP_LOG%" 2>&1` so PS stdout is the single writer. +- **Info screen no longer flashes "Overall: FAIL" during fresh-install warm-up.** `probe_agent_health`, `probe_guest_exec`, and `probe_guest_summary` returned `FAIL` when the agent's /health or /exec timed out. Right after install the agent is up but slow (Sysprep just finished, cold caches), and these probes regularly hit timeout for the first 30-60 s. Now timeout-shaped errors return `WARN("agent warming up or busy: ...")`, and `run_all()` does dependency-aware skipping: if `agent_health` isn't OK, downstream probes (`auth_ready`, `guest_exec`, `guest_summary`) are returned with `status="skip"` rather than each one re-running and reporting its own variation of "agent unreachable". Single warm-up signal instead of a fan-out of cascade FAILs. +- **AgentTransport selection respects host-side token availability.** `/health` is intentionally unauthenticated (the host needs to know if the agent is up before it has a token). The agent transport's `/exec` path however requires the host token; if the token file isn't readable yet (race during first install.sh run), `dispatch()` would pick `AgentTransport` based on green `/health` and then fail on the first `/exec` with token-missing. `AgentClient.auth_ready()` now returns `(bool, detail)` for whether `_token()` resolves; `AgentTransport.health()` consults it after the `/health` call and reports `available=False` if the token isn't ready. Default dispatch then falls back to `FreerdpTransport` instead of selecting agent and exploding later. Token itself is never exposed (bool + detail string only). +- **OEM bind mount no longer copies to `~/.config/winpodx/oem/` when the bundle is already user-writable.** PR #95 made `_find_oem_dir()` *always* copy the OEM tree to a user-owned path; on systems where the bundle is already user-owned (curl install, source checkout, Nix profile — the majority case), dockur's in-container `cp` then failed with `Permission denied` because `~/.config/winpodx/oem/` inherited a restrictive parent mode. Branch on `os.access(bundle_oem, R_OK | W_OK)`: if the user owns the bundle, return it directly (no copy — Podman's `:Z` relabel works fine on user-owned paths). Otherwise fall back to the user-space copy with explicit `chmod 0644` / `0755` so dockur can traverse + read regardless of host umask. +- **Defense-in-depth hardening sweep.** Config TOML revalidates on load (`Config.load()` re-runs `__post_init__()` so a tampered `winpodx.toml` with out-of-range values gets clamped); `_ensure_config()` generates a password + UTC timestamp at create time (no empty-credential window during install.sh's first run); `PasswordFilter` redacts FreeRDP's `/p:` switch in addition to `key=value` patterns; password rotation reorders `generate_compose` before `cfg.save` so a compose-generation failure leaves disk on the OLD password (no half-state). `install.bat` is now ASCII-only end-to-end (em-dash → hyphen across all comments) and uses absolute path `"%SystemRoot%\System32\tar.exe"` for tar (no PATH-hijack vector). New static checks under `tests/test_oem_install_bat.py` pin these invariants. + +### Added +- **Star History chart in README** (light/dark variants via ``). Click goes to the interactive chart on star-history.com. + +### Contributors +Thanks to @Mic92 (Nix flake packaging, default-image switch, FreeRDP argv[0] match, Wayland XWayland gate — PRs #62-#65) and @pgarciaq (SELinux OEM bind mount fix — PR #95). + +## [0.4.0] - 2026-05-03 + +A significant stability + UX release focused on the install / migrate paths. Container recreates are no longer triggered by dockur's `:latest` push cadence (image is SHA-pinned), every PowerShell console flash on app launch and agent autostart has been eliminated, fresh installs honestly wait for Windows to be ready, and multi-session activation is now hands-free via `apply-fixes` / `migrate`. SELinux-enforcing systems (Fedora) work out of the box. RTM-suffixed pods (`0.3.0-RTM1`) migrate cleanly. The `winpodx app refresh` discovery race is closed in three layers. Contributing policy + lifecycle docs added. + +### Fixed +- **Discovery error classification + GUI dialog no longer mislabels session-disconnect failures as "Pod Not Running".** Pre-this-fix the classifier mapped any error message containing `"no result file"` or `"auth"` to ``DiscoveryError(kind="pod_not_running")``. That over-fired on session-side errors where the pod was very much running -- specifically `ERRINFO_LOGOFF_BY_USER` (0x0001000C) and `ERRINFO_RPC_INITIATED_DISCONNECT` (0x00010001). Both surface a "no result file written" outer message because the script never finished writing -- but they mean the FreeRDP RemoteApp session was terminated mid-call by the guest, common during multi-session activation's TermService cycle. The GUI then offered a "Start Pod" button on a perfectly running pod (kernalix7 hit this 2026-05-03 right after a fresh install). Fix: `_classify_channel_error()` now distinguishes three states explicitly -- transport-layer failure (connection refused / connect_transport_failed / agent unavailable) → `pod_not_running`; session-layer disconnect (LOGOFF / RPC_INITIATED_DISCONNECT) → new `session_disconnected` kind; everything else → `script_failed`. The GUI handler shows a "Discovery Session Disconnected" dialog with a Retry button for the new kind, instead of a misleading "Pod Not Running" / "Start Pod" prompt. +- **`winpodx pod start` no longer fails with `lsetxattr: operation not permitted` on Fedora / SELinux-enforcing systems.** Three bugs chained: (1) `bundle_dir()` used `any()` for its marker check (`scripts/`, `config/`, `data/`) — only one had to exist for a directory to qualify as a winpodx bundle, so a stale `/usr/share/winpodx/config/` skeleton (left behind after an RPM uninstall because `agent_token.txt` was untracked) hijacked path resolution even on a curl-only install. (2) `_find_oem_dir()` returned the bundle path directly; on RPM/wheel installs that's `/usr/share/winpodx/config/oem/` (root-owned), and the compose template mounted it with `:Z` for SELinux relabeling — which fails with `lsetxattr: operation not permitted` because rootless Podman can't relabel root-owned files. (3) `stage_token_to_oem()` wrote `agent_token.txt` *into* the bundle OEM dir, so on RPM installs the runtime token landed under `/usr/share/winpodx/` (untracked by RPM, survives `rpm -e`) — feeding back into bug 1. Fixes: `_find_oem_dir()` now copies the bundle OEM tree into `~/.config/winpodx/oem/` (user-owned, `:Z`-relabel-able) and returns that path; the agent token writes there too via the same indirection. `bundle_dir()` marker check changed from `any()` to `all()` so partial leftovers no longer match. (Thanks @pgarciaq — PR #95, fixes #93.) +- **install.bat now spawns the agent directly at end-of-script, not just registers it for future logons.** HKCU\Run only fires *once per user logon*, and the autologon User session has already logged in by the time install.bat (FirstLogonCommands) executes -- so all our HKCU\Run registration was setting up was the *next* session's agent. The current session that install.bat runs in had no agent at all until the user (or some host RDP probe) opened a new session. install.sh's wait-ready phase 3 timed out at /health on every fresh install (kernalix7 reported `OK Windows ready (17:57)` with the agent-missing warning, then migrate's apply chain failed with FreeRDP rc=12 LOGOFF_BY_USER and rc=1 RPC_INITIATED_DISCONNECT errors -- consistent with running against a Windows that was healthy but had no agent). Fix: install.bat ends with a PowerShell `Start-Process` block that spawns the agent in this session via `wscript.exe + hidden-launcher.vbs` (or direct PS fallback if the wrapper didn't survive copy, with the same setup.log diagnostic as the HKCU\Run registration). Agent /health is up by the time install.bat returns -> phase 3 succeeds within seconds -> migrate's apply chain runs against a healthy agent -> no FreeRDP-fallback cascades. The HKCU\Run registration stays in place so future user logons (re-RDP after host restart, multi-session app launches) also get the agent. OEM bundle 20 -> 21. +- **`check_rdp_port` now does a real RDP handshake instead of just TCP-accept.** dockur's QEMU slirp accepts TCP forwards on the host's mapped RDP port the moment the QEMU process launches -- before the Windows guest's TermService is anywhere near up. Pre-this-fix the helper just did `socket.create_connection`, so a fresh install in mid-ISO-download (or any pod that booted QEMU but hadn't brought Windows to the RDP listener yet) would report "RDP port open" the instant the container started. install.sh's `wait-ready` skipped past phase 2 in 0 seconds, hit phase 3 (which after PR #91 returns True with a warning when /health misses), and then ran migrate's apply chain against a Windows that wasn't there -- surfacing as a cascade of FreeRDP rc=147 / "Connection reset by peer" failures. User caught this 2026-05-02 21:53 on a fresh install where the [container] log showed Windows ISO at 7% downloaded while the host had already moved on. Fix: `check_rdp_port` now sends a minimal X.224 Connection Request (TPKT-wrapped) and reads back 2 bytes. A real RDP server responds with a TPKT (first byte `0x03`, second byte `0x00`); QEMU-forwarding-with-no-guest gets us the SYN-ACK from slirp's TCP stack but the recv times out / returns EOF. Distinguishable in ~1 second. New helper `check_tcp_port` exposes the old TCP-accept-only behavior for the one place that legitimately wants it (the VNC liveness check inside `recover_rdp_if_needed` -- VNC doesn't speak RDP, so the X.224 probe would naturally fail there). +- **`wait_for_windows_responsive` no longer deadlocks `install.sh` phase 3 when agent /health doesn't come up.** Pre-this-fix the helper required BOTH an open RDP port AND `agent.ps1` /health to respond before returning. Any path that left the agent dead -- HKCU\Run mis-registered, autologon mid-cycle, agent token mismatch, port-mapping blip, install.bat staging hiccup -- caused the helper to spin out the full caller-supplied timeout (often 3600 s on `install.sh`'s `winpodx pod wait-ready --timeout 3600`). User reported sitting at `[3/3] Waiting for Windows activation...` for 30+ minutes 2026-05-02 on a fresh install where the desktop was visible via VNC. Now: stage 1 (RDP port) is required as before; stage 2 (agent /health) is best-effort, capped at `min(timeout, 60s)`. If /health responds, return True with the agent path live. If it doesn't, return True anyway (Windows IS responsive -- host code falls back to FreeRDP RemoteApp via `transport.dispatch`) plus a warning log pointing at `C:\winpodx\setup.log` for diagnostics. Only RDP-port-never-opens returns False. Same `transport.dispatch` agent-first preference everywhere else, just no longer blocking on it during boot probe. +- **install.bat staging gained per-file verification + structured fallback so the "Cannot find script file" wscript dialog can no longer block fresh install.** Pre-OEM-v20 install.bat copied the 5 launcher files to `C:\Users\Public\winpodx\launchers\` with `2>nul` suppressing any error and registered `HKCU\Run\WinpodxAgent` / `WinpodxMedia` via `reg add` pointing at those paths *unconditionally*. If the copy silently failed (network share blip mid-Sysprep, AV interference, anything), the registry entries pointed at non-existent files; on the next user logon, wscript.exe popped a "Cannot find script file" dialog that blocked the session indefinitely (kernalix7 hit this on a fresh install 2026-05-02 ~19:58). The `reg add` itself was also fragile: cmd-parsing of `\"escaped quotes\"` inside `/d "..."` survives but reg.exe stores literal backslash-quote pairs, and CommandLineToArgvW at logon evaluates them differently than a real quoted argument. Three-way fix: (1) per-file copy verification — each `copy /Y` is followed by an `if exist` check with the result logged to `C:\winpodx\setup.log` (`launcher OK:` / `launcher FAILED:` per file). (2) HKCU\Run registration moved to a single PowerShell block via `Set-ItemProperty` — clean .NET string storage, no cmd-quoting hell. (3) Existence-gated fallback — if `hidden-launcher.vbs` didn't survive copy (LAUNCHERS_OK unset), HKCU\Run is registered with the legacy direct `powershell.exe -WindowStyle Hidden -File ...` form. That fallback flashes a brief PS console (~50 ms) but at least starts the agent; without it the user got a blocking dialog and never saw the desktop. The same setup.log records which path was chosen so apply-fixes can probe it later. OEM bundle 19 → 20. +- **VBS files now ASCII-only — Windows Script Host no longer fails to parse them on non-en-US Windows installs.** `config/oem/hidden-launcher.vbs` and `config/oem/launch_uwp.vbs` had em-dash characters in their leading comments. wscript.exe reads `.vbs` files with the system default codepage (e.g., CP-949 on Korean Windows). The em-dash UTF-8 byte sequence (0xE2 0x80 0x94) decodes as a multi-byte sequence ending mid-statement → "Windows Script Host" error dialog at HKCU\Run firing time → install hangs at Sysprep. Same root cause as PR #88 fixed for `.ps1` files; this PR completes the audit by ASCII-ifying the `.vbs` files. The full set of script files (5 PS + 2 VBS) is now strict ASCII. +- **`agent-respawn.ps1` legacy fallback removed.** The old code had a `Test-Path hidden-launcher.vbs` check with an else-branch that fell back to `Start-Process powershell.exe -WindowStyle Hidden -File agent.ps1` — i.e., the same broken pattern PR #58 fixed for `HKCU\Run\WinpodxAgent`. Post-OEM-v13 the wrapper is always staged (install.bat + `_apply_vbs_launchers` both push it), so the fallback only fires on filesystem corruption or a manual delete — but when it did fire, it surfaced a console flash on every apply-fixes cycle. Replaced with `exit 1` if the wrapper is missing: HKCU\Run will refire (via wscript+hidden-launcher.vbs) on the next user logon and bring the agent back without the flash. No path that produces a flash remains. +- **`apply-fixes` no longer cycles TermService +- **First-boot app discovery is now consistent — no more "menu populated last install, missing UWPs this install" stochasticism.** Three race conditions converged on a flaky `winpodx app refresh` at install.sh time, and they fired independently so any one of them produced an empty / partial result while the others might pass: (1) Sysprep-just-finished AppX deployment was still running when discovery enumerated UWPs, returning a partial `Get-AppxPackage` view; (2) Start Menu indexer hadn't propagated all `.lnk` files yet; (3) install.sh runs migrate (which can cycle TermService for multi-session activation) immediately before app refresh, so the agent could be mid-respawn when discovery's `/exec` fired. Three layers now collapse the variance: + - **Guest-side readiness gate** (`scripts/windows/discover_apps.ps1`): waits for `AppXSvc.Status -eq 'Running'` AND ProgramData Start Menu `.lnk` count > 0 across 3 consecutive 1 s samples (catches the StartPending → Running blip), bounded at 60 s. Logs "stable, proceeding" / "budget exceeded, proceeding with potentially partial state" to stderr so guest-side timing is visible in the apply-fixes / refresh output. + - **Host-side transport readiness** (`core.discovery.discover_apps`): before invoking the script, polls agent `/health` and RDP port up to 30 s. Without it, discovery firing into a freshly-killed agent (post-multi-session-activation TermService cycle) bails with channel-failure cascade on a perfectly-good pod. + - **Host-side retry-on-empty**: if the first pass returns suspiciously few apps (< 5 total OR 0 UWP — both impossible on a stock Win11 install), waits 8 s and retries once. Picks the larger result so retry never regresses. + - Default discovery timeout bumped 120 s → 180 s to absorb the new readiness gate. Retry-on-empty is bounded (one retry max) so a stripped-down image with genuinely few apps doesn't loop. +- **`apply-fixes` no longer cycles TermService (and kills the agent) when rdprrap is *already* patched but the activation marker is stale.** PR #81 made `_apply_multi_session` self-heal by spawning `rdprrap-activate.ps1` whenever the `.activation_status` marker said anything other than `enabled`. That correctly triggered for pods with no marker (pre-OEM-v15) or a `not-activated` / `installer-failed` marker. But it also triggered for pods like kernalix7's where the marker said `installer-failed` from a partial OEM-time apply — yet `ServiceDll` had successfully been flipped to `termwrap.dll` *anyway* and multi-session was working. Re-firing activation in that state cycled TermService unnecessarily, killed the agent's RDP session, and (because `HKCU\Run` only fires at user logon) left the agent dead until the user opened an app. Repeat apply-fixes calls (each `install.sh --main` upgrade re-runs migrate's apply chain) re-killed the agent each time. Fix: before spawning the detached activator, also probe `HKLM\SYSTEM\CurrentControlSet\Services\TermService\Parameters\ServiceDll`. If it already matches `termwrap`, write `enabled` to the marker and return — this reconciles the stale marker so subsequent apply-fixes calls hit the fast path, and importantly *does not* cycle TermService. +- **`HKCU\Run\WinpodxMedia` no longer flashes a black PS console on every app launch.** Pre-OEM-v19 install.bat registered `media_monitor.ps1` (USB auto-mapping background process) with bare `powershell.exe -WindowStyle Hidden ...` — `-WindowStyle Hidden` is honored *after* conhost briefly allocates a console for the child, so a ~50ms black flash slipped through. With multi-session enabled, every app launch creates a new RDP session that re-fires HKCU\Run from scratch, so the user saw the flash on every single launch — visible-but-unreadable, classic Hidden-flag race. Fix: install.bat now registers the entry as `wscript.exe hidden-launcher.vbs powershell.exe ... media_monitor.ps1` (same wscript+SW_HIDE wrapper that PR #58 applied to `WinpodxAgent`). Migrate path: `_apply_vbs_launchers` rewrites `HKCU\Run\WinpodxMedia` if an unwrapped entry exists, so existing pods get the fix on next `winpodx pod apply-fixes` (or `winpodx migrate`). Takes effect from the *next* RDP session/app launch — the current session's already-running media_monitor stays alive but doesn't re-spawn, so doesn't flash again. OEM bundle 18 → 19. +- **`cfg.pod.image` now defaults to a SHA-pinned dockur image; migrate aligns existing pods.** Before this fix, `cfg.pod.image` defaulted to `docker.io/dockurr/windows:latest` (or `ghcr.io/dockur/windows:latest` for installs ≤ v0.3.0). Every `podman-compose up` re-resolved the tag against whatever dockur had pushed since. When the resolved digest changed (frequent — dockur's release cadence is daily-ish), podman-compose treated the spec as different and *recreated the container*. kernalix7 caught this on 2026-05-02 mid-bug: dockur had pushed a `:latest` containing a proc.sh substring failure (`proc.sh: line 137: -1: substring expression < 0`), and a routine `install.sh --main` upgrade triggered a full container rebuild + 7.5GB ISO redownload + Sysprep reset on a working pod that had nothing wrong with it. Pin: `cfg.pod.image` defaults to `docker.io/dockurr/windows@sha256:20b398ab935465f97ec8ab06489f7a85a5ad58e74e036ce66cc3c9172e7dbea8` (queried from Docker Hub registry at release time and stored as `DOCKUR_IMAGE_PIN` in `core.config`). Migrate's "already current" + cross-version paths both call a new `_ensure_canonical_image_pin` step that rewrites `cfg.pod.image` + regenerates `compose.yaml` so existing pods land on the same canonical pin a fresh main install would write — one container recreate on the next `pod start` (~30 s, storage volume preserved → no ISO redownload, no Sysprep), then immune to dockur :latest churn going forward. Idempotent: re-running migrate on an already-pinned config short-circuits before any rewrite. +- **`winpodx setup --update-image` opt-in dockur version bump.** New flag on the existing `setup` subcommand. Pulls `docker.io/dockurr/windows:latest` via the user's container backend, resolves the now-local image's repo-digest, writes it back to `cfg.pod.image`, and regenerates `compose.yaml`. Surfaces the new pin in the output so users can verify what they're locking to. Same recreate cost as the migrate path on next `pod start` (~30 s, volume preserved). The only path that ever pulls a fresh `:latest` — every other code path uses the bundled or persisted pin. + +## [0.3.1] - 2026-05-02 + +Maintenance release focused on making the v0.3.0-RTM1 → main migration path actually heal the multi-session activation gap, without forcing a container recreate. Also collapses the OEM-time and runtime rdprrap activation paths onto a single script. + +### Added +- **Nix flake.** `nix run github:kernalix7/winpodx`, `nix profile install github:kernalix7/winpodx`, or `inputs.winpodx.url = "github:kernalix7/winpodx"`. The wrapper bundles FreeRDP, podman / podman-compose, iproute2 and libnotify so the default podman backend works out of the box; docker and libvirt remain opt-in. A devShell exposes the same runtime tools plus ruff and mypy with `src/` on `PYTHONPATH`. README (en + ko) gains a Nix install section. (Thanks @Mic92 — PR #65.) + +### Changed +- **`paths.bundle_dir()` — single resolver for shipped resource trees.** Seven call sites previously hand-rolled `__file__.parent.parent…` walks with inconsistent fallbacks: discovery script lookup, OEM bundle version, OEM directory for compose mounts, VBS launcher migration, debloat script (CLI + GUI), data assets, and rdprrap version pin. Each one drifted independently — discovery already broke once with an off-by-one parent count (see #?), the OEM dir resolver missed wheel installs, the data-asset lookup couldn't find the icon under Nix. Replaced with a single `bundle_dir()` resolver in `winpodx.utils.paths` that searches `$WINPODX_BUNDLE_DIR` env var → source checkout → `sys.prefix/share/winpodx` → `~/.local/bin/winpodx-app`. Each candidate must contain at least one of `scripts/`, `config/`, `data/` to qualify, falling back to the source-checkout guess for stable error messages. Tests for affected helpers now monkeypatch `bundle_dir` directly rather than juggling `HOME` + `sys.prefix` + `__file__`. (Thanks @Mic92 — PR #65.) + +### Fixed +- **`winpodx migrate` no longer silently drops `apply-fixes` on RTM-suffixed pods (and on every cross-version upgrade past 0.1.9).** Two bugs converged: (1) `_version_tuple("0.3.0-RTM1")` returned `(0, 3)` instead of `(0, 3, 0)` because the parser stopped at the first non-int segment (`split('.')` produced `['0', '3', '0-RTM1']`; `int('0-RTM1')` raised). (2) The cross-version branch gated apply-fixes on `inst_cmp < (0, 1, 9) <= cur_cmp` — a 2026-Q1 back-port window that no longer makes sense now that the apply chain has grown to cover multi-session auto-activate / vbs_launchers / oem_runtime_fixes. Combined effect: a v0.3.0-RTM1 user running `install.sh --main` hit migrate, took the cross-version branch (because the broken tuple compare made `(0,3) >= (0,3,0)` false), then skipped apply-fixes (because `(0,3) < (0,1,9)` was also false). Their pod stayed on whatever runtime fixes RTM1 originally applied — multi-session activation included — until they ran `winpodx pod apply-fixes` manually. Fix: parser now extracts leading digits per segment so `0.3.0-RTM1`, `0.3.0rc1`, `0.3.0+dev` all parse to `(0, 3, 0)`. Cross-version gate removed; the chain is idempotent so running on a pod that's already current is a marker probe + no-op return per helper. +- **`winpodx pod apply-fixes` auto-activates rdprrap multi-session if it isn't already on.** Multi-session is core winpodx functionality — without it the "Select a session to reconnect to" dialog appears on every multi-app launch. Pre-this-fix, the apply chain's `multi_session` step was a status probe only (PR #77, defensive against the inline-activation hang where mid-apply rdprrap-conf killed the agent's session and timed out /exec). PR #80 made activation safe by spawning rdprrap-activate.ps1 *detached* — the /exec response returns before TermService cycles — so the apply chain can now self-heal: if `.activation_status` reads `enabled`, no-op (no /exec round-trip beyond the one we're in, no disconnect, no churn); otherwise queue the detached activator. Apply chain reorders so `vbs_launchers` (which stages rdprrap-activate.ps1 + hidden-launcher.vbs) runs before `multi_session`. Cost when activation is needed: RDP sessions briefly disconnect (~10 s) while TermService cycles, then reconnect with multi-session active. This is the same one-time cost the OEM-time path pays, just deferred to migration time for pre-OEM-v17 pods. +- **`winpodx pod multi-session on` activates rdprrap on existing pods without a container recreate.** Activation needs `rdprrap-installer install` plus a `net stop/start TermService` cycle so the patched `ServiceDll` is loaded — but the cycle kills every active RDP session, including the agent's own user session, so an inline `/exec` couldn't drive it (the agent died before the response could return). Pre-this-fix the only way an existing v0.3.0-RTM1 pod with a failed OEM-time activation could pick up the patch was to `podman rm -f` the container and re-Sysprep — a couple hundred MB of guest-disk churn for what is, on a working install, a 30 s registry tweak. Now: a new `rdprrap-activate.ps1` script (idempotent — falls back to extracting the bundled rdprrap-*.zip from `C:\OEM\` if the installer binary isn't staged, retries up to 3×, verifies the `ServiceDll` flip post-restart, writes the same `.activation_status` marker install.bat OEM v15+ writes) is staged into `C:\Users\Public\winpodx\launchers\` and `winpodx pod multi-session on` spawns it *detached* via wscript+hidden-launcher.vbs (mirrors agent-respawn pattern). The `/exec` call returns immediately with "OK: activation queued"; the user reconnects after the brief disconnect; the agent auto-starts via HKCU\Run; subsequent `winpodx pod multi-session status` (now a marker probe identical to the apply-fixes status surface) confirms `enabled`. `winpodx pod apply-fixes` pushes `rdprrap-activate.ps1` alongside the other VBS launchers, so existing v0.3.0-RTM1 pods get it on the next migrate without recreate. (`status` no longer shells out to `rdprrap-conf.exe`; `disable` still does, since disable just clears the registry patch and doesn't need TermService cycled.) install.bat's inline ~80 lines of installer-retry / TermService-cycle / ServiceDll-verify / marker logic also collapse onto the same script — install.bat now extracts the SHA-pinned bundle and delegates to `rdprrap-activate.ps1` (called synchronously, without `-Detached`, since at OEM time the cmd.exe parent is in the local console session and TermService cycle doesn't tear it down). Single source of truth: a fix to activation behavior now benefits both OEM-time and runtime paths without drift. OEM bundle 16 → 18. +- **`winpodx pod apply-fixes` (and the migrate apply chain) now auto-respawns the running agent under the new wscript wrapper.** The previous version of the vbs_launchers migration step staged the new wrapper into HKCU\Run but the change only took effect on the next user-session logon, so users running apply-fixes against a live pod kept seeing the autostart PS-console flash from the OLD agent instance until they manually restarted. The migration now ships a fourth helper (`agent-respawn.ps1`) and detaches a `Start-Process wscript.exe ... agent-respawn.ps1` at the end of the apply payload. The respawn waits 3 s (giving the /exec response time to land at the host), kills the running agent.ps1 process via `Win32_Process` cmdline match, waits 800 ms for port 8765 to release, then starts a fresh agent under wscript+hidden-launcher.vbs. /health drops for ~3-4 s and recovers under the new wrapper. OEM bundle 13 → 14. +- **UWP launches via the new wscript / launch_uwp.ps1 path no longer silently fail.** The first draft of `launch_uwp.ps1` (shipped in OEM v13) tried two PowerShell forms that don't work against `IApplicationActivationManager`: a PS-level `[InterfaceType]([Impl]::new())` cast, and dynamic method dispatch on the COM object. Both fail because the interface inherits IUnknown only, not IDispatch — PS can't auto-discover the methods, and a PS-level cast on a COM RCW doesn't carry the QueryInterface the CLR needs. Calculator, Settings, Terminal, Notepad UWP and friends were exiting 1 with the actual error logged only to `%LOCALAPPDATA%\winpodx\uwp-launcher.log`. Moved the activation into a small `Add-Type`'d C# helper class — the cast happens inside compiled C# scope where the managed cast resolves to a real COM `QueryInterface`. Existing pods get the fixed script via `winpodx pod apply-fixes`. +- **App launches no longer silently die after the CLI parent exits.** Two paths could spawn a doomed FreeRDP that vanished without explanation: (1) `stderr=subprocess.PIPE` left the parent process holding the read end, so once the CLI exited the next stderr write SIGPIPE-killed the detached client. The launcher now writes stderr to `$XDG_RUNTIME_DIR/.stderr` so the session outlives the parent and the tail stays inspectable; `RDPSession.stderr_tail` reads the last 2KB of that file lazily so existing callers keep their interface. (2) On a pure Wayland session without `$DISPLAY`, `xfreerdp` (the only client with working RAIL — `sdl-freerdp` lacks RAIL per FreeRDP #9078, `wlfreerdp` is deprecated with broken RAIL repaint) failed post-detach with "failed to open display". `launch_app` now refuses to spawn that combination and raises an actionable error pointing at XWayland (compositor built-in or `xwayland-satellite` for niri / river). (Thanks @Mic92 — PR #64.) +- **`is_freerdp_pid()` no longer adopts unrelated processes as live RDP sessions.** The check used to look for `b"freerdp"` or `b"xfreerdp"` anywhere in `/proc//cmdline`, which falsely matched scripts under paths like `~/freerdp-notes/run.sh`, pytest invocations passing `--deselect=test_freerdp_pid`, or any tool whose arguments mentioned freerdp in passing. Stale `.cproc` markers tied to those PIDs never got reaped because winpodx kept thinking the unrelated process was the FreeRDP it spawned. Now parses cmdline by null bytes and compares argv[0]'s basename against the binaries `find_freerdp()` actually launches (`xfreerdp{,3}`, `sdl-freerdp{,3}`, plus the `flatpak run com.freerdp.FreeRDP` fallback). Two regression tests cover the substring-match cases that used to slip through. The downstream PID-reuse test was rewritten without `bash -c "exec -a … sleep 30"` so it stops failing on systems with multi-call coreutils. (Thanks @Mic92 — PR #63.) + +### Changed +- **Default container image switched to `docker.io/dockurr/windows:latest`** (was `ghcr.io/dockur/windows:latest`). Aligns with upstream's official compose / `docker run` reference (the upstream README and example `compose.yml` both use `dockurr/windows`). Same image — verified by digest. Some users hit token / 4xx errors on the GitHub Container Registry path; Docker Hub serves the canonical artifact reliably. **Existing installs are not auto-migrated**: `~/.config/winpodx/winpodx.toml` persists the resolved value, so users who already ran `winpodx setup` keep the old reference. To pick up the new default, either drop the `image = "ghcr.io/..."` line from `winpodx.toml` and re-run `winpodx setup` (regenerates `compose.yaml`), or edit `~/.config/winpodx/compose.yaml` directly. (Thanks @Mic92 — PR #62.) +- **Zero PowerShell-window flashes — guest path goes through hidden VBS launchers and the agent transport everywhere a flash was still possible.** Three fixes converge: + - **Agent autostart via `hidden-launcher.vbs`.** HKCU\Run used to register `powershell.exe -WindowStyle Hidden -File C:\OEM\agent.ps1`; the Hidden flag is honored AFTER PowerShell allocates its conhost so a brief PS console flashed for ~50ms on every user logon. The new VBS wrapper is GUI-subsystem (no console of its own) and `WshShell.Run` with `intWindowStyle=0` propagates `SW_HIDE` to `CreateProcess` so the spawned PowerShell starts windowless. + - **UWP launches via `IApplicationActivationManager`.** The legacy `/app:program:explorer.exe,cmd:shell:AppsFolder\` invocation showed an explorer.exe RemoteApp window for ~300ms before the UWP frame appeared — that's the "PowerShell-like flash" users saw on Calculator / Settings / Terminal launches. RemoteApp now invokes `wscript.exe launch_uwp.vbs `, which calls `IApplicationActivationManager::ActivateApplication` directly. The UWP frame becomes the visible RemoteApp window with no transition; ~300ms faster too. + - **Remaining `run_in_windows` callers routed through the agent transport.** `core.updates`, `core.daemon.sync_windows_time`, `cli.pod.multi-session`, `cli.main.debloat`, and the GUI Tools-page debloat handler now go through `winpodx.core.windows_exec.run_via_transport`, which prefers the v0.3.0 agent's `/exec` (CreateNoWindow=$true) and only falls back to FreeRDP RemoteApp when `/health` doesn't answer. Password rotation (rule #6) and the `winpodx pod sync-password` rescue path stay on FreeRDP intentionally — they need direct credential auth. + +OEM bundle bumps to 13 (new VBS files staged into `C:\Users\Public\winpodx\launchers\` so the agent can rewrite them later without admin). Migration for existing 0.3.0-RTM1 pods is automatic: the new `_apply_vbs_launchers` apply step pushes all three files via the agent's `/exec` and rewrites `HKCU\Run\WinpodxAgent` in one round-trip, then `apply_windows_runtime_fixes` chains it after `multi_session`. Trigger with `winpodx pod apply-fixes` (or `winpodx migrate` after upgrading) — no container recreate needed. Autostart change takes effect on the next user-session logon (or `winpodx pod restart`); UWP launch fix is picked up immediately by the host's next launch. + +### Added +- **Hybrid discovery filter — essentials always shown, system shims hidden by default.** Auto-discovery on a stock Windows 11 install used to surface ~45 entries with both classes of noise: OS staples like File Explorer / Calculator / Settings missing because they aren't enumerated as Start Menu .lnk files, and system shims (`LicenseManagerShellExt`, `WindowsPackageManagerServer`, `DesktopPackageMetadata`, `microsoft-store-server`, …) cluttering the grid. The filter now ships a curated essentials allowlist (synthesizes a stub when the scan misses an essential) and a noise denylist (auto-stamps `hidden = true` in `app.toml` so the GUI grid filters them). User overrides win — toggling Hide / Show on a tile records the preference in the same TOML and survives the next discovery sweep. discover_apps.ps1 also emits the three essentials explicitly with their real Windows icons (File Explorer's icon comes from `C:\Windows\explorer.exe`, Calculator + Settings from their AppxManifest Square logos) so users see authentic Windows iconography rather than a generic letter avatar. +- **Win32 launch args.** RDP RemoteApp builder honors a per-app `args` string from `app.toml` and forwards it as the FreeRDP `cmd:` field. Solves the longstanding "explorer.exe RemoteApp shows nothing" gotcha — the File Explorer essential ships `args = "shell:MyComputerFolder"` so launching it opens the `This PC` view in a normal window instead of trying to take over as the user shell. Existing apps with `args = ""` are unaffected. +- **Per-app .desktop descriptions.** Discovery now extracts a one-line description from the guest (`.lnk` Comment field, exe `ProductName`, or UWP ``) and lands it as the `Comment=` key in each app's `.desktop` file. Replaces the blanket `Comment=Windows application via winpodx` stamp every entry shared, so users see the actual app description in their menu / file manager tooltip. Apps without a discovered description still get the generic stamp. +- **Known-good UWP allowlist.** UWP packages whose `DisplayName` resolves to an `ms-resource:` indirection that PowerShell can't expand in a non-interactive session (Calculator, Terminal, Paint, Snipping Tool, Camera, Alarms, Maps, Sound Recorder, Notepad UWP, Sticky Notes, Get Help, Your Phone, To Do, Settings) used to fall back to their dotted `PackageFamilyName` and get filtered out as junk by the host's UWP-dot-only check. They're now allowlisted explicitly so legitimate Microsoft.* packages aren't dropped along with the AAD/BrokerPlugin-style shims. +- **GUI Apps page "Hidden (N)" toggle.** Hidden entries collapse by default; the count chip in the toolbar tells the user how many got filtered. Clicking the chip expands the grid to include them so the user can promote any that the denylist over-classified. +- **Demo screenshot in README hero.** `docs/images/demo.png` (Windows About / Performance Monitor / PowerShell each in their own Linux window alongside the winpodx Apps grid) now ships at the top of the README so first-time visitors see what the integration looks like. + +## [0.3.0] - 2026-04-30 + +Major release — modular core, HTTP guest agent, and a comprehensive health-check surface. Replaces the FreeRDP RemoteApp pipeline as the default host→guest command channel. + +### Background + +v0.2.2 / v0.2.2.1 were an earlier attempt at these features that landed broken on real installs (PS-window storms, "Another user is signed in" dialogs, install timeouts, RST from `/exec` because compose was missing the `8765` port mapping). main was rolled back to v0.2.1 on 2026-04-29 and the agent + transport feature redesigned from scratch with explicit anti-goals (see `docs/AGENT_V2_DESIGN.md`). v0.3.0 is that redesigned implementation; the `0.2.2.x` tags have been deleted to avoid confusion — go directly from `v0.2.1` to `v0.3.0-RTM1`. + +### Added +- **HTTP guest agent (rev4).** `agent.ps1` runs inside Windows on `127.0.0.1:8765`, bound via `+:8765` so QEMU's user-mode NAT can reach it. Bearer-authed `/exec` (base64-encoded PowerShell payloads) replaces FreeRDP RemoteApp as the default host→guest command channel; `/health` stays unauthenticated for the readiness probe. Child PS spawned via `[Diagnostics.Process]` + `CreateNoWindow=$true` + async `ReadToEndAsync` — no PS-window flashes, no pipe-buffer deadlocks. Token delivered via the OEM bind mount (mode `0600` on host, gitignored). +- **Transport ABC v1** (`core/transport/{base,agent,freerdp,dispatch}`). `dispatch()` prefers the agent and falls back to FreeRDP when `/health` doesn't answer. Password rotation explicitly **never** routes through Transport (rule #6 in `docs/TRANSPORT_ABC.md`) — rotation owns its own credentials and calls `run_in_windows` directly to avoid a bootstrap loop on stale-password recovery. +- **`winpodx check` health probes.** New CLI command runs a fast multi-source health audit and prints one line per probe with `OK` / `WARN` / `FAIL` / `SKIP` and per-probe duration. `--json` emits machine-readable output for scripting. Exit code is `0` unless any probe is `FAIL`. Probes: + - `pod_running`, `rdp_port`, `agent_health` — surface bring-up state + - `guest_exec` — POSTs `/exec` with a trivial `Write-Output ok` and verifies rc=0 + stdout="ok". Proves the host→guest channel actually round-trips, not just that `/health` answers + - `guest_summary` — single `/exec` returning Windows version + uptime + current user + active session count + C: free space + - `oem_version`, `password_age`, `apps_discovered`, `disk_free` — host-side state +- **GUI Info page Health card with auto-refresh.** The Info page gains a top "Health" section that renders each probe with a coloured status badge plus the overall verdict. While the page is visible the card auto-refreshes every 30 s; off-page the timer is paused so the guest isn't polled while idle. +- **Sidebar transport indicators.** The pod chip in the top bar now shows two extra letter dots — `A` (guest agent) and `R` (RDP port) — that turn green when reachable and red when not. Hover surfaces "agent OK (version)" / "host→guest commands fall back to FreeRDP RemoteApp" tooltips so the user sees at a glance which channel will service the next launch. Updated by the existing 15 s pod-status timer. +- **`install.sh` ref selection.** `--main` installs from `origin/main` (development), `--ref TAG` installs from a specific git ref / release tag. Default with no flag uses the latest GitHub Release. Added together with the RTM-only release gate so rapid-iteration tags between RTM points don't trigger AUR / OBS / Debian publishes that overwrite working installs. + +### Fixed +- **Discovery script path off by one.** `_ps_script_path` walked four `.parent`s and resolved to `/src/scripts/windows/discover_apps.ps1`, which never exists in any layout. Walked five now so the resolution lands on the actual `/scripts/windows/` directory; clicking GUI Refresh stops popping the "Pod Not Running" dialog when the pod is fine. +- **GUI misclassified `script_missing` as `pod_not_running`.** `_looks_like_pod_down` matched the substring "pod" inside install paths like `winpodx-app/...`, so any DiscoveryError with the install path in its message was routed to the wrong dialog. The RefreshWorker now reads the explicit `DiscoveryError.kind` first and only falls back to the substring heuristic when no kind is set. +- **Agent `/exec` returned `rc:null` after clean child exit.** PowerShell's `Start-Process -PassThru` + `WaitForExit(timeout)` can leave `$proc.ExitCode` as `$null` even when the child exited normally. The agent (rev4) now coerces the null to `0` at the source and the host `AgentClient` tolerates `rc:null` as `0` so existing pods on rev2 / rev3 stay functional. +- **Agent flashed a PowerShell window for every `/exec`.** `Start-Process -NoNewWindow` from a hidden parent (`-WindowStyle Hidden` via HKCU\Run) re-creates a console for fast-exiting children. agent.ps1 (rev4) now spawns via `[Diagnostics.Process]` + `ProcessStartInfo` with `CreateNoWindow=$true` and `UseShellExecute=$false`; stdio is drained via async `ReadToEndAsync` to avoid pipe-buffer deadlocks. `WINPODX_OEM_VERSION 11 → 12` so the install path picks up the new agent on the next pod recreate. + +## [0.2.1] - 2026-04-28 + +Minor bump (0.2.0.x → 0.2.1) — bundled UX work: install never abandons partial state, GUI logs surface winpodx's own log live, GUI greets first-time users with a system check. + +### Added +- **`utils.pending` resume system.** New `~/.config/winpodx/.pending_setup` marker tracks which install.sh steps couldn't complete (`wait_ready` / `migrate` / `discovery`). The next CLI invocation (any subcommand other than `version`/`help`/`uninstall`/`config`/`info`) and every GUI startup picks up the marker and runs the missing steps in canonical order. Each step removes itself from the marker on success; the file is deleted once empty. 10 unit tests cover ordering, idempotency, partial completion, and the "guest still booting → don't waste retries on later steps" guard. +- **GUI Quick Start dialog on first launch.** A one-shot welcome modal shows a 5-bullet snapshot — backend / FreeRDP / pod state / RDP listener / discovered apps count — and notes whether resume is running in the background. Dismiss writes `~/.config/winpodx/.welcomed` so returning users aren't pestered. +- **GUI Logs page auto-tails the winpodx app log.** Navigating to Tools/Terminal now starts a `tail -F ~/.config/winpodx/winpodx.log` stream by default so users see internal program logs (apply / probe / refresh / pod state transitions) alongside the existing on-demand container log buttons. The streamer is killed automatically when leaving the page. + +### Changed +- **install.sh wait-ready timeout 1800s → 3600s.** Extends the budget to one hour so a fresh install on slow hardware (Windows ISO download + Sysprep + OEM apply on first run) can finish in-line instead of bailing out and leaving work for the resume hook. The resume hook still picks up anything that exceeds the hour. +- **Default `pod.max_sessions` 10 → 25 and `pod.ram_gb` 4 → 6.** 10 was tight for a real-world setup (Office + Teams + Edge + a couple side apps simultaneously). The new RAM default keeps the session-budget warning silent at 25 sessions (2.0 base + 25 × 0.1 ≈ 4.5 GB needed). Setup wizard's tier auto-detect (below) further tunes both per machine. + +### Added (additional) +- **Host-spec auto-tier in setup.** New `utils.specs.detect_host_specs` reads `/proc/meminfo` + `os.cpu_count()` and `recommend_tier` maps to one of three presets: + + Host RAM Host CPU Tier VM CPU VM RAM + >=32 GB >=12 thr high 8 12 GB + 16-32 GB 6-12 thr mid 4 6 GB + <16 GB <6 thr low 2 4 GB + + Both axes must clear the threshold to move up — a 64 GB / 4-core host still gets "low" since CPU is the bottleneck for the VM workload. Interactive setup pre-fills the suggested values; non-interactive applies them directly. 10 unit tests cover both-axis-clear, single-axis-poor, threshold edges. + +### Fixed (additional) +- **`_apply_max_sessions` wrote to the wrong registry key.** The runtime apply targeted `HKLM\...\Terminal Server\MaxInstanceCount` but Windows actually reads `HKLM\...\Terminal Server\WinStations\RDP-Tcp\MaxInstanceCount`. Result: every release since session-cap shipping silently no-op'd cfg changes — only `install.bat`'s OEM-time value was authoritative. v0.2.1 writes the correct subkey (with `fSingleSessionPerUser` still at the Terminal Server root, where it actually lives) and bumps the OEM-time install.bat ceiling 10 → 50 so cfg values up to the [1, 50] clamp aren't silently capped at install time. +- **Zombie disconnected sessions caused "Select a session to reconnect to" dialog every launch.** `MaxDisconnectionTime` was set to `0` in both `install.bat` and `_apply_rdp_timeouts`. In RDP semantics that means **no timeout** — disconnected sessions stay alive forever. Each FreeRDP window the user closed left a session in `Disc` state, so the next launch triggered Windows' built-in reconnect prompt with all prior sessions listed. rdprrap multi-session lets sessions coexist but **doesn't** suppress that prompt. v0.2.1 changes the value to `30000` (30 seconds) — disconnected sessions auto-logoff after 30 s, so the user can close and reopen apps freely without accumulating zombies. Patched in `install.bat` (for fresh containers) and `_apply_rdp_timeouts` (for existing containers via the runtime apply). + + + +### Fixed +- **Second SEGV path on GUI Refresh — Python ref / Qt deleteLater race.** v0.2.0.10 fixed the QImage-on-worker-thread crash, but a second SEGV remained: `_on_refresh_succeeded` and `_on_refresh_failed` slots both did `self._refresh_worker = None` immediately. Python's reference drop raced with Qt's queued `worker.deleteLater()` event — whichever ran second hit a freed/being-freed `QObject` and crashed in `~QObject()` on the worker thread. Coredump on 2026-04-28 confirmed: top frame `QObject::~QObject` on worker thread 2282062, while main thread 2281803 was inside the slot's PySide6 `callPythonMetaMethod` dispatch. Fix: drop `_refresh_worker` / `_refresh_thread` Python refs only via `_cleanup_refresh_worker`, bound to `thread.finished` which fires after both Qt objects are fully torn down. Worker `deleteLater` keeps running on the worker thread's own event loop as Qt intends — no Python GC interference. + + + +### Fixed +- **GUI Refresh button SEGV.** `_DiscoveryWorker.run()` (Qt worker thread) calls `persist_discovered` which calls `_validate_png_bytes`, which used `QImage.loadFromData` — but Qt + libgallium / Mesa state on Wayland race when `QImage` is touched off the main thread, dropping a `Signal: 11 (SEGV)` core. v0.2.0.10 has `_validate_png_bytes` short-circuit to the stdlib chunk walker when `threading.current_thread() is not threading.main_thread()`. The walker still enforces CRC + dimension caps + IEND terminator, so off-main-thread callers get a slightly slower but crash-free path. +- **install.sh wait-ready 600s → 1800s.** A fresh install (`uninstall --purge` then re-install) downloads the ~7.5 GB Windows ISO + extracts + Sysprep + OEM apply + final reboot — that's 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 `.desktop` entries** (parity with `winpodx app refresh` CLI). Previously only the CLI path inline-registered entries — clicking Refresh in the GUI updated the discovered tree but left `~/.local/share/applications/` untouched. v0.2.0.10's `_DiscoveryWorker` runs `_sync_desktop_entries` which is the worker-thread-safe sibling of `cli/app._register_desktop_entries`. + +### Added +- **GUI auto-discovery on first boot.** When the pod transitions to `running` AND the app list is empty, the main window auto-fires the Refresh worker after a 2s settle. Solves the case where install.sh's wait-ready timed out before Sysprep finished — the user opens the GUI later, sees the pod running, and discovery just happens. +- **GUI live log streaming.** The Tools/Terminal page gained four new buttons: `Live (pod)` and `Live (app)` start a `tail -F` against the container or `~/.config/winpodx/winpodx.log` and stream new lines into the panel as they appear; `App log` shows the last 200 lines of winpodx's own application log; `Stop tail` kills the active streamer. Previously the page only showed the last 100 pod logs as a one-shot snapshot. + + + +### Fixed +- **2nd app launch triggered Windows "Select a session to reconnect to" dialog instead of showing an independent app window.** Default Windows refuses concurrent FreeRDP RemoteApp sessions per user, so every app launched after the first one was either embedded in the existing session or popped a reconnect dialog. v0.2.0.9 adds `_apply_multi_session` to the self-heal apply chain — it shells out to `rdprrap-conf --enable` inside the guest so termsrv.dll allows independent per-launch sessions. Idempotent (no-op when already enabled), tolerates rdprrap-conf missing on older OEM bundles by treating it as a best-effort skip. +- **Stale `.desktop` entries lingered in the user's DE menu after apps were removed from the Windows guest.** v0.2.0.8 added auto-install on refresh but never removed entries that no longer matched a discovered app. v0.2.0.9 makes refresh truly bidirectional: any `winpodx-*.desktop` not corresponding to an entry in `list_available_apps()` is removed (along with its icons), so uninstalling Office on the Windows side actually drops Word/Excel/PowerPoint from the launcher on next refresh. User-authored entries under `~/.local/share/winpodx/data/apps/` are preserved. + +### Changed +- **README is more informative.** Big "Status: Beta" + "Latest release" badges at the top in `for-the-badge` style. Standard shields row (license, Python, backend, language, tests, CI) below. Social row (stars, forks, watchers, unique visitors). Activity row (issues, PRs, last commit, code size). EN + KO mirrored. + + + +### Fixed +- **`winpodx app refresh` discovered apps but never registered them in the desktop menu.** The refresh path persisted `app.toml` + icons under `~/.local/share/winpodx/discovered/` but the actual `.desktop` entries were only created by the separate `winpodx app install-all` command — so users saw "Discovered N app(s)" then had no apps in their DE menu. v0.2.0.8 has refresh auto-install entries for the discovered set inline (best-effort: failures are warned but don't abort the refresh) and refresh the icon cache afterwards. +- **PowerShell window flashed on every app launch.** `ensure_ready`'s self-heal apply path fired three FreeRDP RemoteApp PowerShell payloads on every single app launch — even though `-WindowStyle Hidden` makes them tiny, they still flashed visibly each time, which got annoying fast. The applies are idempotent on the registry side, so re-running them on warm pods accomplished nothing visible. v0.2.0.8 stamps `~/.config/winpodx/.applies_stamp` with `:` after a successful self-heal — subsequent launches short-circuit until the pod restarts (so TermService / NIC settings re-apply after a Windows reboot) or winpodx upgrades. + + + +### Fixed +- **`pod wait-ready --logs` showed no `[container]` lines on a fast container.** Two issues: (1) the tail was started with `--tail 0` which means "show only logs emitted from now onwards", but dockur often prints Windows ISO download progress + boot stage transitions *before* wait-ready runs — so the user saw nothing. (2) Only `stdout` was being drained; dockur splits progress across stdout (download bytes/sec) and stderr (boot phase), so half the messages were silently dropped. v0.2.0.7 bumps `--tail 100` so the user sees recent context immediately, and drains both streams in parallel threads. + + + +### Fixed +- **`wait_for_windows_responsive` collapsed in <1s on a still-booting guest, defeating the entire `pod wait-ready` UX.** The helper waited correctly for the RDP TCP port to open, then fired exactly **one** FreeRDP RemoteApp probe — a single failure (which is what every still-booting guest produces, rc=147 connection-reset) returned False immediately and the caller's 600s timeout was effectively ignored. v0.2.0.6 turns the probe into a retry loop that fires repeated 5-20s probes until either one succeeds or the overall `timeout` expires (paced 3s between attempts so we don't pin a CPU spinning FreeRDP). Now `pod wait-ready --timeout 600` actually waits up to 10 minutes — observable in the elapsed-time stamp incrementing during phase 3. + + + +### Added +- **`winpodx pod wait-ready [--timeout SEC] [--logs]`** — multi-phase wait gate for Windows VM first-boot. Polls three checkpoints with elapsed-time stamps so the user actually sees progress instead of a silent multi-minute hang: + - `[1/3] Container running` (~5s) + - `[2/3] RDP port open` (typically 30-90s) + - `[3/3] Windows ready (RemoteApp probes OK)` (typically 2-8 min on first boot) + With `--logs`, container stdout is tailed in a background thread and surfaced as `[container] ...` lines so the user can see Windows actually doing work (Sysprep, OEM apply, etc.) instead of a black box. + +### Changed +- **`install.sh` is now single-shot — install.sh exits when the install is actually finished, not when the container started.** The flow is now `setup` → `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 saw `Installation complete!` while Windows was still silently booting and had to wait again on first app launch. Skip the wait with `WINPODX_NO_WAIT=1` for CI / non-interactive setups; skip discovery with `WINPODX_NO_DISCOVERY=1`. +- Removed the redundant `winpodx pod apply-fixes` call from install.sh — `migrate`'s "always-apply" path (since v0.1.9.3) already runs the apply, so calling it again just doubled the wait. + + + +### Fixed +- **Bogus "cfg.password does not match Windows" warning on every fresh `--purge` install.** v0.1.9.5 added `_probe_password_sync` to detect cfg/Windows password drift before apply, but its error classifier matched on `"no result file"` OR `"auth"` in the FreeRDP error string. On a still-booting guest (which is exactly what every fresh install hits), FreeRDP returns rc=147 `ERRCONNECT_CONNECT_TRANSPORT_FAILED` (connection reset by peer) wrapped in the host's `"No result file written"` envelope — and the classifier saw `"no result file"` and yelled drift. v0.2.0.4 fixes this two ways: + 1. The probe now waits on `wait_for_windows_responsive(timeout=180)` first; if the guest isn't ready, it skips with `(probe deferred — guest still booting; will retry on next ensure_ready)` instead of misfiring. + 2. The classifier now distinguishes transport-level failures (`rc=131`, `rc=147`, `transport_failed`, `connection reset`) from genuine auth failures (`logon_failure`, `STATUS_LOGON_FAILURE`, etc.) — only the latter trigger the "run sync-password" warning. + + + +### Fixed +- **Discovery hit the same boot race the apply path used to.** v0.2.0.1 gated `_apply_*` and `pod apply-fixes` on `wait_for_windows_responsive`, but `winpodx migrate`'s "Run app discovery now?" prompt and `provisioner._auto_discover_if_empty` (fired by ensure_ready on first pod boot) still launched the FreeRDP RemoteApp channel without a probe. On a fresh `--purge` reinstall the Windows VM was still booting inside QEMU when discovery fired, so the scan collapsed with `ERRCONNECT_CONNECT_TRANSPORT_FAILED [0x0002000D]` (rc=147, connection reset by peer) and the user ended up with an empty app menu. v0.2.0.3 wires the same probe into both discovery call sites — discovery now waits, then either scans or skips with a "Re-run later with: winpodx app refresh" pointer. +- **First-boot timeout 90s → 180s.** Real-world fresh installs on slower hardware can take more than 90s for Windows + RDP + activation handshake. Bumped the wait budget on all three apply / discovery probes to 180s so a one-shot install actually completes the apply round on first try. + + + +### Fixed +- **Fresh `--purge` reinstall reported a bogus "0.1.7 -> X detected" upgrade.** `winpodx setup` saved `winpodx.toml` but never stamped `installed_version.txt`, so the follow-up `winpodx migrate` (which `install.sh` chains automatically) saw the config + missing marker and hit the pre-tracker fallback that assumes baseline 0.1.7. The fallback is correct for genuine upgrades from before the marker existed, but for a fresh install it ran every migration step needlessly and printed a confusing "What's new in 0.1.8 / 0.1.9 / …" wall. v0.2.0.2 has setup write the current version to `installed_version.txt` if it doesn't already exist, so a fresh install reports as the current version (no migration steps fire) while a real upgrade flow still works as before. + + + +### Fixed +- **Apply cascade collapsed on cold container.** v0.2.0 fired the three idempotent runtime applies (`max_sessions`, `rdp_timeouts`, `oem_runtime_fixes`) the moment `pod_status` reported `RUNNING`. The dockur Linux container reaches `RUNNING` in seconds, but the Windows VM inside QEMU needs another 30–90s before its RDP listener can accept FreeRDP RemoteApp activation. Within that window every apply collapsed with either `ERRCONNECT_CONNECT_TRANSPORT_FAILED [0x0002000D]` (rc=147, RDP socket open but server not initialized — connection reset by peer) on a fresh install, or `ERRCONNECT_ACTIVATION_TIMEOUT [0x0002001C]` (rc=131, FreeRDP connected but activation phase didn't complete) on `winpodx pod restart`. Each apply waited the full 60s timeout, so the cascade ran 3min before surfacing as a Launch Error dialog or "3 of 3 applies failed" panic message during `winpodx setup` → `winpodx migrate`. +- New `wait_for_windows_responsive(cfg, timeout=90)` helper: polls `check_rdp_port`, then fires a 20s no-op `Write-Output 'ping'` probe to confirm the FreeRDP RemoteApp channel is actually live. Used as a precondition by: + - `ensure_ready()` warm-pod path — skips the self-heal apply block entirely if the guest isn't responsive yet. + - `winpodx pod apply-fixes` CLI — explicit "Waiting for Windows guest to finish booting (up to 90s)…" message so users know it's not hung. + - `winpodx migrate` apply step — same wait, with a clear "guest still booting; run apply-fixes later, or just launch any app" message instead of three channel-failure stack traces. +- `_self_heal_apply()` (new) — wraps the warm-pod ensure_ready apply block in `WindowsExecError` swallow so a transient channel failure logs a warning and stops further attempts in the same call instead of cascading. The next ensure_ready picks up where this one left off. + + + +### Fixed +- **`oem_runtime_fixes` failed on first apply with `AllowComputerToTurnOffDevice` parameter error.** v0.1.9.5 shipped the runtime apply through FreeRDP RemoteApp PowerShell, but the payload still passed `Set-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false`. The cmdlet expects the enum string `'Disabled'` / `'Enabled'`, and on virtual NICs (virtio inside QEMU) the parameter often isn't exposed at all. v0.2.0 wraps the call in `try/catch`, switches to the enum form, and skips adapters that don't support it — apply now passes regardless of NIC topology. +- **migrate password-drift probe timed out at 20s.** FreeRDP's first-contact handshake on a cold pod regularly exceeds 20s (TLS + auth + RemoteApp launch). v0.2.0 bumps the probe budget to 60s so genuine drift detection isn't masked by cold-start latency. + +### Added +- **Streaming refresh progress.** `winpodx app refresh` previously hung silently for 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_windows` accepts a `progress_callback`, the wrapper exports `$Global:WinpodxProgressFile` + `Write-WinpodxProgress`, and `discover_apps.ps1` emits one line per source. Host CLI surfaces them as `... Scanning Registry App Paths...` etc to stderr (JSON output stays clean). +- **`winpodx pod multi-session {on|off|status}`** — runtime toggle for the bundled rdprrap multi-session RDP patch. Shells out to `rdprrap-conf.exe` inside the Windows guest via FreeRDP RemoteApp, so users no longer need to recreate the container to enable / disable / inspect the patch. Tries `C:\OEM\rdprrap\rdprrap-conf.exe`, `C:\OEM\rdprrap-conf.exe`, `C:\Program Files\rdprrap\rdprrap-conf.exe` in order. +- **Discovery junk filter.** Refresh used to surface uninstallers (`unins000.exe`, "Uninstall …"), redistributables (`vc_redist.x64.exe`, "Microsoft Visual C++ …"), helpers (`crashpad_handler.exe`), inbox accessibility tools (`narrator.exe`, `magnify.exe`, `osk.exe`), system plumbing (`ApplicationFrameHost.exe`, `RuntimeBroker.exe`), and unresolved UWP entries whose DisplayName fell back to `PackageFamilyName` (e.g. `Microsoft.AAD.BrokerPlugin`). v0.2.0 drops these via host-side denylist patterns + executable basename matching + UWP fallback detection. Set `WINPODX_DISCOVERY_INCLUDE_ALL=1` to bypass for debugging. +- **GUI app icons.** Discovered apps now render their actual Windows icon (PNG / SVG) in the launcher's grid cards and list tiles, instead of the colored single-letter avatar. Icons are stored next to `app.toml` under `~/.local/share/winpodx/data/discovered//icon.{png,svg}` (already populated since v0.1.8); the GUI now reads them via `QPixmap` (PNG, smooth scaled) and `QSvgRenderer` (SVG, crisp at any size). The letter avatar remains as the fallback when no icon is available. + +### Tests +- Streaming progress wrapper: Popen-based test simulates the 3-poll lifecycle with progress-file writes interleaved. +- Junk filter: 11 garbage cases dropped, 4 real apps preserved, env-bypass honored. + + + +### Fixed +- **BOM in result file caused fake "fail" reports.** v0.1.9.4 routed runtime applies through FreeRDP RemoteApp PowerShell, but the wrapper used `Out-File -Encoding utf8` which (in Windows PowerShell 5.1) writes a UTF-8 BOM. The host then `json.loads`'d the file with the default `utf-8` codec which rejected the BOM and reported "result file unparseable: Unexpected UTF-8 BOM". The registry changes from rdp_timeouts and oem_runtime_fixes had **actually applied successfully** — only the parse step failed, leaving the user thinking nothing worked. `windows_exec.run_in_windows` now reads with `utf-8-sig` so the BOM is consumed transparently. +- **`_apply_max_sessions` killed its own RDP session.** The payload included `Restart-Service -Force TermService` to make the new MaxInstanceCount take effect immediately — but TermService is exactly what's hosting the FreeRDP RemoteApp session running the script. The restart killed the session before the wrapper could write its result file, the host saw `ERRINFO_RPC_INITIATED_DISCONNECT [0x00010001]`, and the apply was incorrectly classified as a channel failure even when the registry write itself had landed. v0.1.9.5 removes the in-script `Restart-Service`; the registry write alone is enough, and TermService picks up the new value on its next natural cycle (next pod boot or `winpodx pod restart`). + +### Changed (architectural) +- **Migrated every remaining host-to-Windows command path off the broken `podman exec ... powershell.exe` channel** onto `windows_exec.run_in_windows`. Six functions had been silently no-op'ing for releases 0.1.0 through 0.1.9.4 — `podman exec` only reaches the Linux container that hosts QEMU, not the Windows VM running inside, so any call to `powershell.exe` returned `rc=127 executable file not found in $PATH` and the helpers logged a warning then returned. v0.1.9.5 ports them all: + - `provisioner._change_windows_password` (password rotation — silent for years) + - `pod.recover_rdp_if_needed` (Bug B TermService restart — never worked; replaced with a container restart since FreeRDP can't authenticate against a dead RDP listener anyway) + - `daemon.sync_windows_time` (w32tm) + - `core.updates._exec_toggle` (Windows Update enable/disable/status) + - `cli/main._cmd_debloat` and `gui/main_window._on_debloat` (debloat.ps1 — was double-broken: `podman cp` to copy the script + `podman exec` to run it) + - `core/discovery.discover_apps` (Bug A's "fix" via stdin pipe was on the same broken path; now actually goes via FreeRDP RemoteApp) + +### Added +- **`winpodx pod sync-password`** CLI command to recover from password drift accumulated under prior releases. Prompts for the "last known working" password (typically the one from initial setup, or the value still in `compose.yml`'s `PASSWORD` env var), authenticates FreeRDP with it, then runs `net user` inside Windows to set the account password to the current cfg.password value. Once the sync completes, password rotation works normally going forward. +- **migrate auto-detects password drift.** When `winpodx migrate` runs and the user is on the "already current" path, it now fires a no-op `Write-Output 'sync-check'` payload through the FreeRDP channel first. If FreeRDP fails with auth/no-result-file, migrate prints a clear "run `winpodx pod sync-password`" pointer instead of letting all three subsequent applies fail with confusing channel errors. +- **Lint test `tests/test_no_broken_podman_exec.py`** — fails CI if any future code under `src/winpodx/` (other than `windows_exec.py` itself) reintroduces the `podman exec ... powershell.exe` pattern. Single canonical channel for Windows-side commands going forward. + +### Tests +- `tests/test_provisioner.py` updated to mock `windows_exec.run_in_windows` for `_apply_max_sessions` and assert that `Restart-Service` is no longer in the payload. +- `tests/test_security.py::TestPowerShellEscape` rewritten — `_change_windows_password` now goes through `windows_exec`, so the test inspects the payload string instead of subprocess argv. +- `tests/test_pod.py::test_recover_rdp_*` updated — recover-rdp now restarts the container instead of attempting an exec-based TermService restart. +- `tests/test_daemon.py::test_sync_windows_time_uses_windows_exec_channel` rewritten for the new transport. +- `tests/test_discovery.py` — five tests rewritten to mock `windows_exec.run_in_windows` instead of `subprocess.Popen` + stdin pipe. The `HARD_STDOUT_CAP` flooding test was removed; the cap was specific to the `_run_bounded` path that discovery no longer uses. + +## [0.1.9.4] - 2026-04-26 + +### Fixed +- **Runtime apply finally actually applies.** kernalix7 reported on 2026-04-26 that v0.1.9.1 / v0.1.9.2 / v0.1.9.3 runtime apply paths were silently failing — `podman exec winpodx-windows ...\powershell.exe` returned `rc=127 executable file not found in $PATH`. Root cause: `podman exec` runs commands in the **Linux container** that hosts QEMU, not in the **Windows VM** running inside QEMU; the Linux container has no `powershell.exe`. The helpers (`_apply_max_sessions`, `_apply_rdp_timeouts`, `_apply_oem_runtime_fixes`, `_change_windows_password`) all logged a warning and returned, while the public-facing `apply_windows_runtime_fixes` reported per-helper "ok" because the helpers didn't `raise`. So three previous releases shipped silent no-ops. Three changes: + 1. **New `core/windows_exec.py`** — `run_in_windows(cfg, ps_payload)` launches PowerShell as a FreeRDP RemoteApp and pipes the script through the existing `\\tsclient\home` redirection. Wrapper writes `{rc, stdout, stderr}` JSON back via the same share. The host parses it and returns `WindowsExecResult`. Channel failures (FreeRDP missing, auth fail, timeout, no result file) raise `WindowsExecError`; non-zero script rc surfaces via `WindowsExecResult.rc`. + 2. **`_apply_max_sessions`, `_apply_rdp_timeouts`, `_apply_oem_runtime_fixes` rewritten** — each builds a PS payload, calls `run_in_windows`, and now `raise RuntimeError` on `rc != 0` so failures actually propagate. + 3. **`apply_windows_runtime_fixes` honest reporting** — `try/except` on each helper still works the same way, but now an actual `rc != 0` from inside the Windows VM produces `failed: rc=2 ...` instead of fake `ok`. + + Cost: ~5–10 s per call (RDP handshake + auth + script + disconnect) plus a brief PowerShell window flash that `-WindowStyle Hidden` minimizes. Trade-off: works on existing pods (no container recreate) and the rc check actually means something. + + **Caveat**: requires `cfg.rdp.password` to match the Windows guest's actual password. If password rotation has been silently failing for previous releases (same `podman exec` root cause), the first call here will fail with auth error and the user has to reset the Windows-side password (open `winpodx app run desktop`, run `net user User `). + +### Tests +- 9 new tests in `tests/test_windows_exec.py` covering the full lifecycle: FreeRDP missing, password missing, timeout, no-result-file (auth fail), happy path with result-file roundtrip, non-zero rc propagation, FreeRDP `/app:program:` cmd shape verification, flatpak-style binary splitting, unparseable result JSON. +- `tests/test_provisioner.py` rewritten to mock `windows_exec.run_in_windows` instead of `subprocess.run`. New tests assert each helper raises `RuntimeError` on `rc != 0` and `WindowsExecError` on channel failure. + +## [0.1.9.3] - 2026-04-26 + +### Fixed +- **Patch-version migrate skipped Windows-side apply ("already current" trap).** kernalix7 upgraded from 0.1.9.x to 0.1.9.2 and got `winpodx 0.1.9.2: already current. Nothing to migrate.` — but the actual Windows guest never received the v0.1.9.1 RDP-timeout / v0.1.9.2 OEM v7-baseline runtime fixes. Root cause: `_version_tuple(...)[:3]` truncated `0.1.9.1` and `0.1.9.2` to the same `(0, 1, 9)` tuple, so `inst_cmp >= cur_cmp` triggered the early-return BEFORE the runtime apply step ran. Migrate now still runs the idempotent runtime apply on the "already current" path so patch-version users still receive Windows-side fixes shipped after their last successful migrate. + +### Added +- **`winpodx pod apply-fixes`** standalone CLI command. Idempotent — calls `_apply_max_sessions`, `_apply_rdp_timeouts`, `_apply_oem_runtime_fixes` against the running pod and prints a per-helper OK/FAIL table. Exit code 0 on full success, 2 if pod isn't running / backend unsupported, 3 if any helper failed. Safe to re-run any time. +- **GUI Tools-page "Apply Windows Fixes" button.** Same runtime apply triggered from the Qt GUI — fires the helpers on a worker thread, surfaces success / failure via the existing toast/info-label channel. Useful for users who want the fixes applied without dropping to the CLI. +- **install.sh auto-fires `winpodx pod apply-fixes`** at the end of every install, after the migrate wizard. Failure-tolerant (`|| true`) — silent skip if the pod isn't running. This guarantees a fresh `curl | bash` always lands the latest Windows-side fixes on existing guests, regardless of whether migrate's version comparison saw a "real" upgrade. +- **Public `provisioner.apply_windows_runtime_fixes(cfg)` API** returning a `{helper_name: "ok" | "failed: ..."}` map so the CLI / GUI / migrate paths share a single entry point and surface uniform per-helper status. + +## [0.1.9.2] - 2026-04-26 + +### Fixed +- **Windows-side fixes from v0.1.9 / v0.1.9.1 weren't reaching existing guests.** kernalix7 reported "마이그레이션 잘 되는거 맞아? 윈도에 적용 안되는거같은데" — and they were right. install.bat (the OEM script) only runs at dockur's first-boot unattended setup, so users on 0.1.6 / 0.1.7 / 0.1.8 / 0.1.9 / 0.1.9.1 never picked up NIC power-save off (OEM v7), TermService failure-recovery actions (OEM v7), or RDP timeout disable + KeepAlive (OEM v8) without recreating the container. Compounding this, the v0.1.9.1 `_apply_rdp_timeouts` runtime helper was wired into `provisioner.ensure_ready` AFTER its `check_rdp_port` early-return — so the helper never fired against an already-healthy pod. + - `provisioner.ensure_ready`: probe `pod_status` once at the top and run all idempotent runtime applies (`_apply_max_sessions`, `_apply_rdp_timeouts`, new `_apply_oem_runtime_fixes`) BEFORE the RDP early-return. Re-applied after pod-start in the cold-pod path. ~1.5s overhead per call; idempotent so re-runs are no-ops. + - new `provisioner._apply_oem_runtime_fixes(cfg)` pipes the OEM v7 baseline (NIC `Set-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false`, `sc.exe failure TermService` recovery actions) to existing guests via `podman exec powershell` — same stdin-pipe transport `discover_apps.ps1` uses. + - `winpodx migrate`: when crossing the 0.1.9 boundary, proactively call all three apply helpers (with pod-state probe + interactive offer to start a stopped pod). Output reports per-helper success / failure so users can see exactly what landed without recreating their container. + +## [0.1.9.1] - 2026-04-26 + +### Fixed +- **GUI SEGV when clicking the Apps "Refresh Apps" button on a pod-not-running guest.** Reported by kernalix7 against 0.1.9: `_on_refresh_failed` constructed a `QMessageBox(self)` directly inside the queued-signal callback frame, and PySide6 + Qt 6.x can SEGV deep in the dialog's font-inheritance path (`QApplication::font(parentWidget)` -> `QMetaObject::className()`) when the parent's metaobject is queried mid-callback. The QMessageBox build is now deferred via `QTimer.singleShot(0, ...)` so the signal handler frame unwinds first. The Info page's first-fetch is also deferred out of `__init__` for the same reason. The Info page worker class was hoisted to module level (was redefined every refresh), gains a busy-state reentrancy guard, and now properly `deleteLater`s both the worker and the QThread on completion. +- **RDP sessions still drop mid-use after host suspend / long idle.** v0.1.9 Bug B fix only handled the "RDP unreachable" path; sessions could still be terminated by the Windows-side TermService timeouts (1h `MaxIdleTime` default). install.bat (OEM v7 -> v8) and a new `_apply_rdp_timeouts` provisioner step now write `MaxIdleTime=0`, `MaxDisconnectionTime=0`, `MaxConnectionTime=0`, `KeepAliveEnable=1` + `KeepAliveInterval=1` to both `HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services` and the `RDP-Tcp` WinStation, plus `KeepAliveTimeout=1` on the WinStation so TCP keep-alive fires every minute. Existing 0.1.x guests get the runtime apply on the next `ensure_ready` without needing container recreation. + +## [0.1.9] - 2026-04-25 + +### Changed +- **Discovery-first refactor.** The 14 bundled app profiles (`word-o365`, `excel-o365`, ..., `notepad`, `cmd`, ...) shipped under `data/apps/` were removed. The Linux app menu now populates exclusively via `winpodx app refresh`, which is fired automatically by `provisioner.ensure_ready` the first time the Windows pod is reachable and the discovered tree is empty. Manual rescan stays the same: `winpodx app refresh` from the CLI or the "Refresh Apps" button on the GUI Apps page. `AppInfo.source` drops the `"bundled"` enum value — only `"discovered"` and `"user"` remain. `winpodx migrate` upgrading from any 0.1.x < 0.1.9 prompts to remove legacy `~/.local/share/applications/winpodx-{14-bundled-slugs}.desktop` files (skipped automatically under `--non-interactive`). + +### Added +- **Info page (CLI + GUI).** New `core.info.gather_info(cfg)` returns a 5-section snapshot — System (winpodx version, OEM bundle version, rdprrap version, distro, kernel), Display, Dependencies, Pod (state, uptime, RDP/VNC reachability probes, active session count), Config (with the existing budget warning). `winpodx info` is rewritten to print all five sections. The Qt main window grows a 5th tab ("Info") with one card per section and an explicit "Refresh Info" button that re-runs `gather_info` on a `QThread`. All probes are hard-bounded so a sick pod can't block the panel. + +### Fixed +- **Bug A: `winpodx app refresh` on Windows.** v0.1.8 used `podman cp host:discover_apps.ps1 container:C:/winpodx-discover.ps1`, which fails because dockur/windows is a Linux container running the actual Windows guest inside QEMU — the C: drive lives in a virtual disk that `podman cp` cannot write. The script body is now piped via `podman exec -i container powershell -NoProfile -ExecutionPolicy Bypass -Command -` over stdin, removing the staging step entirely. Stderr containing recognizable runtime strings ("no such container", "is not running", etc.) is reclassified to `kind="pod_not_running"` so the cli still routes to exit code 2 + the "run `winpodx pod start --wait`" hint. +- **Bug B: RDP unreachable after host suspend / long idle.** Symptom: VNC port 8007 still works but RDP port 3390 doesn't accept connections — Windows TermService stalls and the virtual NIC enters power-save. New `core.pod.recover_rdp_if_needed(cfg)` detects the asymmetry, runs `podman exec powershell Restart-Service -Force TermService; w32tm /resync /force`, and re-probes RDP up to three times with backoff. Wired into `provisioner.ensure_ready` post-`_ensure_pod_running`. OEM bundle bumps 6 → 7 so `install.bat` adds preventive `Set-NetAdapterPowerManagement -AllowComputerToTurnOffDevice $false` plus `sc.exe failure TermService reset=86400 actions=restart/5000/restart/5000/restart/5000` for Windows-side self-recovery. + +## [0.1.8] - 2026-04-25 + +### Added +- **Dynamic Windows-app discovery.** A new `winpodx app refresh` CLI subcommand and a "Refresh Apps" button on the Qt GUI's Apps page now enumerate the apps actually installed on the Windows guest and register them alongside the 14 bundled profiles. Inside the container, `scripts/windows/discover_apps.ps1` scans Registry `App Paths` (HKLM + HKCU), Start Menu `.lnk` recursion, UWP/MSIX packages via `Get-AppxPackage` + `AppxManifest.xml`, and Chocolatey / Scoop shims, returning a JSON array with base64-encoded icons extracted from the real binaries / package logos. The host side (`winpodx.core.discovery`) copies the script via `podman cp`, executes it with `podman exec powershell`, and writes the results under `~/.local/share/winpodx/discovered//` as TOML + PNG/SVG icon files. Bundled profiles, user-authored entries, and discovered entries live in three separate directories and merge at load time (user > discovered > bundled on slug collision) so a rediscovery run only touches the discovered tree. +- **UWP RemoteApp launching.** `rdp.build_rdp_command` now accepts a `launch_uri` + strict-regex-validated AUMID (`!`) and maps UWP apps to `/app:program:explorer.exe,cmd:shell:AppsFolder\`. Per-slug `winpodx-uwp-` fallback for `/wm-class` keeps Linux taskbar grouping distinct when two UWP apps share the same hint. +- **PowerShell Core smoke test in CI.** A new `discover-apps-ps` job installs `pwsh` on the Ubuntu runner and runs `discover_apps.ps1 -DryRun` on every PR, validating that stdout parses as the JSON array shape `core.discovery` expects. +- **Post-upgrade migration wizard.** A new `winpodx migrate` CLI subcommand shows per-version release notes for every version the user has skipped over and optionally runs `winpodx app refresh` so the Windows-app menu populates in one step. `install.sh` now invokes `winpodx migrate` automatically at the end of every upgrade (existing `~/.config/winpodx/winpodx.toml` detected); opt out with `WINPODX_NO_MIGRATE=1`. Flags `--no-refresh` (skip only the refresh prompt) and `--non-interactive` (disable all prompts) are available for automation. The wizard tracks installed version at `~/.config/winpodx/installed_version.txt`; pre-0.1.8 installs without that marker are treated as upgrading from `0.1.7`. +- **`pod.max_sessions` is now configurable.** Default stays 10; clamped to `[1, 50]`. `ensure_ready()` reads the value, compares against the guest's current `HKLM:\...\Terminal Server\MaxInstanceCount`, and rewrites + restarts `TermService` only when they disagree — active RemoteApp sessions aren't dropped every provision. `fSingleSessionPerUser=0` is also re-asserted on every apply. A rough memory budget helper (`estimate_session_memory`, `check_session_budget` in `winpodx.core.config`) surfaces a warning via `winpodx config show`, `winpodx config set`, `winpodx info`, and the GUI Settings page **only when `max_sessions` over-subscribes `ram_gb`** — the default config stays silent. +- **`install.sh` local-path flags for offline / air-gapped installs.** `--source PATH` copies winpodx from a local directory instead of `git clone` (validates `pyproject.toml` + `src/winpodx/` are present). `--image-tar PATH` preloads the Windows container image via `podman load -i` (or `docker load -i`) so first boot doesn't hit the registry. `--skip-deps` skips the distro dependency install phase entirely and fails early if required tools aren't already present. Every flag has a matching environment variable (`WINPODX_SOURCE`, `WINPODX_IMAGE_TAR`, `WINPODX_SKIP_DEPS`) so `curl | bash` callers can compose them too. `install.sh --help` prints the full usage. + +### Changed +- `AppInfo` gains `source: "bundled" | "discovered" | "user"`, `args`, `wm_class_hint`, and `launch_uri` fields so the GUI can badge discovered entries and so RDP launches can target UWP apps. +- `desktop.entry._install_icon` now dispatches between `hicolor/scalable/apps/` (SVG) and `hicolor/32x32/apps/` (PNG) based on the icon file's extension, so discovered apps' extracted PNG icons install cleanly alongside the bundled SVG ones. + +## [0.1.7] - 2026-04-23 + +### Changed +- **Bundled rdprrap bumped to v0.1.3 (license-compliance release).** Upstream withdrew the 0.1.0, 0.1.1, and 0.1.2 GitHub release assets. 0.1.0 / 0.1.1 were missing the upstream source-level attribution notices required by the three projects rdprrap ports code from: `stascorp/rdpwrap` (Apache-2.0), `llccd/TermWrap` (MIT), and `llccd/RDPWrapOffsetFinder` (MIT). 0.1.2 shipped `NOTICE` + `vendor/licenses/` and closed the legal gap but listed only 9 of the 16 rdpwrap-derived Rust sources and had an internally inconsistent copyright line in the `rdprrap-conf` About dialog. 0.1.3 expands the `NOTICE` to all 16 sources (grouped by upstream binary — RDPWInst / RDPConf / RDPCheck), aligns the About-dialog copyright to match `LICENSE`, and cites CC BY 4.0 for the adapted Contributor Covenant text. It also carries forward the registry-readback fix that avoided the `termsrv.dlll` corruption in `OriginalServiceDll`. New bundle SHA256 is pinned in `config/oem/rdprrap_version.txt`; first-boot OEM version bumped to 6 so existing guests re-run the install path and pick up the compliant bundle. + +### Documentation +- Add top-level [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md) documenting the bundled rdprrap binary and the runtime/optional Python dependencies (PySide6 LGPL, libvirt-python LGPL, docker-py Apache-2.0, tomli MIT). +- `debian/copyright` now declares the bundled rdprrap files separately and notes that the in-ZIP `NOTICE` + `vendor/licenses/` texts satisfy the upstream Apache-2.0 / MIT attribution requirements. + +### Fixed +- **`install.sh` now works under `curl … | bash`.** When piped, bash reads from stdin and `BASH_SOURCE[0]` is unset; combined with the `set -u` guard at the top of the script, that aborted the installer at line 205 with `BASH_SOURCE[0]: unbound variable` before the repo could even be cloned. The local-vs-remote branch now defaults the source path to empty and falls through to the git-clone path when there is no local repository. Reported on CachyOS with Python 3.14 / fish shell ([#3](https://github.com/kernalix7/winpodx/issues/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](https://github.com/kernalix7/rdprrap) v0.1.0 inside the package (~1.6 MB zip under `config/oem/`) and auto-installs it during the Windows unattended setup, so each RemoteApp window gets its own independent session instead of stealing the previous one. The bundle is staged into the Windows guest at `C:\OEM\`, sha256-verified against a pin file, then extracted — no network access is required. Failures fall back silently to single-session. A guest-side management channel (enable/disable/status after install) is planned for a later release. + +## [0.1.5] - 2026-04-21 + +### Added +- Prebuilt RPMs for **AlmaLinux 9 / AlmaLinux 10** (also installable on RHEL 9/10 and Rocky 9/10), attached to every GitHub Release. +- Arch Linux AUR packaging (activation pending a one-time maintainer setup; see [`packaging/aur/README.md`](packaging/aur/README.md)). + +### Changed +- **Minimum Python lowered from 3.11 to 3.9.** This unblocks clean installs on distros whose default `python3` is 3.9 (RHEL 9 / AlmaLinux 9 / Rocky 9) without requiring an add-on Python module. + +### Fixed +- OBS RPM downloads no longer come up empty when picking up newly-published assets. + +## [0.1.4] - 2026-04-21 + +### Fixed +- `.deb` build no longer aborts with "missing files" during `dh_install`. +- OBS publish step tolerates unrelated build-service-side failures on obscure architectures that aren't in our target matrix. + +## [0.1.3] - 2026-04-21 + +### Fixed +- OBS publish step no longer loops on authentication errors while waiting for the build. +- `.deb` build no longer tries to run the test suite (tests run upstream in GitHub Actions instead). + +## [0.1.2] - 2026-04-21 + +### Fixed +- After a tag push, the RPM and `.deb` publish workflows now actually run and attach their artifacts to the Release. +- RPM build is resilient to the upstream `pyproject.toml` version being ahead of the latest git tag. + +## [0.1.1] - 2026-04-21 + +### Added +- **Prebuilt packages per Release**: + - RPM: openSUSE Tumbleweed, Leap 15.6, Leap 16.0, Slowroll, Fedora 42, Fedora 43. + - `.deb`: Debian 12 / 13, Ubuntu 24.04 / 25.04 / 25.10. + - Source dist + wheel on PyPI-compatible artifacts. +- README "Install" section now lists distro-specific commands. + +### Changed +- AppImage packaging removed: Python + Qt + FreeRDP + Podman dependencies reduce its single-file-distribution value to near zero. + +### Fixed +- Weekly upstream update checker creates a tracking Issue instead of failing on permission errors. + +## [0.1.0] - 2026-04-21 + +First public release. + +### Added +- **Zero-config auto-provisioning**: first app launch creates config, compose file, container, and desktop entries automatically. +- **14 bundled app profiles**: Word, Excel, PowerPoint, Outlook, OneNote, Access, Notepad, Explorer, CMD, PowerShell, Paint, Calculator, VS Code, Teams. +- **Auto suspend / resume**: container pauses on idle, resumes on next app launch; graceful shutdown on exit. +- **Password auto-rotation**: 20-char cryptographic password, 7-day cycle (configurable), automatic rollback on failure. +- **Manual password rotation**: `winpodx rotate-password`. +- **Office lock-file cleanup**: `winpodx cleanup` removes `~$*.*` lock files from the home directory. +- **Windows time sync**: `winpodx timesync` re-synchronizes the Windows clock after host sleep/wake. +- **Windows debloat**: `winpodx debloat` disables telemetry, ads, Cortana, search indexing. +- **Power management**: `winpodx power --suspend/--resume` manually pauses/resumes the container. +- **System diagnostics**: `winpodx info` reports display, dependency, and configuration status. +- **Desktop notifications** (D-Bus / `notify-send`) surface on app launch. +- **Smart DPI scaling**: auto-detects scale from GNOME, KDE Plasma 5/6, Sway, Hyprland, Cinnamon, env vars, and xrdb. +- **Qt system tray**: pod controls, app launchers, maintenance tools, idle monitor, auto-refresh. +- **Multi-backend**: Podman (default), Docker, libvirt/KVM, manual RDP — unified interface. +- Auto-generated **compose files** for Podman/Docker backends (uses the `dockur/windows` image). +- **Per-app taskbar separation**: each app gets a unique WM_CLASS / `StartupWMClass`. +- **Windows build pinning**: `TargetReleaseVersion` policy blocks feature updates while leaving security updates on. +- **Upstream update monitoring**: weekly check for new `dockur/windows` releases. +- **Concurrency protection**: threading locks prevent crashes on simultaneous app launches. +- GUI **Windows Update toggle** (services + scheduled tasks + hosts-file triple block). +- **Sound + printer** redirection enabled by default. +- **USB drive sharing** with hot-plug (reconnect-free sub-folder exposure). +- **USB device redirection** via FreeRDP `urbdrc` when available, graceful fallback to drive sharing. +- Windows-side **USB drive-letter auto-mapping** (event-based, no polling). +- Desktop integration: `.desktop` entries, hicolor icons, MIME registration, icon-cache refresh. +- Restricted-permission (`0600`) TOML configuration file for credential protection. +- FreeRDP session management with process tracking and zombie reaping. +- `winapps.conf` import for migrating existing winapps installs. + +### Security +- RDP bound to **127.0.0.1 only** — no network exposure. +- **TLS-only** RDP channel (SecurityLayer=2); NLA disabled only in the loopback-bound setup. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d68bf32 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,63 @@ +# Contributor Covenant Code of Conduct + +**English** | [한국어](docs/CODE_OF_CONDUCT.ko.md) + +## Our Pledge + +We as members, contributors, and maintainers pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: +- Demonstrating empathy and kindness +- Respecting differing opinions and experiences +- Giving and gracefully accepting constructive feedback +- Taking responsibility and learning from mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior: +- Sexualized language, imagery, or attention +- Trolling, insulting, or derogatory comments +- Public or private harassment +- Publishing others' private information without permission +- Other conduct inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing standards of +acceptable behavior and will take fair corrective action when needed. + +## Scope + +This Code of Conduct applies within all project spaces and also when an +individual is officially representing the project in public spaces. + +## Enforcement + +Report unacceptable behavior to the maintainers through repository Discussions +or Issues (for non-sensitive cases). For sensitive reports, contact maintainers +privately through the channels listed in the repository profile. + +All complaints will be reviewed and investigated promptly and fairly. + +## Enforcement Guidelines + +Community Impact Guidelines are adapted from the Contributor Covenant and may +include: +1. Correction +2. Warning +3. Temporary ban +4. Permanent ban + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1: +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..07f86c1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,191 @@ +# Contributing to WinPodX + +**English** | [한국어](docs/CONTRIBUTING.ko.md) + +Thank you for your interest in contributing to WinPodX! This guide will help you get started. + +## Prerequisites + +- Python 3.9+ (developed on 3.13; CI covers 3.9 / 3.10 / 3.11 / 3.12 / 3.13) +- FreeRDP 3+ + +## Build + +```bash +git clone https://github.com/kernalix7/winpodx.git +cd winpodx +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +## Test + +```bash +# Run tests +pytest tests/ -v + +# Lint +ruff check src/ tests/ + +# Format check +ruff format --check src/ tests/ +``` + +`pytest` runs on a Linux CI runner and cannot exercise the Windows guest. Any +change that touches the guest (`config/oem/`, `scripts/windows/`, the +reverse-open shim, `compose` ports/QEMU args, the agent, install flow, RAIL +launch) must also be smoke-tested against a real Windows guest before merge — +see [docs/RELEASE_TESTING.md](docs/RELEASE_TESTING.md) for the guest-side smoke ++ per-release checklist. + +## Workflow + +1. **Fork** the repository +2. Create a **feature branch** (`git checkout -b feat/my-feature`) +3. Write your changes following **conventional commits** +4. Submit a **Pull Request** + +## PR Checklist + +Before submitting a PR, ensure the following: + +- [ ] `pytest tests/ -v` passes +- [ ] `ruff check src/ tests/` reports zero errors +- [ ] `ruff format --check src/ tests/` passes +- [ ] Documentation is updated (if applicable) +- [ ] No hardcoded credentials or secrets + +## Commit Convention + +This project follows [Conventional Commits](https://www.conventionalcommits.org/): + +| Prefix | Purpose | +|--------|---------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation changes | +| `refactor` | Code refactoring (no feature change) | +| `test` | Adding or updating tests | +| `chore` | Maintenance tasks (CI, deps, etc.) | + +### Examples + +``` +feat: add Wayland display detection +fix: resolve DPI scaling on multi-monitor setups +docs: update installation instructions +refactor: simplify backend abstraction layer +test: add unit tests for UNC path conversion +chore: update ruff to 0.8.x +``` + +### No AI tool co-author trailers + +Do **not** add `Co-authored-by:` trailers that name AI tools / coding agents. This applies to all of: + +- `Co-authored-by: Cursor ` +- `Co-authored-by: Claude ` (and any other Anthropic email) +- `Co-authored-by: Copilot <...>` (any GitHub Copilot variant) +- `Co-authored-by: ` + +You wrote the patch — the human author of record is you. AI tooling doesn't get co-authorship credit in this repo regardless of how much it contributed. If you forgot and a trailer slipped in, we'll ask you to amend (or, for already-merged PRs, propose a coordinated history-rewrite via a follow-up PR). + +Human co-authors (e.g., a colleague who pair-programmed with you on the change) are fine and welcome — those should use real human identities + emails. + +## Writing release notes + +Each version section in `CHANGELOG.md` (and `docs/CHANGELOG.ko.md`) starts with `### Highlights` — a one-sentence headline followed by 3–6 scannable bullets. This is what users see at the top of the GitHub release page: `release.yml` extracts the version's section verbatim, so the first thing in the section is the first thing in the release body. + +The detailed `### Added` / `### Changed` / `### Fixed` bullets follow underneath. They're for archeology and exhaustive tracking, not first-read. + +Skeleton: + +```markdown +## [X.Y.Z] - YYYY-MM-DD + +### Highlights + +**One-sentence headline.** Optional 1-2 sentence elaboration if needed. + +- Most important user-visible change (one line, scannable) +- Second most important change +- (3-6 bullets max; no prose blocks) + +### Added +- (detailed bullets) + +### Changed +- (detailed bullets) + +### Fixed +- (detailed bullets) +``` + +When cutting a release, also push the `REL-vX.Y.Z` marker tag — this is what fires `release.yml` (which builds the `wheel` + `sdist`, extracts the CHANGELOG section, and updates the GitHub release body). Without the REL- marker, the version tag (`vX.Y.Z`) triggers only the four packaging workflows (`obs-publish.yml`, `rhel-publish.yml`, `debs-publish.yml`, `aur-publish.yml`) but no `wheel` / `sdist` and no auto-extracted release body. + +```bash +git tag vX.Y.Z +git tag REL-vX.Y.Z vX.Y.Z^{} # dereference to commit to avoid a nested-tag warning +git push origin vX.Y.Z REL-vX.Y.Z +``` + +### Crediting contributors in Highlights + +When a Highlights bullet covers work that came from outside the +maintainer (external PR or external bug report / feature request), +credit the contributor inline. The convention: + +| Source | Suffix | +|---|---| +| External PR (someone else's commits) | `(by @username, #PR)` | +| External issue / feature request (maintainer wrote the code) | `(reported by @username, #issue)` | +| Both — external report **and** external PR by the same person | `(by @username, #PR / #issue)` | + +GitHub auto-renders both forms as the user's avatar + handle on the +release page, so the recognition surfaces without extra work. + +Example: + +```markdown +### Highlights + +- Atomic Fedora flavours (Silverblue / Kinoite / Bazzite) now ship via the + OBS repo with `rpm-ostree install --apply-live`. (by @Zeik0s, #163) +- LTSC IoT and Win10 LTSC pickable from Settings or `--win-version`. + (reported by @gabe39, #178) +- Dynamic Desktop Window Resolution — Full Desktop sessions now resize + the FreeRDP client window automatically. (by @Zeik0s, #202) +- Ubuntu 26.04 build target + Wayland-friendly Recommends split. + (by @juampe, #206) +- `winpodx setup` rerun no longer overwrites the working Windows password + and locks the user out of their guest. (reported by @tolistim, #216) +- Host-adaptive Windows-on-KVM tuning — `cfg.pod.tuning_profile = "auto"` + detects host capability and applies `+invtsc` on x86_64 invariant-TSC + CPUs. (reported by @ismikes, #215) +- Fedora 42 / 43 / 44 install snippet now uses dnf5 syntax (`dnf + config-manager addrepo --from-repofile=`); the dnf4 form + failed on Fedora 41 +. (reported by @payayas, #228) +- `install.sh` re-verifies `/dev/kvm` after the package install loop + and refuses to proceed with an actionable BIOS / kernel-module / + kvm-group diagnostic when hardware virtualisation is off. README + gains a "Minimum requirements" section so users see the gating + checks before they curl-install. (reported by @pnogaret2019-code, + #220) +- `install.sh` picks `qemu-system-x86` (or `qemu-system-x86-hwe`) + on Ubuntu 24.04+ / Debian 13 where `qemu-kvm` became a virtual + package with no install candidate. Same probe pattern as the + freerdp2 → freerdp3 selector. (reported by @n-osennij, #200) +- Tray UX overhaul: auto-spawned from GUI / CLI, bundled SVG so the + indicator actually renders, Open Dashboard menu, Quit confirms + + stops pod, install.sh marker suppresses spurious recovery + notifications during Sysprep. +``` + +The "no AI tool co-author trailers" rule above is unrelated: it bans +machine-generated attribution. Human contributors are credited +liberally and explicitly. + +## Security + +If you discover a security vulnerability, please follow the process described in [SECURITY.md](SECURITY.md). **Do NOT open a public issue.** diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6b7738a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kim DaeHyun (kernalix7@kodenet.io) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..64a3ea7 --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +
+ +WinPodX + +### Click an app. Word opens. That's it. + +

Native Linux windows for every Windows app — real icons, real WM_CLASS,
+pin-to-taskbar. FreeRDP RemoteApp + dockur/windows. Zero config.

+ +
# Latest stable release (default)
+curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash
+
+# Latest main HEAD (development; may be unstable)
+curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash -s -- --main
+
+# Uninstall (keeps Windows VM data; pass --purge to wipe everything)
+curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/uninstall.sh | bash -s -- --confirm
+ + + WinPodX in action — Windows apps as native Linux windows on KDE + + +Windows About / Task Manager / PowerShell each in their own Linux window, alongside the WinPodX Dashboard (live Pod / RAM / CPU gauges, workspace tiles). + +[![Beta](https://img.shields.io/badge/status-beta-orange?style=for-the-badge)](#status-beta) +[![Latest](https://img.shields.io/github/v/release/kernalix7/winpodx?include_prereleases&style=for-the-badge&label=latest&color=2962FF)](https://github.com/kernalix7/winpodx/releases) + +[![license](https://img.shields.io/github/license/kernalix7/winpodx?style=flat-square&color=blue)](LICENSE) +[![python](https://img.shields.io/badge/python-3.9%2B-3776AB?style=flat-square&logo=python&logoColor=white)](https://www.python.org/) +[![tests](https://img.shields.io/badge/tests-1800%2B-2EA44F?style=flat-square)](#testing) +[![CI](https://img.shields.io/github/actions/workflow/status/kernalix7/winpodx/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/kernalix7/winpodx/actions/workflows/ci.yml) +[![stars](https://img.shields.io/github/stars/kernalix7/winpodx?style=flat-square&color=FFD93D&logo=github&logoColor=white)](https://github.com/kernalix7/winpodx/stargazers) +[![downloads](https://img.shields.io/github/downloads/kernalix7/winpodx/total?style=flat-square&color=2EA44F)](https://github.com/kernalix7/winpodx/releases) + +###### Works on + +[![openSUSE](https://img.shields.io/badge/openSUSE-73BA25?style=flat-square&logo=opensuse&logoColor=white)](https://www.opensuse.org/) +[![Fedora](https://img.shields.io/badge/Fedora-294172?style=flat-square&logo=fedora&logoColor=white)](https://fedoraproject.org/) +[![Fedora Atomic Desktops](https://img.shields.io/badge/Fedora%20Atomic-294172?style=flat-square&logo=fedora&logoColor=white)](https://fedoraproject.org/atomic-desktops/) +[![Debian](https://img.shields.io/badge/Debian-A81D33?style=flat-square&logo=debian&logoColor=white)](https://www.debian.org/) +[![Ubuntu](https://img.shields.io/badge/Ubuntu-E95420?style=flat-square&logo=ubuntu&logoColor=white)](https://ubuntu.com/) +[![RHEL family](https://img.shields.io/badge/RHEL%20%2F%20Alma%20%2F%20Rocky-EE0000?style=flat-square&logo=redhat&logoColor=white)](https://www.redhat.com/) +[![Arch](https://img.shields.io/badge/Arch-1793D1?style=flat-square&logo=archlinux&logoColor=white)](https://archlinux.org/) +[![NixOS](https://img.shields.io/badge/NixOS-5277C3?style=flat-square&logo=nixos&logoColor=white)](docs/INSTALL.md#nix) +[![AppImage](https://img.shields.io/badge/AppImage-any%20distro-6F42C1?style=flat-square&logo=appimage&logoColor=white)](docs/INSTALL.md) + +**English**  ·  [한국어](docs/README.ko.md)  ·  [Install](docs/INSTALL.md)  ·  [Usage](docs/USAGE.md)  ·  [Features](docs/FEATURES.md)  ·  [Architecture](docs/ARCHITECTURE.md)  ·  [Comparison](docs/COMPARISON.md) + +
+ +--- + +> ### Status: Beta +> WinPodX is in active development (**v0.9.0**). **v0.7.0** introduced the **bare-metal disguise** (#246, opt-in / off by default): with `pod.disguise_level balanced | max` the Windows guest reads like a physical machine to VM-detection software (Nvidia GPU-passthrough "code 43", launch-gate VM checks, VM-hostile installers) — verified against al-khaser 0.82 — and the default guest username became `WPX-User`. **v0.7.1** is a UX + integration release: discovered Windows apps now register **automatic file associations** so they appear in your file manager's "Open with" menu (#545, on by default, only *added* — never set as the default handler); the GUI gains **app management** — reset-to-detected, a custom-icon picker, multi-select bulk hide/remove, and a restore list for deleted apps (#530); a **quick app launcher** (`winpodx launch`, #561) gives a Start-menu-style picker bindable to a DE hotkey; **`winpodx gui` no longer blocks the terminal** (#549); `winpodx doctor` **warns on an old FreeRDP** with broken RemoteApp windows (#546); and `install.sh --main` is now honoured on **Atomic Fedora** (#548). **v0.7.2** is a bug-fix release: it fixes a GUI crash on *Refresh Apps* (#567) and the tray *Terminate Session* / *USB Devices* submenus (#573) on KDE/Plasma, discovers apps with Chinese/Japanese/Korean names (#553), keeps the container across `pod stop` (no more recreate-on-update), and gives a clear error when Windows credentials are missing (#569). **v0.7.3** adds **reverse-open of files on the Windows VM itself** — not just your shared Home, the guest `C:` is shared over SMB and mounted with kio-fuse so a host app opens the real guest file and edits save back (#616, KDE) — an opt-in **idle auto-stop** that frees the VM's RAM (#622), and a **`+multitouch`** flag for touchscreen / stylus / pen passthrough (#623); it also fixes `winpodx app refresh` timing out on a slow guest (#619), the Dashboard RAM / Disk gauges sticking on "n/a" (#634), and removes the USB drive-letter auto-mapper that was destabilizing installs (#613, #638). **v0.7.4** lets you **choose where the Windows VM lives** (`install.sh --storage-dir`, #646) and **install from a local Windows ISO** to skip the download (`--win-iso`, #647), defaults a 24 GB+ host to **8 GB VM RAM** (#630); plus fixes — opening a second document in an already-running app, *Open with* on stripped-PATH desktops like Deepin, a clear error on a missing RDP password (#569), the GUI Logs buttons honouring the Docker backend, the `.deb` pulling in `podman-compose` (#644), and the maintenance dialog size (#550). **v0.8.0** makes app discovery **Start-Menu-only by default** — only the apps your Windows Start Menu actually shows, grouped into nested folder sub-groups, with a `desktop.full_app_scan` opt-in for the old scan-everything behaviour (#581); passes your `[pod] keyboard` to FreeRDP as the session layout (#660); stages `--win-iso` *before* compose-up so dockur installs from it instead of downloading (#647); self-heals the reverse-open listener; bumps bundled rdprrap to **0.3.0**; and fixes several GUI crashes — the *Refresh Apps* SIGSEGV, an off-thread display-scale SIGABRT, the Debloat task dialog not auto-closing (#550), and a layout-recursion SIGSEGV with long / CJK app names (#553). **v0.9.0** lets Windows apps handle **URL-scheme links** from Linux — click a `mailto:` link and Outlook opens; app schemes like `slack:` / `vnc:` route to the right Windows app, auto-harvested during discovery and registered as `x-scheme-handler`s (#421, #694). It also **hardens the trust boundary**: a discovered guest app can no longer silently become your default `http`/`https` handler (opt-in only), and the session window-reaper no longer mis-kills a just-relaunched app or mass-kills sessions on a transient scan failure (#680 follow-up); plus fixes for kio-fuse detection on multiarch/KF6 layouts (#697), the reverse-open listener autostarting with the tray (#691), the suspend/resume D-Bus subscription (#690), and the install one-liner auto-installing `git` when missing (#705). The AppImage is **Thin** (~110 MB) — only FreeRDP + Python + Qt + winpodx — and uses the host's `podman` / `docker`. The CLI surface settled in 0.6.0 stands: **`winpodx guest`** (guest-side ops), **`winpodx install`** (install / disk ops), and **`winpodx doctor`** (diagnostics with `--json` / `--quick` / `--fix`); the post-create chain is the single **`winpodx provision`**. First install still takes ~5–10 minutes (Windows VM ISO download + Sysprep + OEM apply); `winpodx pod wait-ready --logs` shows live progress. Please file issues at if something breaks. + +**No full-screen RDP.** Each Windows app becomes its own Linux window with its real icon — pinnable, alt-tabbable, file-associated, both directions. Drop into a full Windows desktop only when you actually want one (`winpodx app run desktop`). + +WinPodX runs a Windows container (via [dockur/windows](https://github.com/dockur/windows)) in the background and presents Windows apps as native Linux applications through FreeRDP RemoteApp, while a bearer-authed HTTP agent inside the guest handles the host→guest command channel without flashing a PowerShell window. The reverse direction — Linux apps surfaced in the Windows "Open with…" menu — is handled by a host-side listener that consumes JSON requests written by per-slug Rust shims inside the guest. **Near-zero external Python dependencies** (stdlib only on Python 3.11+; one pure-Python `tomli` fallback on 3.9/3.10). + +## Minimum requirements + +**Before installing**, make sure your machine actually supports virtualisation. WinPodX runs Windows in a KVM-backed container; without these three, the install will run to completion but Windows will never boot. + +| Requirement | How to check | Fix | +|---|---|---| +| **Intel VT-x or AMD-V enabled in BIOS / UEFI** | `lscpu \| grep -i virtualization` shows `VT-x` or `AMD-V` | Reboot → firmware setup → enable "Intel Virtualization Technology" / "SVM Mode" / "VT-x". OFF by default on many laptops. | +| **kvm kernel module loaded** | `lsmod \| grep kvm` lists `kvm_intel` or `kvm_amd` | `sudo modprobe kvm_intel` (Intel) or `sudo modprobe kvm_amd` (AMD). Auto-loads on next boot once BIOS allows it. | +| **Your user is in the `kvm` group** | `id -nG \| tr ' ' '\n' \| grep kvm` returns `kvm` | `sudo usermod -aG kvm $USER`, then log out + back in. | + +Hardware: x86_64 or aarch64 CPU with virtualisation extensions, 8 GB+ RAM (12 GB+ recommended), ~30 GB free disk for the Windows image. `install.sh` aborts with the same diagnostic if `/dev/kvm` is missing after the package install step — most "install ran fine but Windows never boots" bug reports trace back to one of the rows above. + +## Quick install + +One-liner (any supported Linux distro): + +```bash +curl -fsSL https://raw.githubusercontent.com/kernalix7/winpodx/main/install.sh | bash +``` + +Or via a native package manager: + +```bash +# openSUSE Tumbleweed / Leap / Slowroll +sudo zypper addrepo https://download.opensuse.org/repositories/home:/Kernalix7/openSUSE_Tumbleweed/home:Kernalix7.repo +sudo zypper install winpodx + +# Fedora 42 / 43 / 44 (dnf5 — Fedora 41+) +sudo dnf config-manager addrepo --from-repofile=https://download.opensuse.org/repositories/home:/Kernalix7/Fedora_43/home:Kernalix7.repo +sudo dnf install winpodx + +# Debian / Ubuntu — grab the matching .deb from the latest release +sudo apt install ./winpodx__all_debian13.deb + +# AlmaLinux / Rocky / RHEL 9 / 10 — grab the matching .rpm +sudo dnf install ./winpodx--0.noarch.el10.rpm + +# Arch +yay -S winpodx + +# Nix +nix run github:kernalix7/winpodx + +# AppImage (distro-agnostic, single file) +# Download winpodx--x86_64.AppImage from the latest GitHub release +chmod +x winpodx-*-x86_64.AppImage +./winpodx-*-x86_64.AppImage setup +``` + +> **After a package-manager / AppImage install:** run `winpodx setup` once to generate `~/.config/winpodx/winpodx.toml` + compose.yaml. The curl one-liner does this for you (and waits ~5–10 min for the Windows first boot); package installs ship the binary only so `apt install` / `dnf install` / `yay -S` / first AppImage launch don't trigger a 10-minute Windows ISO download out of the blue. After setup, just launching an app (`winpodx app run desktop`) auto-provisions the pod the first time. +> +> The Thin AppImage (0.6.0) bundles Python + Qt + winpodx + FreeRDP only — the container runtime lives on the host (`podman` ≥ 4 recommended, `docker` also supported) so the AppImage no longer fights a host stack you already have (#357, #363). Pre-0.6.0 fat AppImages bundled the whole podman stack and shadowed the host's. Host-side requirements left: a container runtime via your package manager, `/dev/kvm`, `kvm` group membership, and `/etc/subuid` / `/etc/subgid` for rootless Podman. `winpodx setup-host` fixes the kvm / subuid bits via a single `pkexec` prompt; `winpodx doctor` surfaces anything still missing. + +See [docs/INSTALL.md](docs/INSTALL.md) for offline / air-gapped builds, source installs, version pinning, and uninstall. + +## First-time setup + +If you used the `curl install.sh` one-liner, setup already ran and the Windows VM is booting -- skip to [Launch](#launch). For every other install path (package managers, AppImage, source, pip) run setup once before the first app launch: + +```bash +# Auto setup -- host-detected defaults, no prompts +winpodx setup + +# Interactive wizard -- pick backend, cores, RAM, edition, language, timezone, debloat preset +winpodx setup --customize +``` + +Setup writes `~/.config/winpodx/winpodx.toml` + `compose.yaml`, registers the GUI launcher, and confirms the host has FreeRDP + Podman / Docker + KVM. If any of those are missing, the output ends with a per-distro install command (e.g. `sudo apt install xfreerdp3 podman podman-compose` on Debian / Ubuntu, `sudo dnf install ...` on Fedora) -- run it and re-run `winpodx setup`. + +The first app launch then provisions the pod, pulls the dockur image, runs the Windows ISO download + Sysprep + OEM apply, and reaches a usable RDP session in ~5-10 min. `winpodx pod wait-ready --logs` tails container progress live so you can watch each phase: + +```bash +winpodx app run desktop # First launch -- ~5-10 min, subsequent launches near-instant +winpodx pod wait-ready --logs # Optional: watch first-boot progress live +``` + +Run `winpodx doctor` any time afterwards to re-check host state and surface the next fix command if something drifts: + +```bash +winpodx doctor # Read-only -- prints what would need fixing +winpodx guest apply-fixes # Re-applies guest-side runtime fixes (RDP timeouts, NIC power-save, etc.) +``` + +## Launch + +```bash +winpodx app run word # Launch Word +winpodx app run word ~/doc.docx # Open a file +winpodx app run desktop # Full Windows desktop +winpodx launch # Quick app launcher (Start-menu style picker) +``` + +Or just click an app icon in your application menu. `winpodx launch` opens a searchable picker of your Windows apps — bind it to a desktop-environment custom shortcut (KDE: *System Settings → Shortcuts → Custom*; GNOME: *Settings → Keyboard → Custom Shortcuts*) for a system-wide hotkey. See [docs/USAGE.md](docs/USAGE.md) for the full CLI, the Qt6 GUI, health checks, and configuration. + +## Key features + + + + + + +
+ +**Bare-metal disguise (VM-detection avoidance)** — new in 0.7.0 · opt-in, off by default +- Makes the Windows guest read as a **physical machine** to software that refuses to run under a detected hypervisor — Nvidia GPU-passthrough "code 43", launch-gate VM checks, VM-hostile installers +- `pod.disguise_level balanced | max`: **balanced** hides the CPUID hypervisor bit + KVM signature and mirrors the host's real SMBIOS/DMI; **max** ("Hardened") adds a locally-built patched-QEMU image (`winpodx disguise build-image`) that rewrites the ACPI / disk / sensor / USB fingerprints and drops the virtio + Red-Hat PCI tells (keeps USB3) +- Host-derived strings stay in the **local image only** (never committed to git); serial / UUID / asset-tag are never read +- **al-khaser 0.82-verified** — enable with `winpodx config set pod.disguise_level max` or the GUI Settings "Bare-metal" selector +- [Details →](docs/FEATURES.md#bare-metal-disguise-vm-detection-avoidance) + +
+ +**Reverse-open** +- Linux apps appear in the Windows guest's right-click "Open with…" menu by default +- Correct per-app icons in both the short menu and the long "Choose another app" dialog +- Selecting one round-trips the file open to host `xdg-open` +- Auto-discovers host-side Linux apps + their MIME associations from freedesktop standards +- Manage via `winpodx host-open` CLI or the GUI Settings panel +- [Details →](docs/FEATURES.md#reverse-open-linux-apps-in-windows-open-with) + + + +**Seamless app windows** +- RemoteApp (RAIL) renders each Windows app as a native Linux window — no full desktop +- Per-app taskbar icons via `WM_CLASS` matching (`/wm-class:` + `StartupWMClass`) +- Bidirectional file associations: double-click `.docx` in your file manager → Word opens +- Multi-session RDP: bundled [rdprrap](https://github.com/kernalix7/rdprrap) auto-enables up to 10 independent sessions +- Multi-monitor RAIL (0.6.0): a remote-app window keeps working input when dragged onto a second monitor — on by default (`cfg.rdp.multimon`, default `span`) +- RAIL prerequisites set automatically during unattended install + +
+ +**Zero-config launch** +- First app click auto-provisions everything: config, container, desktop entries +- Auto-discovery on first boot scans the running Windows guest and registers every installed app with its real icon (Registry App Paths, Start Menu, UWP/MSIX, Chocolatey, Scoop) +- Manual rescan any time via `winpodx app refresh` or the GUI Refresh button +- Multi-backend: Podman (default), Docker, manual RDP (the libvirt backend was dropped in 0.6.0 — stay on ≤0.5.x or use the manual backend for your own libvirt domain) + + + +**Peripherals & sharing** +- **Clipboard**: bidirectional copy-paste (text + images) — on by default +- **Sound**: RDP audio streaming (`/sound:sys:alsa`) — on by default +- **Printer**: Linux printers shared to Windows — on by default +- **Home directory**: shared as `\\tsclient\home` +- **USB drives**: auto-mapped to drive letters (E:, F:, …) via FileSystemWatcher; subfolders work for drives plugged in after session start; the USB desktop shortcut (`\\tsclient\media`) always resolves, opening an empty folder when nothing is mounted instead of erroring +- **Host USB / PCI device passthrough** (0.6.0): pass real host devices into the Windows guest — `winpodx device list / attach / detach `, a GUI "Devices" tab (two-column host↔guest mover), and a system-tray USB switcher. USB hot-plugs live (`cfg.pod.usb_live`, default on); PCI is boot-added and needs a guest restart plus a `--force` / dialog confirmation + +
+ +**Automation & security** +- Auto suspend / resume: container pauses when idle, resumes on next launch +- Pod auto-start on login (v0.5.9, opt-in): `winpodx autostart on` installs a tray autostart entry so the pod starts/resumes at login — off by default (`autostart off|status`, or a GUI Settings checkbox) +- UNRESPONSIVE → recover (v0.5.5): stalled RDP guest is detected on `RUNNING → UNRESPONSIVE` and self-healed via in-guest TermService cycle, no `pod restart` needed +- Host-adaptive Windows-on-KVM tuning profile (v0.5.5): `+invtsc`, `platform_tick` and more, gated by host capability — `tuning_profile = auto|safe|off` +- Password auto-rotation: 20-char cryptographic password, 7-day cycle with atomic rollback +- Smart DPI scaling: auto-detects from GNOME, KDE, Sway, Hyprland, Cinnamon, xrdb +- Windows debloat: telemetry, ads, Cortana, search indexing disabled by default +- FreeRDP `extra_flags` allowlist (regex-validated) as the user-input safety boundary +- Time sync: force Windows clock resync after host sleep/wake + + + +**Operations & resilience** +- Multilingual UI (v0.5.9): tray / GUI / CLI fully translated to 7 languages (en / ko / zh / ja / de / fr / it), auto-detected from `$LANG` — override with `winpodx language ` or GUI Settings → "WinPodX UI language" +- Windows disk auto-grow (v0.5.9): C: grows itself when it fills past a threshold while idle, bounded by host free space — or grow on demand (`winpodx install grow-disk [SIZE]`, `winpodx install disk-usage`, GUI Tools → Grow Disk) +- Guest sync (v0.5.9): push updated agent / urlacl / rdprrap / fixes into a running guest after a host upgrade — automatic once per pod start, or `winpodx guest sync [--force]` +- Offline / air-gapped install (`--source` + `--image-tar`) +- One-line uninstall (keeps Windows VM data unless `--purge`) +- Health checks via `winpodx doctor` (deps / pod / RDP / agent / disk / round-trip / password age; `--json` for machine-readable, `--quick` for cheap subset, `--fix` for idempotent auto-remediation of common findings) +- Redesigned Qt6 GUI (0.6.0): a left Start-menu-style navigation sidebar + a new **Dashboard** home with 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 is now the "All apps" page, alongside Devices / Settings / Tools / Terminal / Info — plus a lighter system tray. In-house SVG icon set, responsive reflow, and a hero search that doubles as a command bar +- Stdlib-leaning Python (no pip-deps on 3.11+; one `tomli` fallback on 3.9 / 3.10) + +
+ +See [docs/FEATURES.md](docs/FEATURES.md) for deep dives, including multi-session RDP internals, app profile schema, and the reverse-open architecture. + +## Documentation + +| Document | What's inside | +|----------|---------------| +| [INSTALL.md](docs/INSTALL.md) | Every install path — one-liner, package managers, AppImage, offline, Nix, source | +| [USAGE.md](docs/USAGE.md) | CLI reference, Qt6 GUI tour, health checks, configuration file | +| [FEATURES.md](docs/FEATURES.md) | Reverse-open, multi-session RDP, peripherals, app profiles, auto-discovery | +| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | How it works (diagram), tech stack, source tree, data flows | +| [COMPARISON.md](docs/COMPARISON.md) | WinPodX vs winapps / LinOffice / winboat, and WinPodX vs Wine | +| [CHANGELOG.md](CHANGELOG.md) | Full version history | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup and workflow | +| [SECURITY.md](SECURITY.md) | Security disclosure process | + +## Supported distros + +| Distro | Package manager | Status | +|--------|-----------------|--------| +| openSUSE Tumbleweed / Leap 15.6 / Leap 16.0 / Slowroll | zypper | Tested | +| Fedora 42 / 43 / 44 / Rawhide | dnf | Supported | +| Fedora Silverblue / Kinoite / Sericea / Bluefin / Bazzite (42 / 43 / 44) | rpm-ostree (OBS, `--apply-live`) | Supported | +| Debian 12 / 13, Ubuntu 24.04 / 25.04 / 25.10 / 26.04 | apt | Supported | +| AlmaLinux / Rocky / RHEL 9 / 10 | dnf | Supported | +| Arch / Manjaro | pacman + `yay -S winpodx` | Supported | +| NixOS (and Nix on any distro) | nix flake | Supported | + +Each tag push (`v*.*.*`) publishes to all channels automatically — see [packaging/](packaging/) for maintainer details. + +## Testing + +```bash +# From repo root (no install needed) +export PYTHONPATH="$PWD/src" +python3 -m pytest tests/ # 1800+ tests +ruff check src/ tests/ # Lint +ruff format --check src/ tests/ +``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, branch naming, commit conventions, and CI expectations. + +## Security + +For security issues, follow the process in [SECURITY.md](SECURITY.md). + +## Star History + + + + + + Star History Chart + + + +## Support + +If WinPodX makes your Linux desktop a little nicer: + +[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?logo=githubsponsors&logoColor=white&style=for-the-badge)](https://github.com/sponsors/kernalix7) +[![Ko-fi](https://img.shields.io/badge/Ko--fi-F16061?logo=ko-fi&logoColor=white&style=for-the-badge)](https://ko-fi.com/kernalix7) +[![Fairy](https://img.shields.io/badge/🧚_Fairy-EE6E73?style=for-the-badge&logoColor=white)](https://fairy.hada.io/@kernalix7) + +GitHub Sponsors supports recurring or one-time sponsorship; Ko-fi handles international cards and PayPal; fairy.hada.io is a Korean tipping platform. Bug reports, PRs, and stars on the repo are equally appreciated and free. + +## License + +[MIT](LICENSE) — Kim DaeHyun (kernalix7@kodenet.io) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..46e8137 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`kernalix7/winpodx` +- 原始仓库:https://github.com/kernalix7/winpodx +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ef6b89a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,171 @@ +# Security Policy + +**English** | [한국어](docs/SECURITY.ko.md) + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| Latest | Yes | + +## Reporting a Vulnerability + +Please report security vulnerabilities through GitHub Security Advisories: + +**[Report a vulnerability](https://github.com/kernalix7/winpodx/security/advisories/new)** + +**Do NOT open a public issue for security vulnerabilities.** + +### What to Include + +- **Description**: A clear description of the vulnerability +- **Steps to Reproduce**: Detailed steps to reproduce the issue +- **Impact**: The potential impact of the vulnerability +- **Affected Components**: Which modules or files are affected +- **Environment**: + - Operating System and version + - Python version + - FreeRDP version + - Desktop Environment + - Display Server (X11/Wayland) + +## Response Timeline + +| Step | Timeframe | +|------|-----------| +| Acknowledgment | Within 48 hours | +| Assessment | Within 7 days | +| Fix | Within 30 days | + +## Scope + +The following areas are considered in scope for security reports: + +- **Command injection in subprocess calls**: Unsanitized input passed to shell commands +- **Credential exposure in config files**: Passwords or secrets stored in plaintext +- **RDP session hijacking**: Unauthorized access to active RDP sessions +- **Path traversal in UNC conversion**: Manipulation of Windows/Linux path translation +- **Privilege escalation via backend management**: Unauthorized privilege gain through container or VM backends + +## Out of Scope + +The following are considered out of scope: + +- Attacks requiring physical access to the machine +- Social engineering attacks +- Vulnerabilities in third-party dependencies (report these to the upstream project) + +## Security Best Practices + +This project follows these security practices: + +- **Input validation on subprocess arguments**: All arguments passed to subprocess calls are validated and sanitized +- **Askpass preferred over plaintext passwords**: Interactive password prompts (askpass) are used instead of storing passwords in plaintext +- **XDG-compliant file permissions**: Configuration files follow XDG Base Directory Specification with appropriate file permissions +- **No secrets in code or git**: Secrets, credentials, and API keys are never committed to the repository +- **TLS-only RDP**: SecurityLayer=2 (TLS) enforced on the RDP channel; NLA disabled only because RDP is bound to 127.0.0.1 +- **Windows build pinning**: Feature updates blocked via registry policy; security updates install normally +- **Container-isolated RDP**: RDP port is bound to 127.0.0.1 only; not exposed to the network + +## Host <-> Guest Trust Model + +WinPodX treats the Windows guest as a **semi-trusted** component, not a trusted +extension of the host. This section documents how that trust boundary is drawn +and enforced, specifically for the app-discovery channel introduced in v0.1.7. + +### Provisioning scope and threat assumptions + +WinPodX provisions the Windows guest itself inside a rootless Podman container +(or, optionally, Docker / libvirt). Because the host controls the image, the +unattended-install answer file, and the OEM post-install scripts, a freshly +provisioned guest starts in a known-good state. + +However, JSON emitted by `scripts/windows/discover_apps.ps1` is treated as +**semi-trusted input**: it is not attacker-controlled network data, but it is +also not implicitly trusted code. The guest can be corrupted through entirely +legitimate Windows-side activity, for example: + +- A malicious `.lnk` dropped into the user's Start Menu folder. +- A rogue `AppxManifest.xml` from a sideloaded UWP package. +- Supply-chain compromise of a Windows application the user installed + themselves (installer trojan, auto-update hijack, etc.). + +Any of these can cause the discovery script to emit hostile metadata. The host +side must therefore validate everything the guest sends before it is allowed to +influence the host filesystem or any executed command line. + +### Host-side validation pipeline + +The host enforces the trust boundary with a layered set of guards. Each of +these rejects malformed guest output before it can be written to disk or +interpolated into a subprocess argument list: + +- **`_CONTAINER_NAME_RE`** — the container name pulled from the host config is + scrubbed against an allowlist regex before it is passed as a subprocess arg. +- **`_AUMID_RE`** — the UWP launch URI must match + `^[A-Za-z0-9._-]+![A-Za-z0-9._-]+$`, i.e. a bare + `PackageFamilyName!AppId`. Anything else is refused. +- **`_WM_CLASS_RE`** — the WM class hint used for desktop-entry + `StartupWMClass=` must be a safe Linux identifier. +- **Slug regex** — every slug derived from a guest-supplied app name is + sanitized to `[a-z0-9_-]{1,64}` before it is used as a filesystem path + component. +- **`_MAX_APPS` (500)** — a hard cap on the total number of discovered + entries accepted per run. +- **`_MAX_ICON_BYTES` (1 MiB per icon)** — a per-icon size cap. +- **`_MAX_PATH_LEN`** — a cap on the length of guest-provided path strings. +- **Host-side stdout cap (64 MiB)** — the subprocess reader bounds the total + bytes consumed from the guest, so a misbehaving guest cannot OOM the host. +- **PNG magic byte + `QImage.loadFromData` sanity check** — every icon must + begin with the PNG signature and must successfully parse through Qt's image + decoder before it is written under `~/.local/share/icons/hicolor/`. +- **`_safe_rmtree`** — refuses to delete any path outside + `~/.local/share/winpodx/apps/`. +- **`_is_within`** — refuses to write any path outside the WinPodX user data + directory. +- **List-args subprocess only** — all subprocess invocations pass an argv + list; `shell=True` is never used on guest-derived strings. + +### Secret flow direction + +Secrets flow **host -> guest only**. The RDP password is generated on the host +by `core/compose.py` `generate_password`, written into the guest through +unattended-install compose environment variables, and rotated on a +host-controlled schedule. + +The discovery JSON carries **no credentials** in either direction. The +`guest -> host` channel is strictly typed as `{app metadata, icon bytes}` and +nothing else, so a compromised guest cannot exfiltrate host secrets through +this channel because the channel has no field that could carry them. + +### Guest-compromise impact (possible vs. not possible) + +If an attacker fully compromises the Windows guest, the bounded impact via the +discovery channel is: + +**Possible:** + +- Bogus `.desktop` entries appearing in the user's app menu (with names + already sanitized through the slug regex). +- A PNG crafted specifically to exercise the `QImage` parser. +- Broken launches for malicious AUMIDs that fail `_AUMID_RE` validation (the + launch simply never happens). + +**NOT possible via the discovery channel:** + +- Host code execution outside the PNG parser itself (which runs inside Qt's + or the stdlib's own image-decoding sandbox). +- Filesystem writes outside `~/.local/share/winpodx/apps/` and + `~/.local/share/icons/hicolor/`. +- Command injection into FreeRDP, Podman, Docker, or virsh argument lists. +- Credential exfiltration (no credentials traverse the guest -> host + direction). + +Additionally, the Windows guest runs under **rootless Podman** by default, so +even a full guest-kernel RCE does not grant host root. A guest-compromise +attacker is confined to the unprivileged user namespace that hosts the guest +container. + +## Attribution + +We appreciate responsible disclosure and will credit reporters in release notes (unless anonymity is preferred). diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..5ebaa89 --- /dev/null +++ b/THIRD_PARTY_LICENSES.md @@ -0,0 +1,137 @@ +# Third-Party Licenses + +WinPodX is MIT-licensed (see [LICENSE](LICENSE)). This document lists the +third-party components redistributed inside the source tree or pulled in as +runtime/optional dependencies, together with their upstream licenses. + +## Bundled binaries + +### rdprrap + +- Upstream: https://github.com/kernalix7/rdprrap +- Version: 0.3.0 (pinned by `config/oem/rdprrap_version.txt`, SHA256-verified) +- License: MIT +- Bundled as: `config/oem/rdprrap-0.3.0-windows-x64.zip` +- Role: enables multi-session RDP on the Windows guest during first-boot OEM + install. Same copyright holder as WinPodX. + +rdprrap's own source tree ports code from three upstream projects whose +licenses require attribution / license-text redistribution. The bundled ZIP +therefore ships: + +- `LICENSE` — rdprrap's own MIT terms. +- `NOTICE` — names each upstream project and lists the rdprrap source files + derived from it: `stascorp/rdpwrap` (Apache-2.0), `llccd/TermWrap` (MIT), + `llccd/RDPWrapOffsetFinder` (MIT). +- `vendor/licenses/` — verbatim copies of the three upstream license texts. +- `THIRD_PARTY_LICENSES.txt` — compiled Rust-dependency attributions, + auto-generated from the crate graph. + +WinPodX redistributes the ZIP unmodified. All four attribution files are +extracted into the Windows guest at first-boot install time +(`C:\Program Files\RDP Wrapper\` and `C:\winpodx\rdprrap\`), which is where +the binaries live and is the redistribution surface that the upstream +licenses govern. + +> **Historical note.** WinPodX 0.1.6 bundled rdprrap 0.1.0, which upstream +> later withdrew because the 0.1.0 / 0.1.1 ZIPs were missing `NOTICE` and +> `vendor/licenses/`. 0.1.7 onward bundles 0.1.3 and is the first +> license-compliant WinPodX release for this component. WinPodX 0.8.0 bumps the +> bundled component to rdprrap 0.3.0 (same MIT terms, same copyright holder; +> 0.3.0 derives the `termsrv.dll` patch sites dynamically). + +### rcedit + +- Upstream: https://github.com/electron/rcedit +- License: MIT (`Copyright (c) 2013 GitHub Inc.`) +- Bundled as: `config/oem/reverse-open/shim/bin/rcedit.exe` +- Role: patches PE metadata on the per-app reverse-open shim during OEM + install. `LICENSE-rcedit.txt` ships beside the binary in the same + directory. + +### winpodx-reverse-open-shim + +- Own code (`config/oem/reverse-open/shim/`, `Cargo.toml` declares MIT). +- License: MIT (same as WinPodX). +- Bundled as: `config/oem/reverse-open/shim/bin/winpodx-reverse-open-shim.exe` +- Role: stub Windows Explorer invokes from "Open with" to relay a + file-open request back to the host's reverse-open listener. + +## Runtime dependency (always required) + +| Package | License | When | Notes | +|---------|---------|------|-------| +| [tomli](https://pypi.org/project/tomli/) | MIT | Python 3.9 / 3.10 only | Back-fills stdlib `tomllib` (3.11+). Pure Python. | + +## Optional dependencies (only installed with matching extras) + +| Package | License | Extra | Linkage | +|---------|---------|-------|---------| +| [PySide6](https://pypi.org/project/PySide6/) | LGPL-3.0-or-later (with [Qt for Python FAQ exceptions](https://www.qt.io/qt-for-python)) | `winpodx[gui]` | Dynamic — imported at runtime. Not redistributed by WinPodX. | +| [docker](https://pypi.org/project/docker/) (docker-py) | Apache-2.0 | `winpodx[docker]` | Dynamic — imported at runtime. | +| [pyxdg](https://pypi.org/project/pyxdg/) | LGPL-2.0-or-later | (none — soft optional) | Dynamic — try-imported at runtime by `core/reverse_open/mime.py` for the long-tail MIME→extension fallback when a type isn't in the curated 86-entry table. WinPodX works without it (fallback simply returns the curated entry only). Not vendored. | + +LGPL compliance: WinPodX does not statically link, vendor, or redistribute +the PySide6 binaries. Users install them from PyPI (or their distro) at their +own discretion; the LGPL reverse-engineering / replacement rights are preserved +because the libraries remain swappable at the Python import level. + +## Development-only dependencies (`winpodx[dev]`) + +| Package | License | +|---------|---------| +| pytest | MIT | +| ruff | MIT | +| pip-audit | Apache-2.0 | +| hatchling (build backend) | MIT | + +Dev dependencies are not shipped in the wheel / sdist / distro packages. + +## Fat AppImage release artifact (DOES redistribute the components below) + +The **source tree, wheel, `.deb`, and `.rpm` do not vendor** FreeRDP / Podman +/ Qt / Python — they are runtime dependencies the host provides (see the next +section). **The fat AppImage release artifact is the exception**: since v0.5.8, +`winpodx-fat-x86_64.AppImage` bundles, for self-contained operation on +immutable distros: + +- **Python 3.11** (astral-sh python-build-standalone) — PSF +- **PySide6 / Qt6** — LGPL-3.0 (dynamically loaded; the AppImage SquashFS is + user-extractable via `--appimage-extract`, satisfying LGPL relinking) +- **Pillow** (HPND), **cairosvg** (LGPL-3.0), **pyxdg** (LGPL-2.0) +- **FreeRDP** (xfreerdp / wlfreerdp / sdl-freerdp) — Apache-2.0 +- **Podman**, **conmon**, **crun**, **netavark**, **slirp4netns**, **passt** — + Apache-2.0 +- **podman-compose** — GPL-2.0, invoked by WinPodX as a **separate executable + via subprocess** (mere aggregation under GPLv2 §2 — WinPodX itself stays + MIT; the GPL does not reach across the exec boundary) + +Each bundled component's license + NOTICE text is shipped inside the AppImage +at `usr/share/doc/winpodx/third-party/`, alongside WinPodX's own `LICENSE` and +this file at `usr/share/doc/winpodx/`. A GPL-2.0 source offer for +podman-compose is included there too. The CI build step that collects these is +in `.github/workflows/appimage-publish.yml`. + +## Runtime system dependencies (not vendored) + +Installed by `install.sh` via the host's package manager, or by the user +(this is the default for the wheel / `.deb` / `.rpm` / curl install — only +the fat AppImage above bundles them): + +- **FreeRDP 3+** — Apache-2.0 +- **Podman** / Docker — Apache-2.0 / Apache-2.0 +- **Microsoft Windows** — EULA-governed; the user supplies their own license + via the dockur/windows image, which WinPodX pulls at setup time. +- **dockur/windows container image** — MIT + (https://github.com/dockur/windows). WinPodX orchestrates but does not + redistribute this image. + +## Reference projects (inspiration only, no code redistributed) + +- **winapps** (https://github.com/winapps-org/winapps) — independent + predecessor that also wraps FreeRDP RemoteApp. WinPodX's CLI shape and + `.cproc` tracking concepts are compatible with winapps configuration + conventions for migration, but WinPodX does not copy winapps source code. +- **LinOffice** — concept reference only; no source derivation. + +If you find any attribution gap, please open an issue. diff --git a/config/oem/README.md b/config/oem/README.md new file mode 100644 index 0000000..a0d1eda --- /dev/null +++ b/config/oem/README.md @@ -0,0 +1,23 @@ +# WinPodX OEM post-install + +Files in this directory are mounted read-only into the guest at `/oem` by +`config/oem:/oem:Z` in the generated compose.yaml. The dockur/windows image +executes `install.bat` once, on first boot, after Windows OOBE finishes. + +## Files + +| File | Purpose | +|------|---------| +| `install.bat` | One-shot first-boot configurator: DNS, RDP/NLA, RemoteApp, firewall, power plan, telemetry lockdown, desktop shortcuts to the `\\tsclient` shares. | +| `toggle_updates.ps1` | Runtime toggle for Windows Update (`enable`/`disable`/`status`). Edits `hosts` with `-Encoding ASCII` (PS 5.1 ANSI default and PS 7 UTF-8-BOM both break the Windows DNS client's `hosts` parser). | + +## USB media + +There is no drive-letter auto-mapper. Removable media plugged into the host is +redirected by FreeRDP and reachable in every session at `\\tsclient\media\