#include "tools/playnite_launcher/launcher.h" #include "src/logging.h" #include "src/platform/windows/ipc/misc_utils.h" #include "src/platform/windows/playnite_ipc.h" #include "src/platform/windows/playnite_protocol.h" #include "tools/playnite_launcher/arguments.h" #include "tools/playnite_launcher/cleanup.h" #include "tools/playnite_launcher/focus_utils.h" #include "tools/playnite_launcher/lossless_scaling.h" #include "tools/playnite_launcher/playnite_process.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace std::chrono_literals; namespace playnite_launcher { namespace detail { std::filesystem::path resolve_log_path(); void ensure_playnite_open(); int64_t steady_to_millis(std::chrono::steady_clock::time_point tp); std::chrono::steady_clock::time_point millis_to_steady(int64_t ms); std::string normalize_game_id(std::string s); int run_cleanup_mode(const LauncherConfig &config, const lossless::lossless_scaling_options &lossless_options); int run_fullscreen_mode(LauncherConfig config, const lossless::lossless_scaling_options &lossless_options); int run_standard_mode(LauncherConfig config, const lossless::lossless_scaling_options &lossless_options); } // namespace detail int launcher_run(int argc, char **argv) { auto parsed = parse_arguments(argc, argv); if (!parsed.success) { return parsed.exit_code; } LauncherConfig config = std::move(parsed.config); FreeConsole(); auto log_path = detail::resolve_log_path(); auto log_guard = logging::init(2, log_path.string()); BOOST_LOG(info) << "Playnite launcher starting; pid=" << GetCurrentProcessId(); auto lossless_options = lossless::read_lossless_scaling_options(); // Adjust focus_attempts to account for lossless scaling focus attempt (1 additional) // This ensures the game still gets the configured number of focus attempts if (lossless_options.enabled && config.focus_attempts > 0) { config.focus_attempts += 1; BOOST_LOG(debug) << "Lossless Scaling enabled: adjusted focus_attempts by +1 to " << config.focus_attempts; } if (config.cleanup) { return detail::run_cleanup_mode(config, lossless_options); } if (config.fullscreen) { return detail::run_fullscreen_mode(config, lossless_options); } return detail::run_standard_mode(config, lossless_options); } namespace detail { std::filesystem::path resolve_log_path() { std::wstring appdataW; appdataW.resize(MAX_PATH); if (FAILED(SHGetFolderPathW(nullptr, CSIDL_APPDATA, nullptr, SHGFP_TYPE_CURRENT, appdataW.data()))) { appdataW = L"."; } appdataW.resize(wcslen(appdataW.c_str())); std::filesystem::path logdir = std::filesystem::path(appdataW) / L"Sunshine"; std::error_code ec; std::filesystem::create_directories(logdir, ec); return logdir / L"sunshine_playnite_launcher.log"; } void ensure_playnite_open() { if (!playnite::is_playnite_running()) { BOOST_LOG(info) << "Playnite not running; opening playnite:// URI in detached mode"; if (!playnite::launch_uri_detached_parented(L"playnite://")) { BOOST_LOG(warning) << "Failed to launch playnite:// via detached CreateProcess"; } } } int64_t steady_to_millis(std::chrono::steady_clock::time_point tp) { return std::chrono::duration_cast(tp.time_since_epoch()).count(); } std::chrono::steady_clock::time_point millis_to_steady(int64_t ms) { return std::chrono::steady_clock::time_point(std::chrono::milliseconds(ms)); } std::string normalize_game_id(std::string s) { s.erase(std::remove_if(s.begin(), s.end(), [](char c) { return c == '{' || c == '}'; }), s.end()); std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return s; } nlohmann::json snapshot_process_environment() { nlohmann::json environment = nlohmann::json::object(); LPWCH block = GetEnvironmentStringsW(); if (!block) { BOOST_LOG(warning) << "Unable to capture environment for Playnite launch, error=" << GetLastError(); return environment; } auto free_block = util::fail_guard([block]() { FreeEnvironmentStringsW(block); }); for (const wchar_t *entry = block; *entry != L'\0'; entry += wcslen(entry) + 1) { std::wstring_view pair(entry); auto separator = pair.find(L'='); // Windows environment blocks can contain drive-current-directory entries // (for example, "=C:=C:\\working"). They are not regular variables and // cannot be set through the .NET process-environment API used by Playnite. if (separator == 0) { separator = pair.find(L'=', 1); } if (separator == std::wstring_view::npos || separator == 0) { continue; } try { std::wstring_view name = pair.substr(0, separator); std::wstring_view value = pair.substr(separator + 1); environment[platf::dxgi::wide_to_utf8(std::wstring(name))] = platf::dxgi::wide_to_utf8(std::wstring(value)); } catch (...) { BOOST_LOG(warning) << "Unable to serialize an environment variable for Playnite launch"; } } return environment; } int run_cleanup_mode(const LauncherConfig &config, const lossless::lossless_scaling_options &lossless_options) { BOOST_LOG(info) << "Cleanup mode: starting (installDir='" << config.install_dir << "' fullscreen=" << (config.fullscreen ? 1 : 0) << ")"; bool parent_bound_watchdog = false; if (!config.wait_for_pid.empty()) { try { if (config.wait_for_pid.find_first_not_of("0123456789") != std::string::npos) { BOOST_LOG(warning) << "Cleanup mode: invalid --wait-for-pid value: '" << config.wait_for_pid << "'"; } else { DWORD wpid = static_cast(std::stoul(config.wait_for_pid)); if (wpid == 0 || wpid == GetCurrentProcessId()) { BOOST_LOG(warning) << "Cleanup mode: refusing zero/self --wait-for-pid value: " << wpid; } else { parent_bound_watchdog = true; HANDLE hp = OpenProcess(SYNCHRONIZE, FALSE, wpid); if (hp) { BOOST_LOG(info) << "Cleanup mode: waiting for PID=" << wpid << " to exit"; DWORD wr = WaitForSingleObject(hp, INFINITE); CloseHandle(hp); BOOST_LOG(info) << "Cleanup mode: wait result=" << wr; } else { const auto error = GetLastError(); BOOST_LOG(info) << "Cleanup mode: parent PID is no longer waitable; continuing to successor grace. PID=" << wpid << " error=" << error; } } } } catch (...) { BOOST_LOG(warning) << "Cleanup mode: invalid --wait-for-pid value: '" << config.wait_for_pid << "'"; } } std::wstring install_dir_w; if (!config.install_dir.empty()) { install_dir_w = platf::dxgi::utf8_to_wide(config.install_dir); } if (parent_bound_watchdog && !config.install_dir.empty()) { // The previous launcher may have exited because a client disconnected // just as another client resumed the same game. Give the successor time // to claim the install directory before closing any of its processes. constexpr auto successor_grace = 10s; const auto deadline = std::chrono::steady_clock::now() + successor_grace; while (std::chrono::steady_clock::now() < deadline) { if (playnite::cleanup_lease_t::held_by_another_launcher(config.install_dir)) { BOOST_LOG(info) << "Cleanup mode: successor launcher claimed install directory; skipping cleanup"; return 0; } std::this_thread::sleep_for(250ms); } if (playnite::cleanup_lease_t::held_by_another_launcher(config.install_dir)) { BOOST_LOG(info) << "Cleanup mode: successor launcher claimed install directory; skipping cleanup"; return 0; } } if (!config.fullscreen && !install_dir_w.empty()) { cleanup::cleanup_graceful_then_forceful_in_dir(install_dir_w, config.exit_timeout_secs); } if (config.fullscreen) { cleanup::cleanup_fullscreen_via_desktop(std::max(3, config.exit_timeout_secs)); } if (lossless_options.enabled) { auto runtime = lossless::capture_lossless_scaling_state(); if (!runtime.running_pids.empty()) { lossless::lossless_scaling_stop_processes(runtime); } } BOOST_LOG(info) << "Cleanup mode: done"; return 0; } int run_fullscreen_mode(LauncherConfig config, const lossless::lossless_scaling_options &lossless_options) { BOOST_LOG(info) << "Fullscreen mode: preparing IPC connection to Playnite plugin"; platf::playnite::IpcClient client; const auto fullscreen_launch_environment = snapshot_process_environment(); std::atomic pipe_connected {false}; std::atomic game_start_signal {false}; std::atomic game_stop_signal {false}; std::atomic cleanup_spawn_signal {false}; std::atomic active_game_flag {false}; // Set when the plugin reports Playnite is shutting down with no game running // (i.e. the user purposely quit Playnite, rather than Playnite quitting to // launch a game). Used to end the session promptly instead of waiting out // the process-missing grace window. std::atomic playnite_user_exit {false}; std::atomic grace_deadline_ms {steady_to_millis(std::chrono::steady_clock::now() + std::chrono::seconds(15))}; std::mutex game_mutex; struct FullscreenGameState { std::string id_original; std::string id_norm; std::string install_dir; std::string exe_path; std::string cleanup_dir; } game_state; std::string fullscreen_install_dir_utf8; std::optional fullscreen_cleanup_lease; std::string fullscreen_cleanup_lease_install_dir; std::optional game_cleanup_lease; std::string game_cleanup_lease_install_dir; auto ensure_cleanup_leases = [&]() { std::string game_install_dir; { std::lock_guard lk(game_mutex); game_install_dir = game_state.cleanup_dir; } auto ensure_lease = [&](const std::string &install_dir, auto &lease, std::string &lease_install_dir) { if (lease_install_dir != install_dir) { lease.reset(); lease_install_dir = install_dir; } if (!install_dir.empty() && !lease) { if (auto acquired = playnite::cleanup_lease_t::acquire(install_dir)) { lease = std::move(*acquired); BOOST_LOG(debug) << "Fullscreen cleanup lease: acquired for install directory '" << install_dir << "'"; } } }; ensure_lease( fullscreen_install_dir_utf8, fullscreen_cleanup_lease, fullscreen_cleanup_lease_install_dir ); ensure_lease(game_install_dir, game_cleanup_lease, game_cleanup_lease_install_dir); }; std::mutex cleanup_mutex; std::string active_cleanup_dir; bool game_cleanup_spawned = false; std::atomic watcher_spawned {false}; lossless::lossless_scaling_profile_backup fullscreen_lossless_backup {}; bool fullscreen_lossless_applied = false; bool game_started_once = false; bool fullscreen_was_running = false; auto renew_grace_deadline = [&](const char *reason) { grace_deadline_ms.store(steady_to_millis(std::chrono::steady_clock::now() + std::chrono::seconds(15))); BOOST_LOG(debug) << "Fullscreen mode: extending handoff grace due to " << reason; }; auto resolve_install_dir = [&](const std::string &install_dir, const std::string &exe_path) -> std::string { if (!install_dir.empty()) { return install_dir; } try { if (!exe_path.empty()) { std::wstring wexe = platf::dxgi::utf8_to_wide(exe_path); std::filesystem::path p(wexe); auto parent = p.parent_path(); if (!parent.empty()) { return platf::dxgi::wide_to_utf8(parent.wstring()); } } } catch (...) { } return std::string(); }; client.set_message_handler([&](std::span bytes) { auto msg = platf::playnite::parse(bytes); using MT = platf::playnite::MessageType; if (msg.type != MT::Status) { return; } auto norm_id = normalize_game_id(msg.status_game_id); if (msg.status_name == "gameStarted") { std::string install_for_ls; std::string exe_for_ls; { std::lock_guard lk(game_mutex); game_state.id_original = msg.status_game_id; game_state.id_norm = norm_id; if (!msg.status_install_dir.empty()) { game_state.install_dir = msg.status_install_dir; } if (!msg.status_exe.empty()) { game_state.exe_path = msg.status_exe; } auto resolved = resolve_install_dir(game_state.install_dir, game_state.exe_path); if (!resolved.empty()) { game_state.install_dir = resolved; game_state.cleanup_dir = resolved; } else { game_state.cleanup_dir.clear(); } install_for_ls = game_state.install_dir; exe_for_ls = game_state.exe_path; } game_started_once = true; active_game_flag.store(true); // A fresh launch overrides any prior "Playnite is exiting" intent. playnite_user_exit.store(false); game_start_signal.store(true); cleanup_spawn_signal.store(true); renew_grace_deadline("gameStarted"); if (lossless_options.enabled && !fullscreen_lossless_applied) { if (lossless_options.launch_delay_seconds > 0) { BOOST_LOG(info) << "Lossless Scaling: delaying launch by " << lossless_options.launch_delay_seconds << " seconds after gameStarted"; auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(lossless_options.launch_delay_seconds); while (std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(200ms); } } auto runtime = lossless::capture_lossless_scaling_state(); if (!runtime.running_pids.empty()) { lossless::lossless_scaling_stop_processes(runtime); } lossless::lossless_scaling_profile_backup backup; bool changed = lossless::lossless_scaling_apply_global_profile(lossless_options, install_for_ls, exe_for_ls, backup); if (backup.valid) { fullscreen_lossless_backup = backup; fullscreen_lossless_applied = true; } else { fullscreen_lossless_backup = {}; } DWORD focus_pid = 0; if (auto selected = lossless::lossless_scaling_select_focus_pid(install_for_ls, exe_for_ls, std::nullopt)) { focus_pid = *selected; } lossless::lossless_scaling_restart_foreground( runtime, changed, install_for_ls, exe_for_ls, focus_pid, lossless_options.legacy_auto_detect ); } } else if (msg.status_name == "gameStopped") { bool matches = false; { std::lock_guard lk(game_mutex); if (game_state.id_norm.empty() || norm_id.empty()) { matches = true; } else { matches = game_state.id_norm == norm_id; } if (matches) { game_state.id_original.clear(); game_state.id_norm.clear(); } } if (matches) { active_game_flag.store(false); game_stop_signal.store(true); renew_grace_deadline("gameStopped"); if (fullscreen_lossless_applied) { auto runtime = lossless::capture_lossless_scaling_state(); if (!runtime.running_pids.empty()) { lossless::lossless_scaling_stop_processes(runtime); } bool restored = lossless::lossless_scaling_restore_global_profile(fullscreen_lossless_backup); lossless::lossless_scaling_restart_foreground(runtime, restored); fullscreen_lossless_backup = {}; fullscreen_lossless_applied = false; } } } else if (msg.status_name == "playniteExiting") { // Playnite is shutting down and the plugin determined no game is running, // so this is a deliberate user quit. Flag it; the main loop ends the // session once it confirms no game process is keeping the stream alive. BOOST_LOG(info) << "Fullscreen mode: Playnite reported user-initiated exit"; playnite_user_exit.store(true); } }); client.set_connected_handler([&]() { pipe_connected.store(true); try { nlohmann::json hello; hello["type"] = "hello"; hello["role"] = "launcher"; hello["pid"] = static_cast(GetCurrentProcessId()); hello["mode"] = "fullscreen"; client.send_json_line(hello.dump()); // Keep the session environment active while this fullscreen helper is // connected. This covers games and other child processes started from // Playnite's fullscreen UI, including manually selected games. nlohmann::json environment_command; environment_command["type"] = "command"; environment_command["command"] = "set-environment"; environment_command["env"] = fullscreen_launch_environment; if (!client.send_json_line(environment_command.dump())) { BOOST_LOG(warning) << "Fullscreen mode: failed to send persistent environment to Playnite"; } } catch (...) {} }); client.set_disconnected_handler([&]() { pipe_connected.store(false); }); client.start(); auto launch_playnite_fullscreen = [&]() -> bool { bool started = false; try { std::wstring assocExe = playnite::query_playnite_executable_from_assoc(); if (!assocExe.empty()) { std::filesystem::path assoc_path(assocExe); std::filesystem::path base = assoc_path.parent_path(); fullscreen_install_dir_utf8 = platf::dxgi::wide_to_utf8(base.wstring()); ensure_cleanup_leases(); std::filesystem::path desktopExe = base / L"Playnite.DesktopApp.exe"; std::filesystem::path targetExe = desktopExe; if (!std::filesystem::exists(targetExe) && std::filesystem::exists(assoc_path)) { targetExe = assoc_path; } if (std::filesystem::exists(targetExe)) { BOOST_LOG(info) << "Launching Playnite Desktop with --startfullscreen from: " << platf::dxgi::wide_to_utf8(targetExe.wstring()); started = playnite::launch_executable_detached_parented_with_args(targetExe.wstring(), L"--startfullscreen"); } if (!started) { std::filesystem::path fullscreenExe = base / L"Playnite.FullscreenApp.exe"; if (std::filesystem::exists(fullscreenExe)) { BOOST_LOG(info) << "Desktop launch failed; falling back to FullscreenApp from: " << platf::dxgi::wide_to_utf8(fullscreenExe.wstring()); started = playnite::launch_executable_detached_parented(fullscreenExe.wstring()); } } } } catch (...) { } if (!started) { BOOST_LOG(info) << "Playnite executable not resolved; falling back to playnite://"; ensure_playnite_open(); } return started; }; BOOST_LOG(info) << "Fullscreen mode requested; attempting to start Playnite.DesktopApp.exe --startfullscreen"; (void) launch_playnite_fullscreen(); ensure_cleanup_leases(); WCHAR selfPath[MAX_PATH] = {}; GetModuleFileNameW(nullptr, selfPath, ARRAYSIZE(selfPath)); DWORD launcher_pid = GetCurrentProcessId(); if (playnite::spawn_cleanup_watchdog_process(selfPath, fullscreen_install_dir_utf8, config.exit_timeout_secs, true, launcher_pid)) { watcher_spawned.store(true); } else { BOOST_LOG(warning) << "Fullscreen mode: failed to spawn cleanup watchdog"; } auto spawn_game_cleanup = [&](const std::string &dir_utf8) { if (dir_utf8.empty()) { return; } std::lock_guard lk(cleanup_mutex); if (active_cleanup_dir != dir_utf8) { active_cleanup_dir = dir_utf8; game_cleanup_spawned = false; } if (game_cleanup_spawned) { return; } if (playnite::spawn_cleanup_watchdog_process(selfPath, dir_utf8, config.exit_timeout_secs, false, GetCurrentProcessId())) { game_cleanup_spawned = true; } else if (active_cleanup_dir == dir_utf8) { game_cleanup_spawned = false; } }; auto wait_deadline = std::chrono::steady_clock::now() + 10s; while (std::chrono::steady_clock::now() < wait_deadline) { ensure_cleanup_leases(); auto pids = platf::dxgi::find_process_ids_by_name(L"Playnite.FullscreenApp.exe"); if (!pids.empty()) { break; } std::this_thread::sleep_for(300ms); } auto cancel_fullscreen_focus = [&]() { ensure_cleanup_leases(); return active_game_flag.load(); }; ensure_cleanup_leases(); bool focused = focus::focus_process_by_name_extended(L"Playnite.FullscreenApp.exe", config.focus_attempts, config.focus_timeout_secs, config.focus_exit_on_first, cancel_fullscreen_focus); ensure_cleanup_leases(); BOOST_LOG(info) << (focused ? "Fullscreen focus applied" : "Fullscreen focus not confirmed"); int fullscreen_successes_left = std::max(0, config.focus_attempts); bool fullscreen_focus_budget_active = fullscreen_successes_left > 0 && config.focus_timeout_secs > 0; auto fullscreen_focus_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(0, config.focus_timeout_secs)); auto next_fullscreen_focus_check = std::chrono::steady_clock::now(); int game_successes_left = 0; bool game_focus_budget_active = false; auto game_focus_deadline = std::chrono::steady_clock::now(); auto next_game_focus_check = std::chrono::steady_clock::now(); bool fullscreen_detected = false; int consecutive_missing = 0; auto connection_grace_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(30, config.timeout_sec)); // Track the launched game's processes directly. Playnite can be configured to // quit when launching a game ("exit Playnite after launch"), in which case the // plugin dies and never delivers gameStopped over IPC. Watching the process tree // lets us keep the stream alive while the game runs and tear it down once it ends, // independent of any IPC signal. bool game_proc_seen = false; std::optional game_proc_first_seen; std::optional game_missing_since; // A long grace absorbs the early launch handoff (bootstrappers exiting and // respawning the real exe, loaders, multi-process launchers). Once a game has // actually been running for a while, any process disappearance is a genuine // exit, so a short grace is enough to confirm it. const auto game_missing_grace_long = std::chrono::seconds(15); const auto game_missing_grace_short = std::chrono::seconds(5); const auto long_session_threshold = std::chrono::seconds(60); auto game_processes_alive = [&]() -> bool { std::string install_dir; std::string exe_path; { std::lock_guard lk(game_mutex); install_dir = game_state.install_dir; exe_path = game_state.exe_path; } if (!install_dir.empty()) { try { std::wstring wdir = platf::dxgi::utf8_to_wide(install_dir); if (!focus::find_pids_under_install_dir_sorted(wdir, false).empty()) { return true; } } catch (...) { } } if (!exe_path.empty()) { try { std::filesystem::path p(platf::dxgi::utf8_to_wide(exe_path)); std::wstring base = p.filename().wstring(); if (!base.empty() && !platf::dxgi::find_process_ids_by_name(base.c_str()).empty()) { return true; } } catch (...) { } } return false; }; auto send_stop_command_if_needed = [&]() { if (!client.is_active()) { return; } std::string id_to_stop; { std::lock_guard lk(game_mutex); id_to_stop = game_state.id_original; } if (id_to_stop.empty()) { return; } try { nlohmann::json stop; stop["type"] = "command"; stop["command"] = "stop"; stop["id"] = id_to_stop; client.send_json_line(stop.dump()); BOOST_LOG(info) << "Fullscreen mode: stop command sent for id=" << id_to_stop; } catch (...) { BOOST_LOG(warning) << "Fullscreen mode: failed to send stop command"; } }; while (true) { ensure_cleanup_leases(); bool fs_running = false; std::vector fs_pids; try { fs_pids = platf::dxgi::find_process_ids_by_name(L"Playnite.FullscreenApp.exe"); fs_running = !fs_pids.empty(); } catch (...) { } bool active_game_now = active_game_flag.load(); auto now = std::chrono::steady_clock::now(); // Watch the launched game's process tree so the session survives Playnite quitting // on launch (no gameStopped IPC) and ends once the game itself exits. bool game_proc_alive = game_processes_alive(); if (game_proc_alive) { game_proc_seen = true; if (!game_proc_first_seen) { game_proc_first_seen = now; } game_missing_since.reset(); } bool game_alive; if (game_proc_seen) { if (game_proc_alive) { game_alive = true; } else { if (!game_missing_since) { game_missing_since = now; } // Shorten the grace once the game has clearly been running for a while. bool long_session = game_proc_first_seen && (*game_missing_since - *game_proc_first_seen) >= long_session_threshold; auto grace = long_session ? game_missing_grace_short : game_missing_grace_long; game_alive = (now - *game_missing_since) < grace; } } else { // gameStarted signaled but the process has not been observed yet; trust IPC + grace. game_alive = active_game_now; } // Playnite fullscreen can exit before the plugin delivers gameStarted for the launched game. // Hold the session briefly on that transition so Sunshine does not tear down the stream first. if (!fs_running && fullscreen_was_running && fullscreen_detected && !game_alive) { renew_grace_deadline("fullscreen-handoff"); } int64_t grace_ms = grace_deadline_ms.load(); bool in_grace = grace_ms > 0 && now < millis_to_steady(grace_ms); bool waiting_for_pipe = !pipe_connected.load() && now < connection_grace_deadline; if (!fullscreen_detected && fs_running) { fullscreen_detected = true; } // The user purposely quit Playnite (plugin confirmed no game was running) and no // game process is keeping the stream alive: end the session right away. if (playnite_user_exit.load() && !fs_running && !game_alive) { BOOST_LOG(info) << "Fullscreen mode: ending session after user exit with no active game"; break; } // A game we were watching has exited (its processes are gone past the grace window) // and Playnite fullscreen is not running. This is the "exit Playnite on launch" // case: Playnite quit to start the game, so the user expects to land back in the // fullscreen library rather than on the desktop with a dead stream. Relaunch // Playnite fullscreen and keep the session going. The session only ends when // Playnite is quit deliberately with no game (playnite_user_exit, handled above). if (game_proc_seen && !game_alive && !fs_running) { BOOST_LOG(info) << "Fullscreen mode: game exited and Playnite is closed; relaunching Playnite fullscreen"; { std::lock_guard lk(game_mutex); game_state.id_original.clear(); game_state.id_norm.clear(); game_state.install_dir.clear(); game_state.exe_path.clear(); game_state.cleanup_dir.clear(); } active_game_flag.store(false); game_proc_seen = false; game_proc_first_seen.reset(); game_missing_since.reset(); fullscreen_detected = false; fullscreen_was_running = false; consecutive_missing = 0; (void) launch_playnite_fullscreen(); // Give Playnite time to come back before any teardown is reconsidered, and // allow the plugin's IPC connection to re-establish. renew_grace_deadline("fullscreen-relaunch"); connection_grace_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(30, config.timeout_sec)); // Re-arm autofocus for the relaunched fullscreen UI. if (config.focus_attempts > 0 && config.focus_timeout_secs > 0) { fullscreen_successes_left = std::max(0, config.focus_attempts); fullscreen_focus_budget_active = true; fullscreen_focus_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(1, config.focus_timeout_secs)); next_fullscreen_focus_check = std::chrono::steady_clock::now(); } std::this_thread::sleep_for(500ms); continue; } if (fs_running || game_alive || in_grace || waiting_for_pipe) { consecutive_missing = 0; } else { consecutive_missing++; } if (consecutive_missing >= 12) { break; } fullscreen_was_running = fs_running; if (!watcher_spawned.load() && !fullscreen_install_dir_utf8.empty()) { bool expected = false; if (watcher_spawned.compare_exchange_strong(expected, true)) { if (!playnite::spawn_cleanup_watchdog_process(selfPath, fullscreen_install_dir_utf8, config.exit_timeout_secs, true, launcher_pid)) { watcher_spawned.store(false); } } } if (cleanup_spawn_signal.exchange(false)) { std::string dir; { std::lock_guard lk(game_mutex); dir = game_state.cleanup_dir; } spawn_game_cleanup(dir); } if (game_start_signal.exchange(false)) { game_successes_left = std::max(0, config.focus_attempts); game_focus_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(1, config.focus_timeout_secs)); game_focus_budget_active = config.focus_attempts > 0 && config.focus_timeout_secs > 0; next_game_focus_check = std::chrono::steady_clock::now(); fullscreen_focus_budget_active = false; fullscreen_successes_left = std::max(0, config.focus_attempts); } if (game_stop_signal.exchange(false)) { game_focus_budget_active = false; game_successes_left = 0; if (config.focus_attempts > 0 && config.focus_timeout_secs > 0) { fullscreen_focus_budget_active = true; fullscreen_focus_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(std::max(1, config.focus_timeout_secs)); next_fullscreen_focus_check = std::chrono::steady_clock::now(); } } if (active_game_now && game_focus_budget_active) { auto now_focus = std::chrono::steady_clock::now(); if (now_focus >= next_game_focus_check) { int remaining_secs = static_cast(std::chrono::duration_cast(game_focus_deadline - now_focus).count()); if (remaining_secs <= 0) { game_focus_budget_active = false; } else { std::string install_dir; std::string exe_path; { std::lock_guard lk(game_mutex); install_dir = game_state.install_dir; exe_path = game_state.exe_path; } bool applied = false; auto cancel = [&]() { return !active_game_flag.load(); }; int slice = std::clamp(remaining_secs, 1, 3); if (!install_dir.empty()) { try { std::wstring wdir = platf::dxgi::utf8_to_wide(install_dir); applied = focus::focus_by_install_dir_extended(wdir, 1, slice, true, cancel); } catch (...) { } } if (!applied && !exe_path.empty()) { try { std::wstring wexe = platf::dxgi::utf8_to_wide(exe_path); std::filesystem::path p(wexe); std::wstring base = p.filename().wstring(); if (!base.empty()) { applied = focus::focus_process_by_name_extended(base.c_str(), 1, slice, true, cancel); } } catch (...) { } } if (applied) { if (game_successes_left > 0) { game_successes_left--; } if (game_successes_left <= 0) { game_focus_budget_active = false; } } else if (std::chrono::steady_clock::now() >= game_focus_deadline) { game_focus_budget_active = false; } } next_game_focus_check = std::chrono::steady_clock::now() + 1s; } } if (!active_game_now && fullscreen_focus_budget_active) { auto now_focus = std::chrono::steady_clock::now(); if (now_focus >= next_fullscreen_focus_check) { bool already_fg = false; for (auto pid : fs_pids) { if (focus::confirm_foreground_pid(pid)) { already_fg = true; break; } } if (!already_fg) { int remaining_secs = static_cast(std::chrono::duration_cast(fullscreen_focus_deadline - now_focus).count()); if (remaining_secs <= 0) { fullscreen_focus_budget_active = false; } else if (fullscreen_successes_left > 0) { bool ok = focus::focus_process_by_name_extended(L"Playnite.FullscreenApp.exe", 1, std::min(2, remaining_secs), true); if (ok) { fullscreen_successes_left--; } if (fullscreen_successes_left <= 0 || std::chrono::steady_clock::now() >= fullscreen_focus_deadline) { fullscreen_focus_budget_active = false; } } else { fullscreen_focus_budget_active = false; } } next_fullscreen_focus_check = now_focus + 2s; } } std::this_thread::sleep_for(500ms); } if (game_started_once) { send_stop_command_if_needed(); } { std::string final_cleanup_dir; { std::lock_guard lk(game_mutex); final_cleanup_dir = game_state.cleanup_dir; } if (!final_cleanup_dir.empty()) { spawn_game_cleanup(final_cleanup_dir); } } client.stop(); if (fullscreen_lossless_applied) { auto runtime = lossless::capture_lossless_scaling_state(); if (!runtime.running_pids.empty()) { lossless::lossless_scaling_stop_processes(runtime); } (void) lossless::lossless_scaling_restore_global_profile(fullscreen_lossless_backup); } return 0; } int run_standard_mode(LauncherConfig config, const lossless::lossless_scaling_options &lossless_options) { BOOST_LOG(info) << "Launcher mode: preparing IPC connection to Playnite plugin"; platf::playnite::IpcClient client; const auto launch_environment = snapshot_process_environment(); std::atomic should_exit {false}; std::atomic got_started {false}; std::atomic game_stop_pending {false}; std::atomic explicit_stop_requested {false}; std::atomic launch_command_sent {false}; std::atomic launch_retry_budget {2}; std::atomic request_game_focus {false}; std::atomic game_focus_confirmed {false}; std::atomic game_focus_successes_left {0}; std::atomic lossless_refocus_pending {false}; std::atomic had_focus_success {false}; std::atomic last_confirmed_focus_pid {0}; std::atomic focus_retry_deadline_ms {0}; std::atomic next_focus_attempt_ms {std::numeric_limits::min()}; std::mutex game_info_mutex; std::string last_install_dir; std::string last_game_exe; bool focus_exit_on_first_flag = config.focus_exit_on_first; int exit_timeout_secs = config.exit_timeout_secs; auto snapshot_game_info = [&]() { std::lock_guard lock(game_info_mutex); return std::pair {last_install_dir, last_game_exe}; }; std::mutex lossless_backup_mutex; lossless::lossless_scaling_profile_backup active_lossless_backup {}; std::atomic lossless_profiles_applied {false}; // Restores the user's Lossless Scaling configuration and stops the processes // this launcher started. The IPC handler thread and the main loop both reach // this, so the applied flag is claimed with an exchange and the backup is // taken under the mutex; only the winning caller restores, which keeps a // deferred cleanup from double-restoring an already consumed backup. auto teardown_lossless_scaling = [&]() { if (!lossless_profiles_applied.exchange(false, std::memory_order_acq_rel)) { return; } lossless::lossless_scaling_profile_backup backup; { std::lock_guard lock(lossless_backup_mutex); backup = active_lossless_backup; active_lossless_backup = {}; } auto runtime = lossless::capture_lossless_scaling_state(); if (!runtime.running_pids.empty()) { lossless::lossless_scaling_stop_processes(runtime); } (void) lossless::lossless_scaling_restore_global_profile(backup); }; std::atomic watcher_spawned {false}; std::optional cleanup_lease; std::string cleanup_lease_install_dir; auto ensure_cleanup_lease = [&]() { const auto install_dir = snapshot_game_info().first; if (install_dir.empty()) { return; } if (cleanup_lease_install_dir != install_dir) { cleanup_lease.reset(); cleanup_lease_install_dir = install_dir; } if (!cleanup_lease) { if (auto lease = playnite::cleanup_lease_t::acquire(install_dir)) { cleanup_lease = std::move(*lease); BOOST_LOG(debug) << "Cleanup lease: acquired for install directory '" << install_dir << "'"; } } }; auto send_launch_command = [&](std::string_view reason) -> bool { nlohmann::json j; j["type"] = "command"; j["command"] = "launch"; j["id"] = config.game_id; // Playnite is usually already running, so its children would otherwise // inherit Playnite's stale environment instead of the per-session // environment Sunshine gave this helper. j["env"] = launch_environment; launch_command_sent.store(true, std::memory_order_release); if (!client.send_json_line(j.dump())) { BOOST_LOG(error) << "Failed to send launch command for id=" << config.game_id << " reason=" << reason; return false; } BOOST_LOG(info) << "Launch command sent for id=" << config.game_id << " reason=" << reason; return true; }; auto schedule_focus_retry = [&]() { if (!got_started.load(std::memory_order_acquire)) { return; } if (config.focus_attempts <= 0 || config.focus_timeout_secs <= 0) { return; } auto now = std::chrono::steady_clock::now(); auto deadline = now + std::chrono::seconds(std::max(1, config.focus_timeout_secs)); // If autofocus is already active, don't reset the attempt budget. // Playnite sends multiple status updates (installDir, exe, gameStarted) // in rapid succession for the same game launch, and each one was // resetting the budget — effectively making it unlimited. // Only extend the deadline and clear the throttle so the next attempt // uses the updated target info promptly. if (request_game_focus.load(std::memory_order_acquire)) { focus_retry_deadline_ms.store(steady_to_millis(deadline), std::memory_order_relaxed); next_focus_attempt_ms.store(std::numeric_limits::min(), std::memory_order_relaxed); BOOST_LOG(debug) << "Autofocus: extended deadline (budget already active, remaining=" << game_focus_successes_left.load(std::memory_order_acquire) << ')'; return; } focus_retry_deadline_ms.store(steady_to_millis(deadline), std::memory_order_relaxed); next_focus_attempt_ms.store(std::numeric_limits::min(), std::memory_order_relaxed); int attempts = std::max(0, config.focus_attempts); if (focus_exit_on_first_flag && attempts > 1) { attempts = 1; } game_focus_successes_left.store(attempts, std::memory_order_release); game_focus_confirmed.store(false, std::memory_order_release); request_game_focus.store(true, std::memory_order_release); }; client.set_message_handler([&](std::span bytes) { auto msg = platf::playnite::parse(bytes); using MT = platf::playnite::MessageType; if (msg.type != MT::Status) { return; } auto norm = [&](std::string s) { return normalize_game_id(std::move(s)); }; if (!msg.status_game_id.empty() && norm(msg.status_game_id) == norm(config.game_id)) { const bool launch_was_sent = launch_command_sent.load(std::memory_order_acquire); if (!launch_was_sent && msg.status_name != "gameStarted") { BOOST_LOG(debug) << "Ignoring pre-launch status '" << msg.status_name << "' for id=" << msg.status_game_id; return; } if (!launch_was_sent) { // The plugin synchronizes an already-running game as soon as the launcher // connects. That status can arrive before the launcher has sent its launch // command, which is the normal resume path rather than stale state. BOOST_LOG(info) << "Accepting active game sync before launch for id=" << msg.status_game_id; } if (!msg.status_install_dir.empty()) { bool changed; std::string install_dir; { std::lock_guard lock(game_info_mutex); changed = last_install_dir != msg.status_install_dir; last_install_dir = msg.status_install_dir; install_dir = last_install_dir; } if (!watcher_spawned.load()) { bool expected = false; if (watcher_spawned.compare_exchange_strong(expected, true)) { WCHAR selfPath[MAX_PATH] = {}; GetModuleFileNameW(nullptr, selfPath, ARRAYSIZE(selfPath)); if (!playnite::spawn_cleanup_watchdog_process(selfPath, install_dir, exit_timeout_secs, false, GetCurrentProcessId())) { watcher_spawned.store(false); } } } if (changed) { schedule_focus_retry(); } } if (!msg.status_exe.empty()) { bool changed; { std::lock_guard lock(game_info_mutex); changed = last_game_exe != msg.status_exe; last_game_exe = msg.status_exe; } if (changed) { schedule_focus_retry(); } } if (msg.status_name == "gameStarted") { got_started.store(true); game_stop_pending.store(false, std::memory_order_release); launch_retry_budget.store(0, std::memory_order_release); schedule_focus_retry(); // Wait for user to unlock if they launched the game while locked bool was_locked = false; while (platf::dxgi::is_secure_desktop_active()) { if (!was_locked) { BOOST_LOG(info) << "Secure desktop detected (user locked screen). Waiting for unlock before applying Lossless Scaling and autofocus..."; was_locked = true; } if (should_exit.load()) { BOOST_LOG(info) << "Exit requested while waiting for unlock"; return; } std::this_thread::sleep_for(500ms); } if (was_locked) { BOOST_LOG(info) << "User unlocked. Proceeding with Lossless Scaling and autofocus."; } if (lossless_options.enabled && !lossless_profiles_applied.load(std::memory_order_acquire)) { if (lossless_options.launch_delay_seconds > 0) { BOOST_LOG(info) << "Lossless Scaling: delaying launch by " << lossless_options.launch_delay_seconds << " seconds after gameStarted"; auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(lossless_options.launch_delay_seconds); while (std::chrono::steady_clock::now() < deadline) { if (should_exit.load()) { return; } std::this_thread::sleep_for(200ms); } } auto runtime = lossless::capture_lossless_scaling_state(); if (!runtime.running_pids.empty()) { lossless::lossless_scaling_stop_processes(runtime); } const auto [install_dir, game_exe] = snapshot_game_info(); lossless::lossless_scaling_profile_backup backup; bool changed = lossless::lossless_scaling_apply_global_profile(lossless_options, install_dir, game_exe, backup); { std::lock_guard lock(lossless_backup_mutex); if (backup.valid) { active_lossless_backup = backup; } else { active_lossless_backup = {}; } } lossless_profiles_applied.store(backup.valid, std::memory_order_release); DWORD focus_pid = last_confirmed_focus_pid.load(std::memory_order_acquire); if (!focus_pid) { if (auto selected = lossless::lossless_scaling_select_focus_pid(install_dir, game_exe, std::nullopt)) { focus_pid = *selected; } } lossless::lossless_scaling_restart_foreground( runtime, changed, install_dir, game_exe, focus_pid, lossless_options.legacy_auto_detect ); } } if (msg.status_name == "stopRequested") { BOOST_LOG(info) << "Explicit stop requested for id=" << config.game_id; explicit_stop_requested.store(true, std::memory_order_release); should_exit.store(true, std::memory_order_release); request_game_focus.store(false, std::memory_order_release); game_focus_confirmed.store(false, std::memory_order_release); game_focus_successes_left.store(0, std::memory_order_release); lossless_refocus_pending.store(false, std::memory_order_release); focus_retry_deadline_ms.store(0, std::memory_order_relaxed); next_focus_attempt_ms.store(std::numeric_limits::min(), std::memory_order_relaxed); teardown_lossless_scaling(); return; } if (msg.status_name == "gameStopped") { if (!got_started.load(std::memory_order_acquire)) { int retries_left = launch_retry_budget.load(std::memory_order_acquire); while (retries_left > 0) { if (launch_retry_budget.compare_exchange_weak(retries_left, retries_left - 1, std::memory_order_acq_rel)) { BOOST_LOG(info) << "Received gameStopped before gameStarted; retrying launch for id=" << config.game_id << " retries_left=" << (retries_left - 1); if (!send_launch_command("retry-after-prestart-stop")) { should_exit.store(true); } return; } } BOOST_LOG(debug) << "Ignoring gameStopped before fresh gameStarted for id=" << msg.status_game_id << " (retry budget exhausted)"; return; } // Treat the plugin notification as a request to verify that the // launched process is actually gone. Some launchers emit a // transient stop while handing off to the real game process. game_stop_pending.store(true, std::memory_order_release); request_game_focus.store(false, std::memory_order_release); game_focus_confirmed.store(false, std::memory_order_release); game_focus_successes_left.store(0, std::memory_order_release); lossless_refocus_pending.store(false, std::memory_order_release); focus_retry_deadline_ms.store(0, std::memory_order_relaxed); next_focus_attempt_ms.store(std::numeric_limits::min(), std::memory_order_relaxed); // Process verification is deferred, but the Lossless Scaling overrides are // not: Sunshine force-kills this process group at exit_timeout (10s by // default), which is shorter than the verification grace, so a deferred // restore would never run and would leave the user's Settings.xml mutated // and LosslessScaling.exe alive. Restore now; a resumed game re-applies on // the next gameStarted. teardown_lossless_scaling(); } } }); client.set_connected_handler([&]() { try { nlohmann::json hello; hello["type"] = "hello"; hello["role"] = "launcher"; hello["pid"] = static_cast(GetCurrentProcessId()); hello["mode"] = "standard"; if (!config.game_id.empty()) { hello["gameId"] = config.game_id; } client.send_json_line(hello.dump()); } catch (...) {} }); client.start(); if (!config.game_id.empty()) { ensure_playnite_open(); } auto start_deadline = std::chrono::steady_clock::now() + std::chrono::minutes(2); while (!client.is_active() && std::chrono::steady_clock::now() < start_deadline) { std::this_thread::sleep_for(50ms); } if (!client.is_active()) { BOOST_LOG(error) << "IPC did not become active; exiting"; client.stop(); return 3; } if (!send_launch_command("initial")) { client.stop(); return 3; } // Wait for user to unlock if they launched the game while locked bool was_initially_locked = false; while (platf::dxgi::is_secure_desktop_active()) { ensure_cleanup_lease(); if (!was_initially_locked) { BOOST_LOG(info) << "Secure desktop detected at launch (user locked screen). Waiting for unlock before proceeding..."; was_initially_locked = true; } if (should_exit.load()) { BOOST_LOG(info) << "Exit requested while waiting for unlock at launch"; client.stop(); return 0; } std::this_thread::sleep_for(500ms); } if (was_initially_locked) { BOOST_LOG(info) << "User unlocked. Proceeding with game launch."; } if (config.focus_attempts > 0 && config.focus_timeout_secs > 0) { if (got_started.load()) { schedule_focus_retry(); BOOST_LOG(info) << "Autofocus scheduled after launch"; } else { BOOST_LOG(info) << "Autofocus deferred until gameStarted signal"; } } auto restart_lossless_after_refocus = [&]() { if (!lossless_options.enabled || !lossless_profiles_applied.load(std::memory_order_acquire)) { return; } const auto [install_dir, game_exe] = snapshot_game_info(); auto runtime = lossless::capture_lossless_scaling_state(); if (!runtime.running_pids.empty()) { lossless::lossless_scaling_stop_processes(runtime); } DWORD focus_pid = last_confirmed_focus_pid.load(std::memory_order_acquire); if (!focus_pid) { if (auto selected = lossless::lossless_scaling_select_focus_pid(install_dir, game_exe, std::nullopt)) { focus_pid = *selected; } } BOOST_LOG(info) << "Autofocus: refocus detected; restarting Lossless Scaling"; lossless::lossless_scaling_restart_foreground( runtime, true, install_dir, game_exe, focus_pid, lossless_options.legacy_auto_detect ); }; auto normalize_path = [](std::wstring value) { for (auto &ch : value) { if (ch == L'/') { ch = L'\\'; } ch = static_cast(std::towlower(ch)); } return value; }; auto path_equals = [&](const std::wstring &lhs, const std::wstring &rhs) -> bool { if (lhs.empty() || rhs.empty()) { return false; } return normalize_path(lhs) == normalize_path(rhs); }; auto path_starts_with_dir = [&](const std::wstring &path, const std::wstring &dir) -> bool { if (path.empty() || dir.empty()) { return false; } auto normalized_path = normalize_path(path); auto normalized_dir = normalize_path(dir); if (!normalized_dir.empty() && normalized_dir.back() != L'\\') { normalized_dir.push_back(L'\\'); } if (normalized_path.size() < normalized_dir.size()) { return false; } return normalized_path.compare(0, normalized_dir.size(), normalized_dir) == 0; }; auto process_path_matches_game = [&](const std::wstring &image_path, const std::string &install_dir, const std::string &game_exe) { if (image_path.empty()) { return false; } if (!game_exe.empty()) { try { if (path_equals(image_path, platf::dxgi::utf8_to_wide(game_exe))) { return true; } } catch (...) { } } if (!install_dir.empty()) { try { if (path_starts_with_dir(image_path, platf::dxgi::utf8_to_wide(install_dir))) { return true; } } catch (...) { } } return false; }; auto process_matches_game = [&](DWORD pid, const std::string &install_dir, const std::string &game_exe) { if (!pid) { return false; } std::wstring image_path; return focus::get_process_image_path(pid, image_path) && process_path_matches_game(image_path, install_dir, game_exe); }; auto foreground_pid = [&]() -> DWORD { HWND fg = GetForegroundWindow(); if (!fg || !IsWindowVisible(fg) || IsIconic(fg) || GetWindow(fg, GW_OWNER) != nullptr) { return 0; } DWORD pid = 0; GetWindowThreadProcessId(fg, &pid); return pid; }; auto is_game_foreground = [&]() -> bool { DWORD pid = foreground_pid(); if (!pid) { return false; } const auto [install_dir, game_exe] = snapshot_game_info(); return process_matches_game(pid, install_dir, game_exe); }; auto focus_game_after_start = [&]() { if (!request_game_focus.load(std::memory_order_acquire)) { return; } if (config.focus_attempts <= 0 || config.focus_timeout_secs <= 0) { request_game_focus.store(false, std::memory_order_release); return; } auto now = std::chrono::steady_clock::now(); int attempts_left = game_focus_successes_left.load(std::memory_order_acquire); if (attempts_left <= 0) { request_game_focus.store(false, std::memory_order_release); return; } int64_t deadline_ms = focus_retry_deadline_ms.load(std::memory_order_relaxed); if (deadline_ms != 0) { auto deadline = millis_to_steady(deadline_ms); if (now >= deadline) { request_game_focus.store(false, std::memory_order_release); return; } } int64_t next_ms = next_focus_attempt_ms.load(std::memory_order_relaxed); if (next_ms != std::numeric_limits::min()) { auto next_time = millis_to_steady(next_ms); if (now < next_time) { return; } } auto consume_focus_confirmation = [&](const char *source, DWORD confirmed_pid) { bool first_confirmation = !game_focus_confirmed.exchange(true, std::memory_order_acq_rel); had_focus_success.store(true, std::memory_order_release); if (confirmed_pid) { last_confirmed_focus_pid.store(confirmed_pid, std::memory_order_release); } if (lossless_refocus_pending.exchange(false, std::memory_order_acq_rel)) { restart_lossless_after_refocus(); } if (!first_confirmation) { next_focus_attempt_ms.store(steady_to_millis(now + 1s), std::memory_order_relaxed); return; } if (focus_exit_on_first_flag) { BOOST_LOG(info) << "Autofocus: focus confirmed for launched game via " << source << " (exit-on-first)"; request_game_focus.store(false, std::memory_order_release); return; } int left = game_focus_successes_left.fetch_sub(1, std::memory_order_acq_rel) - 1; BOOST_LOG(info) << "Autofocus: focus confirmed for launched game via " << source << " (remaining=" << left << ')'; if (left <= 0) { request_game_focus.store(false, std::memory_order_release); } next_focus_attempt_ms.store(steady_to_millis(now + 1s), std::memory_order_relaxed); }; bool visible = is_game_foreground(); if (visible) { consume_focus_confirmation("foreground", foreground_pid()); return; } game_focus_confirmed.store(false, std::memory_order_release); if (had_focus_success.load(std::memory_order_acquire)) { lossless_refocus_pending.store(true, std::memory_order_release); } int remaining = config.focus_timeout_secs; if (deadline_ms != 0) { auto deadline = millis_to_steady(deadline_ms); remaining = static_cast(std::chrono::duration_cast(deadline - now).count()); } if (remaining <= 0) { request_game_focus.store(false, std::memory_order_release); return; } int slice = std::clamp(remaining, 1, 3); bool applied = false; DWORD confirmed_focus_pid = 0; auto cancel = [&]() { return should_exit.load(); }; const auto [install_dir, game_exe] = snapshot_game_info(); if (!applied && !game_exe.empty()) { try { std::wstring wexe = platf::dxgi::utf8_to_wide(game_exe); std::filesystem::path p = wexe; std::wstring base = p.filename().wstring(); if (!base.empty()) { applied = focus::focus_process_by_name_extended(base.c_str(), 1, slice, true, cancel, &confirmed_focus_pid); } } catch (...) { } } if (!applied && !install_dir.empty()) { try { std::wstring wdir = platf::dxgi::utf8_to_wide(install_dir); applied = focus::focus_by_install_dir_extended(wdir, 1, slice, true, cancel, &confirmed_focus_pid); } catch (...) { } } DWORD confirmed_pid = applied ? confirmed_focus_pid : 0; bool confirmed = confirmed_pid != 0; if (confirmed) { consume_focus_confirmation("focus-attempt", confirmed_pid); } else { game_focus_confirmed.store(false, std::memory_order_release); if (applied) { BOOST_LOG(debug) << "Autofocus: focus helper returned success without a confirmed PID"; } } next_focus_attempt_ms.store(steady_to_millis(now + 1s), std::memory_order_relaxed); }; auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(config.timeout_sec); // While the plugin connection is active, gameStarted is authoritative. // A gameStopped notification starts local process verification rather // than tearing the session down immediately, because launch handoffs can // emit a transient stop while the actual game remains alive. std::optional game_missing_since; std::optional game_verification_deadline; const auto game_missing_grace = std::chrono::seconds(15); const auto game_verification_limit = std::chrono::seconds(std::max(30, config.timeout_sec)); while (!should_exit.load()) { ensure_cleanup_lease(); focus_game_after_start(); if (!got_started.load() && std::chrono::steady_clock::now() >= deadline) { break; } if (got_started.load()) { const bool verify_game_processes = !client.is_active() || game_stop_pending.load(std::memory_order_acquire); if (!verify_game_processes) { game_missing_since.reset(); game_verification_deadline.reset(); std::this_thread::sleep_for(250ms); continue; } const auto now = std::chrono::steady_clock::now(); const auto [install_dir, game_exe] = snapshot_game_info(); bool any_game_proc = false; const auto confirmed_focus_pid = last_confirmed_focus_pid.load(std::memory_order_acquire); if (confirmed_focus_pid && process_matches_game(confirmed_focus_pid, install_dir, game_exe)) { any_game_proc = true; } if (!install_dir.empty()) { try { std::wstring wdir = platf::dxgi::utf8_to_wide(install_dir); if (!focus::find_pids_under_install_dir_sorted(wdir, false).empty()) { any_game_proc = true; } } catch (...) { } } if (!any_game_proc && !game_exe.empty()) { try { std::wstring wexe = platf::dxgi::utf8_to_wide(game_exe); std::filesystem::path p(wexe); std::wstring base = p.filename().wstring(); if (!base.empty()) { for (const auto pid : platf::dxgi::find_process_ids_by_name(base.c_str())) { if (process_matches_game(pid, install_dir, game_exe)) { any_game_proc = true; break; } } } } catch (...) { } } if (any_game_proc) { game_missing_since.reset(); game_verification_deadline = now + game_verification_limit; } else { if (!game_verification_deadline) { game_verification_deadline = now + game_verification_limit; } else if (now >= *game_verification_deadline) { BOOST_LOG(warning) << "Game process verification deadline reached; proceeding to cleanup"; should_exit.store(true); break; } if (!game_missing_since) { game_missing_since = now; } else if (now - *game_missing_since >= game_missing_grace) { BOOST_LOG(info) << "Game processes have exited; proceeding to cleanup"; should_exit.store(true); break; } } } std::this_thread::sleep_for(250ms); } if (!should_exit.load()) { BOOST_LOG(warning) << (got_started.load() ? "Timeout after start unexpectedly; exiting" : "Timeout waiting for game start; exiting"); } request_game_focus.store(false, std::memory_order_release); game_focus_confirmed.store(false, std::memory_order_release); game_focus_successes_left.store(0, std::memory_order_release); lossless_refocus_pending.store(false, std::memory_order_release); had_focus_success.store(false, std::memory_order_release); last_confirmed_focus_pid.store(0, std::memory_order_release); focus_retry_deadline_ms.store(0, std::memory_order_relaxed); next_focus_attempt_ms.store(std::numeric_limits::min(), std::memory_order_relaxed); const auto install_dir = snapshot_game_info().first; if (explicit_stop_requested.load(std::memory_order_acquire)) { if (!install_dir.empty()) { try { BOOST_LOG(info) << "Explicit stop requested; beginning graceful cleanup immediately"; cleanup::cleanup_graceful_then_forceful_in_dir( platf::dxgi::utf8_to_wide(install_dir), exit_timeout_secs ); } catch (...) { BOOST_LOG(warning) << "Explicit stop requested; immediate graceful cleanup failed"; } } else { BOOST_LOG(warning) << "Explicit stop requested without an install directory; falling back to cleanup watcher"; } } BOOST_LOG(info) << "Playnite launcher is scheduling fallback cleanup and exiting"; if (!install_dir.empty() && !watcher_spawned.load()) { WCHAR selfPath[MAX_PATH] = {}; GetModuleFileNameW(nullptr, selfPath, ARRAYSIZE(selfPath)); static_cast(playnite::spawn_cleanup_watchdog_process(selfPath, install_dir, exit_timeout_secs, false, GetCurrentProcessId())); } teardown_lossless_scaling(); int exit_code = should_exit.load() ? 0 : 4; client.stop(); return exit_code; } } // namespace detail } // namespace playnite_launcher