server/tools/playnite_launcher/launcher.cpp
Rama main · réplica de lectura del repositorio original.
#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 <algorithm>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <limits>
#include <mutex>
#include <nlohmann/json.hpp>
#include <optional>
#include <ShlObj.h>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
#include <windows.h>
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<std::chrono::milliseconds>(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<char>(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<DWORD>(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<bool> pipe_connected {false};
std::atomic<bool> game_start_signal {false};
std::atomic<bool> game_stop_signal {false};
std::atomic<bool> cleanup_spawn_signal {false};
std::atomic<bool> 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<bool> playnite_user_exit {false};
std::atomic<int64_t> 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<playnite::cleanup_lease_t> fullscreen_cleanup_lease;
std::string fullscreen_cleanup_lease_install_dir;
std::optional<playnite::cleanup_lease_t> game_cleanup_lease;
std::string game_cleanup_lease_install_dir;
auto ensure_cleanup_leases = [&]() {
std::string game_install_dir;
{
std::lock_guard<std::mutex> 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<bool> 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<const uint8_t> 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<std::mutex> 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<std::mutex> 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<uint32_t>(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<std::mutex> 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<std::chrono::steady_clock::time_point> game_proc_first_seen;
std::optional<std::chrono::steady_clock::time_point> 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<std::mutex> 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<std::mutex> 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<DWORD> 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<std::mutex> 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<std::mutex> 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<int>(std::chrono::duration_cast<std::chrono::seconds>(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<std::mutex> 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<int>(std::chrono::duration_cast<std::chrono::seconds>(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<std::mutex> 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<bool> should_exit {false};
std::atomic<bool> got_started {false};
std::atomic<bool> game_stop_pending {false};
std::atomic<bool> explicit_stop_requested {false};
std::atomic<bool> launch_command_sent {false};
std::atomic<int> launch_retry_budget {2};
std::atomic<bool> request_game_focus {false};
std::atomic<bool> game_focus_confirmed {false};
std::atomic<int> game_focus_successes_left {0};
std::atomic<bool> lossless_refocus_pending {false};
std::atomic<bool> had_focus_success {false};
std::atomic<DWORD> last_confirmed_focus_pid {0};
std::atomic<int64_t> focus_retry_deadline_ms {0};
std::atomic<int64_t> next_focus_attempt_ms {std::numeric_limits<int64_t>::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<std::mutex> 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<bool> 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<std::mutex> 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<bool> watcher_spawned {false};
std::optional<playnite::cleanup_lease_t> 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<int64_t>::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<int64_t>::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<const uint8_t> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<int64_t>::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<int64_t>::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<uint32_t>(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<wchar_t>(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<int64_t>::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<int>(std::chrono::duration_cast<std::chrono::seconds>(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<std::chrono::steady_clock::time_point> game_missing_since;
std::optional<std::chrono::steady_clock::time_point> 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<int64_t>::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<void>(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