From 80cc5e72bde185cd8d2cc08cc3642172d0d93d63 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 21 Jun 2026 01:14:05 +0200 Subject: [PATCH] Add window-based target picker as the default; process list goes advanced New host/src/inject/window_list.{hpp,cpp} enumerates visible, titled, non-tool top-level (alt-tab-style) windows via EnumWindows -- root-owner only, our own process excluded -- and maps each to its owning pid + image name. The Injection panel now defaults to this window list (each row "title [process.exe pid]", with a filter over title or process), since there are far fewer windows than processes and a window maps straight to the HWND the capturer wants. The full process list stays as the advanced picker under Debug details. Verified live: launched the host and captured its window -- the picker lists real windows (Discord/Firefox/Explorer/...) in "title [exe pid]" form, filter present, and the host's own window correctly excluded. x64 build green. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 ---- host/CMakeLists.txt | 1 + host/src/inject/window_list.cpp | 82 +++++++++++++++++++++++++++++++++ host/src/inject/window_list.hpp | 24 ++++++++++ host/src/injection_panel.cpp | 59 +++++++++++++++++++----- host/src/injection_panel.hpp | 6 ++- 6 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 host/src/inject/window_list.cpp create mode 100644 host/src/inject/window_list.hpp diff --git a/README.md b/README.md index 6d640e2..78d1af8 100644 --- a/README.md +++ b/README.md @@ -75,16 +75,6 @@ is removed from this list once done — so the top item is always next. The self-verifiable tooling / UI / input items come first; the game-pipeline items that need a real game (and Remote Play) to fully validate come last. -- **Select targets by window, not just process.** A flat process list is fine as an - advanced/debug view, but the default should be a **window list** — there are far - fewer top-level windows than processes, and a window directly yields the HWND the - WGC capturer and focus spoof already want. *How:* add a window enumerator - (`EnumWindows`, keeping visible, titled, non-tool top-level windows — - `IsWindowVisible`, `GetWindowTextLength > 0`, exclude `WS_EX_TOOLWINDOW` and our own - HWND, resolve to the root owner) and map each via `GetWindowThreadProcessId` → pid → - image name. Show **title + process name + pid** with a filter box like the process - list; injecting by window injects into its pid and hands the HWND straight to - capture. Keep the process list behind "Debug details" as the advanced path. - **Auto-size and lay out the overlay windows so none need manual resizing.** Panels currently `Begin` at default cascade positions, so they overlap and clip. Give each `ImGuiWindowFlags_AlwaysAutoResize` and an initial position computed from diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 64fecf0..e4a8017 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(coop_host WIN32 src/ui/app_chrome.cpp src/input/xinput_source.cpp src/inject/process_list.cpp + src/inject/window_list.cpp src/inject/injector.cpp src/ipc/ipc_server.cpp src/capture/frame_renderer.cpp diff --git a/host/src/inject/window_list.cpp b/host/src/inject/window_list.cpp new file mode 100644 index 0000000..304207f --- /dev/null +++ b/host/src/inject/window_list.cpp @@ -0,0 +1,82 @@ +#include "inject/window_list.hpp" + +#include +#include + +#include + +#include "inject/process_list.hpp" + +namespace coop +{ + +namespace +{ + +struct EnumCtx +{ + std::vector* out; + const std::unordered_map* names; + DWORD self_pid; +}; + +BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lparam) +{ + auto* ctx = reinterpret_cast(lparam); + + // Keep only "alt-tab" windows: visible, titled, root-owner, non-tool, not ours. + if (!IsWindowVisible(hwnd) || GetAncestor(hwnd, GA_ROOTOWNER) != hwnd) + { + return TRUE; + } + const int len = GetWindowTextLengthW(hwnd); + if (len <= 0) + { + return TRUE; + } + if ((GetWindowLongW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) + { + return TRUE; + } + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid == 0 || pid == ctx->self_pid) + { + return TRUE; + } + + std::wstring title(static_cast(len), L'\0'); + GetWindowTextW(hwnd, title.data(), len + 1); + + std::wstring exe; + if (const auto it = ctx->names->find(pid); it != ctx->names->end()) + { + exe = it->second; + } + ctx->out->push_back(WindowEntry{pid, hwnd, std::move(title), std::move(exe)}); + return TRUE; +} + +} // namespace + +std::vector list_windows() +{ + // pid -> image name, so each window can show its owning process without a separate + // OpenProcess per window. + std::unordered_map names; + for (const ProcessEntry& p : list_processes()) + { + names.emplace(p.pid, p.exe_name); + } + + std::vector out; + EnumCtx ctx{&out, &names, GetCurrentProcessId()}; + EnumWindows(&enum_proc, reinterpret_cast(&ctx)); + + std::sort(out.begin(), out.end(), [](const WindowEntry& a, const WindowEntry& b) { + return _wcsicmp(a.title.c_str(), b.title.c_str()) < 0; + }); + return out; +} + +} // namespace coop diff --git a/host/src/inject/window_list.hpp b/host/src/inject/window_list.hpp new file mode 100644 index 0000000..23eda13 --- /dev/null +++ b/host/src/inject/window_list.hpp @@ -0,0 +1,24 @@ +// Enumerates visible top-level windows for the injection target picker. There are +// far fewer windows than processes, and a window maps directly to the HWND the +// capturer wants, so this is the default (friendlier) way to pick a game. +#pragma once + +#include +#include + +namespace coop +{ + +struct WindowEntry +{ + unsigned long pid = 0; // owning process id + void* hwnd = nullptr; // HWND (opaque here to keep windows.h out of the header) + std::wstring title; // window caption + std::wstring exe_name; // owning process image base name, e.g. "game.exe" +}; + +// Snapshot of the visible, titled, non-tool top-level (alt-tab-style) windows, with +// our own process's windows excluded. Sorted by title (case-insensitive). +std::vector list_windows(); + +} // namespace coop diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index 4533ad5..d869a52 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -84,7 +84,13 @@ std::wstring hook_dll_path() InjectionPanel::InjectionPanel() { - refresh_processes(); + refresh_targets(); +} + +void InjectionPanel::refresh_targets() +{ + windows_ = list_windows(); + processes_ = list_processes(); } InjectionPanel::~InjectionPanel() @@ -507,35 +513,64 @@ void InjectionPanel::draw(bool debug_details) ImGui::Separator(); } - ImGui::TextUnformatted("Target process"); + ImGui::TextUnformatted("Target window"); if (ImGui::Button("Refresh")) { - refresh_processes(); + refresh_targets(); } ImGui::SameLine(); ImGui::SetNextItemWidth(-1.0f); - ImGui::InputTextWithHint("##filter", "filter by name...", filter_, sizeof(filter_)); + ImGui::InputTextWithHint("##wfilter", "filter by title or process...", window_filter_, sizeof(window_filter_)); - if (ImGui::BeginListBox("##processes", ImVec2(-1.0f, 200.0f))) + if (ImGui::BeginListBox("##windows", ImVec2(-1.0f, 180.0f))) { - for (const ProcessEntry& entry : processes_) + for (const WindowEntry& w : windows_) { - if (!contains_ci(entry.exe_name, filter_)) + if (!contains_ci(w.title, window_filter_) && !contains_ci(w.exe_name, window_filter_)) { continue; } - const bool selected = entry.pid == selected_pid_; - char label[300]; - snprintf(label, sizeof(label), "%-40s %lu", narrow(entry.exe_name).c_str(), entry.pid); + const bool selected = w.pid == selected_pid_; + char label[400]; + snprintf(label, sizeof(label), "%-32s [%s %lu]", narrow(w.title).c_str(), + narrow(w.exe_name).c_str(), w.pid); if (ImGui::Selectable(label, selected)) { - selected_pid_ = entry.pid; - selected_name_ = entry.exe_name; + selected_pid_ = w.pid; + selected_name_ = w.exe_name; } } ImGui::EndListBox(); } + // The full process list is the advanced fallback (e.g. a windowless game host), + // kept out of the way unless the operator wants it. + if (debug_details) + { + ImGui::SeparatorText("All processes (advanced)"); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##filter", "filter by name...", filter_, sizeof(filter_)); + if (ImGui::BeginListBox("##processes", ImVec2(-1.0f, 160.0f))) + { + for (const ProcessEntry& entry : processes_) + { + if (!contains_ci(entry.exe_name, filter_)) + { + continue; + } + const bool selected = entry.pid == selected_pid_; + char label[300]; + snprintf(label, sizeof(label), "%-40s %lu", narrow(entry.exe_name).c_str(), entry.pid); + if (ImGui::Selectable(label, selected)) + { + selected_pid_ = entry.pid; + selected_name_ = entry.exe_name; + } + } + ImGui::EndListBox(); + } + } + const bool can_inject = selected_pid_ != 0; ImGui::BeginDisabled(!can_inject); if (ImGui::Button("Inject & Connect", ImVec2(-1.0f, 0.0f))) diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index d154b1c..7bff396 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -11,6 +11,7 @@ #include "coop/protocol.hpp" #include "imgui.h" #include "inject/process_list.hpp" +#include "inject/window_list.hpp" #include "ipc/ipc_server.hpp" namespace coop @@ -106,6 +107,7 @@ public: } private: + void refresh_targets(); // refresh both the window list and the process list void refresh_processes(); void inject_selected(); void reattach(); // re-inject a relaunched same-name target (Terminated state) @@ -115,7 +117,9 @@ private: void draw_hook_list(const HookStatusView& status); void draw_hook_status(bool debug_details); - std::vector processes_; + std::vector windows_; // default picker (visible top-level windows) + std::vector processes_; // advanced picker (all processes) + char window_filter_[128] = {}; char filter_[128] = {}; unsigned long selected_pid_ = 0; std::wstring selected_name_;