From 37a0b68d85b23c14e768775e64c0e60447bbb4ca Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:21:00 +0100 Subject: [PATCH] keep steam desktop layout off while focused --- runtime/include/aurora_events.h | 3 + runtime/include/runtime_config.h | 23 + runtime/include/steam_input_layout.h | 17 + runtime/src/main.cpp | 17 + runtime/src/steam_input_layout.cpp | 603 +++++++++++++++++++++++++ runtime/tests/runtime_config_tests.cpp | 12 + 6 files changed, 675 insertions(+) create mode 100644 runtime/include/steam_input_layout.h create mode 100644 runtime/src/steam_input_layout.cpp diff --git a/runtime/include/aurora_events.h b/runtime/include/aurora_events.h index 7dbaf00c3..f44f8617a 100644 --- a/runtime/include/aurora_events.h +++ b/runtime/include/aurora_events.h @@ -2,6 +2,7 @@ #include "friend_code_keyboard.h" #include "settings_overlay.h" +#include "steam_input_layout.h" #include "runtime_config.h" #include "gx_worker.h" #include "perf_stats.h" @@ -63,6 +64,7 @@ inline void Flush(bool force = false) { // process directly and do not run the crash/atexit paths. [[noreturn]] inline void ExitForAuroraWindowClose() noexcept { settings_overlay::ReleaseControllers(); + SteamInputLayout::Release(); WindowPlacementPersistence::Flush(true); #if defined(_WIN32) ::ExitProcess(0); @@ -120,6 +122,7 @@ inline void ProcessAuroraEvents(const AuroraEvent* events) { } settings_overlay::HandleEvents(events); FriendCodeKeyboard::HandleEvents(events); + SteamInputLayout::HandleEvents(events); } inline void ApplyPendingMkwDynamicAspectSurface() { diff --git a/runtime/include/runtime_config.h b/runtime/include/runtime_config.h index 3e1ee0d63..aa69f878c 100644 --- a/runtime/include/runtime_config.h +++ b/runtime/include/runtime_config.h @@ -70,6 +70,10 @@ struct RuntimeUserConfig { std::optional wiiIrSensitivity; std::optional wiiSpeakerVolume; std::optional wiiRumble; + // Outside a Steam launch, ask a running Steam client for a game controller layout while + // the window has focus instead of its desktop one (buttons bound to mouse and keyboard). + std::optional steamInputOverride; + std::optional steamInputAppId; std::optional networkEnabled; std::optional discordPresenceEnabled; // The application ID of the WiiCompiled Discord application. This is only @@ -308,6 +312,12 @@ inline void EnsureConfigFile() { "mix_worker = true\n\n" "[network]\n" "enabled = true\n\n" + "[controller]\n" + "# Outside a Steam launch, Steam's desktop layout binds controller buttons\n" + "# to mouse and keyboard. While the game has focus, ask a running Steam\n" + "# client to use the game layout of steam_input_app_id instead.\n" + "steam_input_override = true\n" + "# steam_input_app_id = 480\n\n" "[discord]\n" "# Rich Presence talks only to a locally-running Discord client.\n" "# Retro Rewind supplies its official app ID automatically. Set this\n" @@ -489,6 +499,10 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) { config.wiiSpeakerVolume = *value; } config.wiiRumble = FindConfigValue(document, "controller", "wii_rumble"); + config.steamInputOverride = FindConfigValue(document, "controller", "steam_input_override"); + if (const auto value = FindConfigUint(document, "controller", "steam_input_app_id"); value && *value != 0) { + config.steamInputAppId = *value; + } config.networkEnabled = FindConfigValue(document, "network", "enabled"); config.discordPresenceEnabled = FindConfigValue(document, "discord", "enabled"); config.discordClientId = FindConfigValue(document, "discord", "client_id"); @@ -895,6 +909,15 @@ inline bool SetWiiRemotesEnabled(bool value) { return true; } +inline bool SteamInputOverrideEnabled(bool fallback = true) { + return Get().steamInputOverride.value_or(fallback); +} + +// Spacewar, Valve's Steamworks sample app, available to every Steam account. +inline uint32_t SteamInputAppId(uint32_t fallback = 480) { + return Get().steamInputAppId.value_or(fallback); +} + inline bool WiiAutoPairEnabled(bool fallback = true) { return Get().wiiAutoPair.value_or(fallback); } diff --git a/runtime/include/steam_input_layout.h b/runtime/include/steam_input_layout.h new file mode 100644 index 000000000..bcc832343 --- /dev/null +++ b/runtime/include/steam_input_layout.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include + +// Outside a Steam launch, Steam applies its desktop controller layout, which binds +// controller buttons to mouse and keyboard actions. While the game window has focus, +// a running Steam client is asked to apply the given app's game layout instead. +namespace SteamInputLayout { + +void Initialize(bool enabled, uint32_t appId); +void HandleEvents(const AuroraEvent* events); +// Restores the desktop layout. Idempotent; safe to call from any exit path. +void Release() noexcept; + +} // namespace SteamInputLayout diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a34c86cf4..56bec9f7a 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -41,6 +41,7 @@ #include #include #else +#include #include #if defined(__x86_64__) // Only the x86 POSIX fault path inspects ucontext_t to recover the page-fault @@ -68,6 +69,7 @@ #include "aurora_events.h" #include "wiimote_host.h" #include "discord_presence.h" +#include "steam_input_layout.h" #include "fiber_manager.h" #include "gx_worker.h" #include "hle_stubs.h" @@ -337,6 +339,9 @@ bool InstallTranscriptPipe(int& outReadFd, int& outWriteFd, int targetFd) { if (::pipe(pipeFds) != 0) { return false; } + // Spawned helpers must not keep the transcript open; its reader waits for EOF on shutdown. + ::fcntl(pipeFds[0], F_SETFD, FD_CLOEXEC); + ::fcntl(pipeFds[1], F_SETFD, FD_CLOEXEC); #endif outReadFd = pipeFds[0]; @@ -1098,6 +1103,7 @@ LONG ReportFatalSehAndExit(EXCEPTION_POINTERS* info) { << " at host address 0x" << record->ExceptionInformation[1]; } popupDetails << ".\n\nThe process transcript and crash log contain the full CPU and stack diagnostics."; + SteamInputLayout::Release(); ShowRuntimeFatalPopup("a native crash occurred", popupDetails.str()); DumpHostStackTrace(); @@ -1198,6 +1204,7 @@ void PosixMemoryFaultHandler(int sig, siginfo_t* info, void* ucontextVoid) { } popupDetails << ".\n\nThe process transcript and crash log contain the full CPU and stack " "diagnostics."; + SteamInputLayout::Release(); ShowRuntimeFatalPopup("a native crash occurred", popupDetails.str()); DumpHostStackTrace(); WriteFatalLogImpl(sig == SIGBUS ? "sigbus" : "sigsegv"); @@ -1231,6 +1238,7 @@ void AbortSignalHandler(int signum) { // abort() bypasses atexit, so the guest-memory fault summary has to be // emitted here too. It is idempotent, so a later AtExitHandler is a no-op. GuestFlat::LogFaultSummary(); + SteamInputLayout::Release(); ShowRuntimeFatalPopup("a fatal internal error occurred", "The process called abort while running the game or Aurora renderer.\n\n" @@ -1266,6 +1274,7 @@ void SetRuntimeExitCode(int code) { // Global handler called via atexit() to flush buffers before any exit static void AtExitHandler() { + SteamInputLayout::Release(); // End-of-run guest memory report. This runs before the fatal-report check // below because the counters describe the whole session and are just as // interesting after a crash as after a clean exit. @@ -1290,6 +1299,7 @@ static void AtExitHandler() { // Global terminate handler for uncaught exceptions static void TerminateHandler() { + SteamInputLayout::Release(); // Skip detailed dump if already reported if (g_fatalErrorReported.load(std::memory_order_acquire)) { std::fflush(stderr); @@ -1480,6 +1490,10 @@ int RuntimeMain(int argc, char** argv) { UpdateMkwDynamicAspectSurface(auroraInfo.windowSize.native_fb_width, auroraInfo.windowSize.native_fb_height); settings_overlay::InitializeRuntimeSettings(); + if (!g_headless) { + SteamInputLayout::Initialize(RuntimeConfigFile::SteamInputOverrideEnabled(), + RuntimeConfigFile::SteamInputAppId()); + } if (BootRace::Requested()) { aurora_set_skip_unready_pipelines(false); } @@ -1525,6 +1539,7 @@ int RuntimeMain(int argc, char** argv) { Fiber::GuestFiberManager::Shutdown(); WindowPlacementPersistence::Flush(true); Wiimote::Shutdown(); + SteamInputLayout::Release(); GxWorker::Stop(); aurora_shutdown(); DiscordPresence::Shutdown(); @@ -1539,6 +1554,7 @@ int RuntimeMain(int argc, char** argv) { << " len=0x" << ex.length() << std::dec << std::nouppercase << " reason=" << ex.reason(); + SteamInputLayout::Release(); ShowRuntimeFatalPopup("a guest memory access was out of bounds", details.str()); WriteFatalLogImpl("access_violation", details.str()); SetRuntimeExitCodeImpl(1); @@ -1553,6 +1569,7 @@ int RuntimeMain(int argc, char** argv) { } catch (const std::exception& ex) { std::cerr << "Runtime error: " << ex.what() << std::endl; SystemBridge::DumpCpuState(TryGetCpuContext()); + SteamInputLayout::Release(); ShowRuntimeFatalPopup("a runtime exception occurred", ex.what()); WriteFatalLogImpl("exception"); SetRuntimeExitCodeImpl(1); diff --git a/runtime/src/steam_input_layout.cpp b/runtime/src/steam_input_layout.cpp new file mode 100644 index 000000000..c6b9d0bcc --- /dev/null +++ b/runtime/src/steam_input_layout.cpp @@ -0,0 +1,603 @@ +#include "steam_input_layout.h" + +#include "runtime_log.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +#include +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(__APPLE__) +#include +#include +#include +#else +#include +#include +#include +#endif + +extern char** environ; +#endif + +namespace SteamInputLayout { +namespace { + +using Clock = std::chrono::steady_clock; + +enum class Handoff { None, Unconfirmed, Confirmed }; + +constexpr std::chrono::milliseconds kHandoffTimeout{2000}; +#if defined(__linux__) +// xdg-open can return before the Steam forwarder it started has delivered the URL. +constexpr std::chrono::milliseconds kDeliverySpacing{1500}; +#else +constexpr std::chrono::milliseconds kDeliverySpacing{0}; +#endif + +struct State { + std::mutex mutex; + std::condition_variable wake; + // Serializes requests to Steam so they arrive in order. + std::timed_mutex delivery; + bool enabled = false; + bool stopping = false; + bool desired = false; + bool sent = false; + bool maybeForced = false; + uint32_t appId = 0; + SDL_WindowID windowId = 0; + std::atomic lastDelivery{0}; + std::atomic released{false}; +}; + +// Never destroyed: the detached worker and late exit paths may still touch it. +std::atomic g_state{nullptr}; +thread_local bool t_isWorker = false; +thread_local bool t_inRelease = false; + +// The worker launches and publishes its request helper under this lock, so an exit that +// can't wait for the worker can cancel it: either the helper is published, or it never starts. +std::mutex g_helperMutex; +std::atomic g_helperCancelled{false}; +#if defined(_WIN32) +HANDLE g_helper = nullptr; +#else +pid_t g_helper = 0; +#endif + +// These locks are only held briefly; failing to get one means a crash inside it. +bool TryLockBriefly(std::unique_lock& lock, + std::chrono::milliseconds timeout = std::chrono::milliseconds(250)) { + const auto deadline = Clock::now() + timeout; + while (!lock.try_lock()) { + if (Clock::now() >= deadline) { + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return true; +} + +// Steam applies the launching app's own layout to everything it starts, children included. +bool LaunchedBySteam() { + for (const char* name : {"SteamGameId", "SteamAppId", "SteamClientLaunch"}) { + const char* value = std::getenv(name); + if (value != nullptr && *value != '\0') { + return true; + } + } +#if defined(_WIN32) + if (GetModuleHandleW(L"GameOverlayRenderer64.dll") != nullptr) { + return true; + } +#endif + return false; +} + +#if defined(_WIN32) + +std::vector TokenUserOf(HANDLE process) { + std::vector user; + HANDLE token = nullptr; + if (!OpenProcessToken(process, TOKEN_QUERY, &token)) { + return user; + } + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + user.resize(size); + if (size == 0 || !GetTokenInformation(token, TokenUser, user.data(), size, &size)) { + user.clear(); + } + CloseHandle(token); + return user; +} + +// A stale pid can be reused by another signed-in user's Steam. +bool OwnedByCurrentUser(HANDLE process) { + const auto theirs = TokenUserOf(process); + const auto ours = TokenUserOf(GetCurrentProcess()); + return !theirs.empty() && !ours.empty() && + EqualSid(reinterpret_cast(theirs.data())->User.Sid, + reinterpret_cast(ours.data())->User.Sid); +} + +// ActiveProcess\pid outlives a Steam crash, so the process behind it is verified too. +std::optional RunningSteamExecutable() { + DWORD pid = 0; + DWORD size = sizeof(pid); + if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam\\ActiveProcess", L"pid", + RRF_RT_REG_DWORD, nullptr, &pid, &size) != ERROR_SUCCESS || + pid == 0) { + return std::nullopt; + } + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (process == nullptr) { + return std::nullopt; + } + std::wstring path(32768, L'\0'); + DWORD length = static_cast(path.size()); + DWORD exitCode = 0; + const bool alive = QueryFullProcessImageNameW(process, 0, path.data(), &length) && + GetExitCodeProcess(process, &exitCode) && exitCode == STILL_ACTIVE && + OwnedByCurrentUser(process); + CloseHandle(process); + if (!alive) { + return std::nullopt; + } + path.resize(length); + if (_wcsicmp(std::filesystem::path(path).filename().c_str(), L"steam.exe") != 0) { + return std::nullopt; + } + return path; +} + +// Same command line as Steam's registered steam:// handler, sent to the verified client. +Handoff OpenSteamUrl(const std::string& url, bool bounded) { + const auto steam = RunningSteamExecutable(); + if (!steam) { + return Handoff::None; + } + std::wstring commandLine = L"\"" + *steam + L"\" -- \"" + std::wstring(url.begin(), url.end()) + L"\""; + const std::wstring directory = std::filesystem::path(*steam).parent_path().wstring(); + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION info{}; + std::unique_lock launch(g_helperMutex, std::defer_lock); + if (!bounded) { + launch.lock(); + if (g_helperCancelled) { + return Handoff::None; + } + } + // The worker's helper starts suspended so a cancellation that couldn't take the lock while + // CreateProcessW ran still stops it before it can reach Steam. + if (!CreateProcessW(steam->c_str(), commandLine.data(), nullptr, nullptr, FALSE, + bounded ? 0 : CREATE_SUSPENDED, nullptr, directory.c_str(), &startup, &info)) { + return Handoff::None; + } + if (!bounded) { + g_helper = info.hProcess; + launch.unlock(); + if (g_helperCancelled) { + TerminateProcess(info.hProcess, 1); + } else { + ResumeThread(info.hThread); + } + } + CloseHandle(info.hThread); + const DWORD waited = WaitForSingleObject(info.hProcess, bounded ? static_cast(kHandoffTimeout.count()) : INFINITE); + if (!bounded) { + std::lock_guard lock(g_helperMutex); + g_helper = nullptr; + } + DWORD exitCode = 1; + const bool succeeded = waited == WAIT_OBJECT_0 && GetExitCodeProcess(info.hProcess, &exitCode) && exitCode == 0; + CloseHandle(info.hProcess); + return succeeded ? Handoff::Confirmed : Handoff::Unconfirmed; +} + +void CancelWorkerRequests() { + g_helperCancelled = true; + std::unique_lock lock(g_helperMutex, std::defer_lock); + if (!TryLockBriefly(lock, kHandoffTimeout)) { + return; + } + if (g_helper != nullptr && TerminateProcess(g_helper, 1)) { + WaitForSingleObject(g_helper, static_cast(kHandoffTimeout.count())); + } +} + +#else + +// Only a live client of this user; a URL handler with no client running would start Steam. +bool SteamRunning() { + const uid_t uid = getuid(); +#if defined(__APPLE__) + const int count = proc_listallpids(nullptr, 0); + if (count <= 0) { + return false; + } + std::vector pids(static_cast(count) + 32); + const int filled = proc_listallpids(pids.data(), static_cast(pids.size() * sizeof(pid_t))); + for (int i = 0; i < filled; ++i) { + char name[2 * MAXCOMLEN + 1] = {}; + if (proc_name(pids[i], name, sizeof(name)) <= 0 || std::string(name) != "steam_osx") { + continue; + } + proc_bsdinfo info{}; + if (proc_pidinfo(pids[i], PROC_PIDTBSDINFO, 0, &info, sizeof(info)) == sizeof(info) && + info.pbi_uid == uid && info.pbi_status != SZOMB) { + return true; + } + } + return false; +#else + DIR* proc = opendir("/proc"); + if (proc == nullptr) { + return false; + } + bool found = false; + while (const dirent* entry = readdir(proc)) { + if (entry->d_name[0] < '1' || entry->d_name[0] > '9') { + continue; + } + std::ifstream status(std::string("/proc/") + entry->d_name + "/status"); + std::string line; + bool named = false; + bool live = false; + bool owned = false; + while (std::getline(status, line)) { + if (line.rfind("Name:", 0) == 0) { + named = line == "Name:\tsteam"; + } else if (line.rfind("State:", 0) == 0) { + live = line.find_first_of("ZX") == std::string::npos; + } else if (line.rfind("Uid:", 0) == 0) { + std::istringstream fields(line.substr(4)); + uid_t real = 0; + owned = static_cast(fields >> real) && real == uid; + break; + } + } + if (named && live && owned) { + found = true; + break; + } + } + closedir(proc); + return found; +#endif +} + +Handoff OpenSteamUrl(const std::string& url, bool bounded) { + if (!SteamRunning()) { + return Handoff::None; + } + // The worker's helper waits for a go-ahead on fd 3 before it runs the URL handler, so a + // cancellation that couldn't take the lock while posix_spawn ran still stops it in time. + static char gateScript[] = "read -r go <&3 || exit 1; exec 3<&-; exec \"$0\" \"$@\""; +#if defined(__APPLE__) + const char* program = "/usr/bin/open"; + char* argv[] = {const_cast("open"), const_cast("-g"), const_cast(url.c_str()), nullptr}; + char* gated[] = {const_cast("sh"), const_cast("-c"), gateScript, const_cast(program), + const_cast("-g"), const_cast(url.c_str()), nullptr}; + constexpr int kNoSignal = 0; +#else + const char* program = "xdg-open"; + char* argv[] = {const_cast("xdg-open"), const_cast(url.c_str()), nullptr}; + char* gated[] = {const_cast("sh"), const_cast("-c"), gateScript, const_cast(program), + const_cast(url.c_str()), nullptr}; + constexpr int kNoSignal = MSG_NOSIGNAL; +#endif + int gate[2] = {-1, -1}; + if (!bounded) { + if (socketpair(AF_UNIX, SOCK_STREAM, 0, gate) != 0) { + return Handoff::None; + } + // dup2(3, 3) keeps FD_CLOEXEC on macOS, which would close the gate in the child. + if (gate[0] == 3) { + std::swap(gate[0], gate[1]); + } + fcntl(gate[0], F_SETFD, FD_CLOEXEC); + fcntl(gate[1], F_SETFD, FD_CLOEXEC); +#if defined(__APPLE__) + int noSigPipe = 1; + setsockopt(gate[1], SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, sizeof(noSigPipe)); +#endif + } + const auto closeGate = [&gate] { + for (int& end : gate) { + if (end != -1) { + close(end); + end = -1; + } + } + }; + posix_spawn_file_actions_t actions; + if (posix_spawn_file_actions_init(&actions) != 0) { + closeGate(); + return Handoff::None; + } + if (!bounded) { + posix_spawn_file_actions_adddup2(&actions, gate[0], 3); + } + posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); + posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, "/dev/null", O_WRONLY, 0); + posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0); + // A crash handler can call this with signals blocked; the child should not inherit that. + posix_spawnattr_t attributes; + posix_spawnattr_init(&attributes); + sigset_t noSignals; + sigemptyset(&noSignals); + posix_spawnattr_setsigmask(&attributes, &noSignals); + posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSIGMASK); + pid_t child = 0; + std::unique_lock launch(g_helperMutex, std::defer_lock); + if (!bounded) { + launch.lock(); + } + int error = ECANCELED; + if (bounded) { + error = posix_spawnp(&child, program, &actions, &attributes, argv, environ); + } else if (!g_helperCancelled) { + error = posix_spawn(&child, "/bin/sh", &actions, &attributes, gated, environ); + } + posix_spawnattr_destroy(&attributes); + posix_spawn_file_actions_destroy(&actions); + if (error != 0) { + closeGate(); + return Handoff::None; + } + int status = 0; + if (!bounded) { + g_helper = child; + if (!g_helperCancelled) { + send(gate[1], "1\n", 2, kNoSignal); + } + closeGate(); + launch.unlock(); + // Wait without reaping so the pid can't be reused while it is still published. + siginfo_t info{}; + while (waitid(P_PID, static_cast(child), &info, WEXITED | WNOWAIT) == -1 && errno == EINTR) { + } + { + std::lock_guard lock(g_helperMutex); + g_helper = 0; + } + } + const auto deadline = Clock::now() + kHandoffTimeout; + for (;;) { + const pid_t waited = waitpid(child, &status, bounded ? WNOHANG : 0); + if (waited == child) { + return WIFEXITED(status) && WEXITSTATUS(status) == 0 ? Handoff::Confirmed : Handoff::Unconfirmed; + } + if (waited == -1 && errno != EINTR) { + return Handoff::Unconfirmed; + } + if (waited == 0) { + if (Clock::now() >= deadline) { + try { + std::thread([child] { waitpid(child, nullptr, 0); }).detach(); + } catch (...) { + } + return Handoff::Unconfirmed; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } +} + +void CancelWorkerRequests() { + g_helperCancelled = true; + std::unique_lock lock(g_helperMutex, std::defer_lock); + if (!TryLockBriefly(lock, kHandoffTimeout)) { + return; + } + if (g_helper == 0 || kill(g_helper, SIGKILL) != 0) { + return; + } + // The worker only reaps after clearing g_helper under this lock, so the pid stays ours. + const auto deadline = Clock::now() + kHandoffTimeout; + for (;;) { + siginfo_t info{}; + if (waitid(P_PID, static_cast(g_helper), &info, WEXITED | WNOWAIT | WNOHANG) != 0 || + info.si_pid != 0 || Clock::now() >= deadline) { + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } +} + +#endif + +// Caller holds state.delivery. Only exit paths bound the wait; the worker waits for each +// request to finish so a slow one can never land after the next. +Handoff Deliver(State& state, bool forced, uint32_t appId, bool bounded) { + const auto earliest = Clock::time_point(Clock::duration(state.lastDelivery.load())) + kDeliverySpacing; + if (const auto now = Clock::now(); now < earliest) { + std::this_thread::sleep_for(earliest - now); + } + const Handoff delivered = OpenSteamUrl("steam://forceinputappid/" + std::to_string(forced ? appId : 0), bounded); + if (delivered != Handoff::None) { + state.lastDelivery = Clock::now().time_since_epoch().count(); + if (forced) { + RT_LOG(RT_TAG_RUNTIME) << "Steam Input: applying the controller layout of app " << appId << std::endl; + } else { + RT_LOG(RT_TAG_RUNTIME) << "Steam Input: restored the desktop controller layout" << std::endl; + } + } + return delivered; +} + +void Worker(State& state) { + t_isWorker = true; + std::unique_lock lock(state.mutex); + for (;;) { + state.wake.wait(lock, [&] { return state.stopping || state.desired != state.sent; }); + if (state.stopping) { + return; + } + const bool target = state.desired; + const uint32_t appId = state.appId; + // A failed request is retried on the next focus change rather than in a loop. + state.sent = target; + lock.unlock(); + { + std::lock_guard delivery(state.delivery); + { + // Release may have run while this thread waited for the delivery lock. + std::lock_guard guard(state.mutex); + if (state.stopping) { + return; + } + } + const Handoff delivered = Deliver(state, target, appId, false); + std::lock_guard guard(state.mutex); + state.maybeForced = target ? delivered != Handoff::None || state.maybeForced + : state.maybeForced && delivered != Handoff::Confirmed; + } + lock.lock(); + } +} + +} // namespace + +void Initialize(bool enabled, uint32_t appId) { + if (!enabled || appId == 0 || g_state.load() != nullptr) { + return; + } + if (LaunchedBySteam()) { + RT_LOG(RT_TAG_RUNTIME) << "Steam Input: launched by Steam, leaving its controller layout alone" << std::endl; + return; + } + SDL_Window* window = aurora_get_window(); + if (window == nullptr) { + return; + } + auto* state = new State(); + state->enabled = true; + state->appId = appId; + state->windowId = SDL_GetWindowID(window); + state->desired = (SDL_GetWindowFlags(window) & SDL_WINDOW_INPUT_FOCUS) != 0; + try { + std::thread(Worker, std::ref(*state)).detach(); + } catch (const std::exception& error) { + RT_LOG(RT_TAG_RUNTIME) << "Steam Input: " << error.what() << std::endl; + delete state; + return; + } + g_state.store(state); +} + +void HandleEvents(const AuroraEvent* events) { + State* state = g_state.load(); + if (state == nullptr || events == nullptr) { + return; + } + int focus = -1; + for (const AuroraEvent* event = events; event->type != AURORA_NONE; ++event) { + if (event->type != AURORA_SDL_EVENT) { + continue; + } + const SDL_Event& sdl = event->sdl; + const bool gained = sdl.type == SDL_EVENT_WINDOW_FOCUS_GAINED; + if ((gained || sdl.type == SDL_EVENT_WINDOW_FOCUS_LOST) && sdl.window.windowID == state->windowId) { + focus = gained ? 1 : 0; + } + } + if (focus < 0) { + return; + } + std::lock_guard lock(state->mutex); + if (state->enabled) { + state->desired = focus == 1; + state->wake.notify_one(); + } +} + +void Release() noexcept { + State* state = g_state.load(); + if (state == nullptr) { + return; + } + const auto releaseWait = kHandoffTimeout + kDeliverySpacing + std::chrono::milliseconds(500); + try { + { + std::unique_lock lock(state->mutex, std::defer_lock); + if (!TryLockBriefly(lock)) { + return; + } + if (!state->enabled) { + lock.unlock(); + // Another thread is restoring; exiting under it would kill its request. + const auto deadline = Clock::now() + 2 * releaseWait; + while (!t_inRelease && !state->released.load() && Clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return; + } + t_inRelease = true; + state->enabled = false; + state->stopping = true; + state->wake.notify_one(); + } + // Let an in-flight request land first so the restore is the last one Steam sees. + std::unique_lock delivery(state->delivery, std::defer_lock); + const bool ordered = !t_isWorker && delivery.try_lock_for(releaseWait); + bool maybeForced = true; + if (ordered) { + std::unique_lock lock(state->mutex, std::defer_lock); + maybeForced = !TryLockBriefly(lock) || state->maybeForced; + } + // A request still in flight, or about to start, could otherwise land after the restore. + CancelWorkerRequests(); + if (maybeForced) { + // Discovery and spawning can block as well; they must not hold up the exit. + auto done = std::make_shared>(false); + try { + std::thread([state, done] { + Deliver(*state, false, 0, true); + done->store(true); + }).detach(); + } catch (const std::system_error&) { + done->store(true); + } + const auto deadline = Clock::now() + releaseWait; + while (!done->load() && Clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + } + } catch (...) { + } + state->released.store(true); +} + +} // namespace SteamInputLayout diff --git a/runtime/tests/runtime_config_tests.cpp b/runtime/tests/runtime_config_tests.cpp index 644cec924..66b2a3e73 100644 --- a/runtime/tests/runtime_config_tests.cpp +++ b/runtime/tests/runtime_config_tests.cpp @@ -78,6 +78,18 @@ int main() { if (!Require(!config.motionTrick && !config.motionTrickSensitivity, "out of range shake to trick sensitivity was accepted")) return 1; } + { + std::istringstream input("[controller]\nsteam_input_override = false\nsteam_input_app_id = 1234\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "steam.toml"); + if (!Require(config.steamInputOverride == std::optional{false} && + config.steamInputAppId == std::optional{1234}, + "Steam Input settings were not loaded")) return 1; + } + { + std::istringstream input("[controller]\nsteam_input_app_id = 0\n"); + const RuntimeUserConfig config = RuntimeConfigFile::ParseConfig(input, "steam-zero.toml"); + if (!Require(!config.steamInputAppId, "zero Steam Input app id was accepted")) return 1; + } if (!SetsFps("[video]\nshow_fps = false\n", "bare key was not replaced")) return 1; if (!SetsFps("[video]\n\"show_fps\" = false\n", "basic-quoted key was not replaced")) return 1; if (!SetsFps("[video]\n'show_fps'=false\n", "literal-quoted key was not replaced")) return 1;