#include "tools/playnite_launcher/cleanup.h" #include "src/logging.h" #include "src/platform/windows/ipc/misc_utils.h" #include "tools/playnite_launcher/focus_utils.h" #include "tools/playnite_launcher/playnite_process.h" #include #include #include #include #include #include #include #include #include using namespace std::chrono_literals; namespace playnite_launcher::cleanup { namespace { using WindowFn = std::function; void send_message_timeout(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { SendMessageTimeoutW(hwnd, msg, wParam, lParam, SMTO_ABORTIFHUNG, 5000, nullptr); } void enumerate_top_windows(DWORD pid, const WindowFn &fn) { struct Ctx { DWORD target; const WindowFn *fn; } ctx {pid, &fn}; auto proc = [](HWND hwnd, LPARAM param) -> BOOL { auto *ctx = reinterpret_cast(param); DWORD owner = 0; GetWindowThreadProcessId(hwnd, &owner); if (owner == ctx->target) { (*ctx->fn)(hwnd); } return TRUE; }; EnumWindows(proc, reinterpret_cast(&ctx)); } HANDLE create_thread_snapshot() { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); return snap == INVALID_HANDLE_VALUE ? nullptr : snap; } void enumerate_pid_threads(DWORD pid, const std::function &fn) { HANDLE snap = create_thread_snapshot(); if (!snap) { return; } THREADENTRY32 te {sizeof(te)}; if (!Thread32First(snap, &te)) { CloseHandle(snap); return; } do { if (te.th32OwnerProcessID == pid) { fn(te.th32ThreadID); } } while (Thread32Next(snap, &te)); CloseHandle(snap); } void enumerate_thread_windows(DWORD thread_id, const WindowFn &fn) { auto proc = [](HWND hwnd, LPARAM param) -> BOOL { auto *f = reinterpret_cast(param); (*f)(hwnd); return TRUE; }; EnumThreadWindows(thread_id, proc, reinterpret_cast(&fn)); } void for_each_thread_window(DWORD pid, const WindowFn &fn) { enumerate_pid_threads(pid, [&](DWORD thread_id) { enumerate_thread_windows(thread_id, fn); }); } void post_close_messages(DWORD pid) { enumerate_top_windows(pid, [&](HWND hwnd) { send_message_timeout(hwnd, WM_CLOSE, 0, 0); }); } void post_logoff_messages(DWORD pid) { enumerate_top_windows(pid, [&](HWND hwnd) { send_message_timeout(hwnd, WM_QUERYENDSESSION, TRUE, ENDSESSION_CLOSEAPP); }); enumerate_top_windows(pid, [&](HWND hwnd) { send_message_timeout(hwnd, WM_ENDSESSION, TRUE, 0); }); } void post_quit_messages(DWORD pid) { for_each_thread_window(pid, [&](HWND hwnd) { PostMessageW(hwnd, WM_QUIT, 0, 0); }); } void signal_console_threads(DWORD pid) { enumerate_pid_threads(pid, [&](DWORD thread_id) { HANDLE thread = OpenThread(THREAD_SET_CONTEXT | THREAD_QUERY_INFORMATION, FALSE, thread_id); if (thread) { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, thread_id); CloseHandle(thread); } }); } bool ensure_window_minimized(HWND hwnd, std::chrono::milliseconds timeout) { if (!hwnd) { return false; } auto deadline = std::chrono::steady_clock::now() + timeout; while (std::chrono::steady_clock::now() < deadline) { send_message_timeout(hwnd, WM_SYSCOMMAND, SC_RESTORE, 0); ShowWindow(hwnd, SW_RESTORE); send_message_timeout(hwnd, WM_SYSCOMMAND, SC_MINIMIZE, 0); ShowWindow(hwnd, SW_SHOWMINNOACTIVE); if (IsIconic(hwnd)) { return true; } std::this_thread::sleep_for(100ms); } return IsIconic(hwnd) != FALSE; } struct CleanupPlan { std::wstring install_dir; int timeout_secs; std::chrono::steady_clock::time_point start; bool sent_close = false; bool sent_logoff = false; bool sent_quit = false; bool logged_initial = false; }; CleanupPlan make_plan(const std::wstring &install_dir, int timeout_secs) { CleanupPlan plan; plan.install_dir = install_dir; plan.timeout_secs = std::max(1, timeout_secs); plan.start = std::chrono::steady_clock::now(); return plan; } std::vector collect_candidates(const std::wstring &install_dir) { auto pids = focus::find_pids_under_install_dir_sorted(install_dir); if (pids.empty()) { pids = focus::find_pids_under_install_dir_sorted(install_dir, false); } return pids; } double elapsed_fraction(const CleanupPlan &plan) { auto now = std::chrono::steady_clock::now(); auto elapsed = std::chrono::duration_cast(now - plan.start).count(); double total = static_cast(plan.timeout_secs) * 1000.0; return std::clamp(elapsed / total, 0.0, 1.0); } void log_initial_once(CleanupPlan &plan, const std::vector &pids) { if (plan.logged_initial) { return; } plan.logged_initial = true; BOOST_LOG(info) << "Cleanup: initial candidates count=" << pids.size(); for (auto pid : pids) { std::wstring img; focus::get_process_image_path(pid, img); BOOST_LOG(info) << "Cleanup: candidate PID=" << pid << " path='" << platf::dxgi::wide_to_utf8(img) << "'"; } } void apply_stages(CleanupPlan &plan, const std::vector &pids) { double fraction = elapsed_fraction(plan); if (!plan.sent_close) { for (auto pid : pids) { post_close_messages(pid); } plan.sent_close = true; return; } if (fraction >= 0.4 && !plan.sent_logoff) { for (auto pid : pids) { post_logoff_messages(pid); } plan.sent_logoff = true; return; } if (fraction >= 0.7 && !plan.sent_quit) { for (auto pid : pids) { post_quit_messages(pid); signal_console_threads(pid); } plan.sent_quit = true; } } void force_terminate(const std::wstring &install_dir) { auto remaining = collect_candidates(install_dir); for (auto pid : remaining) { std::wstring img; focus::get_process_image_path(pid, img); BOOST_LOG(warning) << "Cleanup: forcing termination of PID=" << pid << (img.empty() ? "" : (" path=" + platf::dxgi::wide_to_utf8(img))); focus::terminate_pid(pid); } } bool launch_desktop_command(const std::wstring &path, DWORD flags) { std::wstring cmd = L"\"" + path + L"\" --startdesktop"; std::vector cmdline(cmd.begin(), cmd.end()); cmdline.push_back(0); STARTUPINFOW si {}; si.cb = sizeof(si); si.dwFlags |= STARTF_USESHOWWINDOW; si.wShowWindow = SW_HIDE; PROCESS_INFORMATION pi {}; BOOL ok = CreateProcessW(path.c_str(), cmdline.data(), nullptr, nullptr, FALSE, flags, nullptr, nullptr, &si, &pi); if (pi.hThread) { CloseHandle(pi.hThread); } if (pi.hProcess) { CloseHandle(pi.hProcess); } return ok; } std::wstring resolve_desktop_path() { try { std::wstring assoc = playnite::query_playnite_executable_from_assoc(); if (assoc.empty()) { return {}; } std::filesystem::path base(assoc); if (_wcsicmp(base.filename().c_str(), L"Playnite.DesktopApp.exe") == 0) { return assoc; } std::filesystem::path candidate = base.parent_path() / L"Playnite.DesktopApp.exe"; if (std::filesystem::exists(candidate)) { return candidate.wstring(); } return assoc; } catch (...) { return {}; } } bool launch_desktop_app(const std::wstring &path) { if (path.empty() || !std::filesystem::exists(path)) { BOOST_LOG(warning) << "Cleanup fullscreen: unable to resolve Playnite.DesktopApp path"; return false; } DWORD base_flags = CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW | DETACHED_PROCESS; bool ok = launch_desktop_command(path, base_flags | CREATE_BREAKAWAY_FROM_JOB); if (!ok) { ok = launch_desktop_command(path, base_flags); } BOOST_LOG(info) << "Cleanup fullscreen: launch DesktopApp attempt result=" << (ok ? "ok" : "fail"); return ok; } std::vector wait_for_process(const wchar_t *exe, std::chrono::steady_clock::time_point deadline, std::chrono::milliseconds step) { std::vector ids; while (std::chrono::steady_clock::now() < deadline) { try { ids = platf::dxgi::find_process_ids_by_name(exe); } catch (...) { ids.clear(); } if (!ids.empty()) { break; } std::this_thread::sleep_for(step); } return ids; } bool minimize_desktop_once(const std::vector &pids) { auto deadline = std::chrono::steady_clock::now() + 30s; while (std::chrono::steady_clock::now() < deadline) { for (auto pid : pids) { HWND hwnd = focus::find_main_window_for_pid(pid); if (!hwnd) { continue; } if (!IsWindow(hwnd)) { return true; } if (IsWindowVisible(hwnd) && !IsIconic(hwnd)) { if (!ensure_window_minimized(hwnd, 5s)) { BOOST_LOG(warning) << "Cleanup fullscreen: failed to confirm DesktopApp minimized"; } return true; } } std::this_thread::sleep_for(300ms); } BOOST_LOG(info) << "Cleanup fullscreen: DesktopApp window never reported visible before timeout"; return false; } bool wait_for_fullscreen_exit(int exit_timeout_secs) { auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(3, exit_timeout_secs)); while (std::chrono::steady_clock::now() < deadline) { try { auto ids = platf::dxgi::find_process_ids_by_name(L"Playnite.FullscreenApp.exe"); if (ids.empty()) { return true; } } catch (...) { } std::this_thread::sleep_for(250ms); } return false; } } // namespace void cleanup_graceful_then_forceful_in_dir(const std::wstring &install_dir, int exit_timeout_secs) { if (install_dir.empty()) { return; } BOOST_LOG(info) << "Cleanup: begin for install_dir='" << platf::dxgi::wide_to_utf8(install_dir) << "' timeout=" << exit_timeout_secs << "s"; CleanupPlan plan = make_plan(install_dir, exit_timeout_secs); while (true) { auto pids = collect_candidates(plan.install_dir); log_initial_once(plan, pids); if (pids.empty()) { BOOST_LOG(info) << "Cleanup: all processes exited gracefully"; return; } apply_stages(plan, pids); if (elapsed_fraction(plan) >= 1.0) { break; } std::this_thread::sleep_for(250ms); } force_terminate(plan.install_dir); } void cleanup_fullscreen_via_desktop(int exit_timeout_secs) { BOOST_LOG(info) << "Cleanup fullscreen: launching DesktopApp to close fullscreen"; std::wstring desktop_path = resolve_desktop_path(); launch_desktop_app(desktop_path); auto wait_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(3, exit_timeout_secs)); auto desktop_pids = wait_for_process(L"Playnite.DesktopApp.exe", wait_deadline, 200ms); if (!desktop_pids.empty()) { minimize_desktop_once(desktop_pids); } if (wait_for_fullscreen_exit(exit_timeout_secs)) { BOOST_LOG(info) << "Cleanup fullscreen: FullscreenApp exited after desktop launch"; } } } // namespace playnite_launcher::cleanup