Files
90CNServer/client-patch/90CN_web_redirect.cpp
T
hanfuit 7f41f9797a GM 控制台跟随游戏窗口:窗口模式下按游戏客户区收小并居中
- 新增 FindGameWindow/FitInsideGame:取本进程最大可见顶层窗口的客户区作为游戏画面
- 开窗口时按 min(配置尺寸, 客户区-2倍边距) 定尺寸并居中;游戏画面小于配置时自动缩
- 新增 GmWindowFollowGame:切全屏/改分辨率/挪游戏窗口时重新摆放,玩家手动拖过的窗口不动
- 服务端指纹回绑 186A2C95… (1260032)
2026-11-05 13:29:05 +08:00

793 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 90CN_web_redirect.cpp -- 把小井盖(小助手)变成我们自己的 GM 窗口
//
// 背景:DNF 客户端里那个「小井盖 / 小助手」窗口是 IE 内核(窗口类链为
// Browser Example -> Shell DocObject View -> Internet Explorer_Server)。
// 往这个控件里塞我们的页面可以显示,但中文输入法候选框死活不出来
// (MSHTML 的 TSF 通道在这个进程里接不上),所以不再折腾它。
//
// 两种模式(90CN_web.ini 的 external 开关):
// external=1(默认) 点小井盖 → 游戏面板立刻隐藏 → 弹出我们自己的独立窗口
// (Edge/Chrome 的 app 模式,无地址栏,标题栏是我们的
// 页面标题)。输入法由系统正常处理,中文没问题。
// 关掉独立窗口后把游戏面板放回去,游戏状态保持原样。
// external=0 老行为:仍用内嵌 IE,等控件创建出来再把文档导航到
// location.replace(我们的页面)。
//
// 配置:DLL 同目录的 90CN_web.ini,没有配置文件时用内置默认值。
//
// 失败不影响游戏:找不到浏览器时自动退回内嵌 IE 模式,任何一步取不到对象
// 都只写日志,不会终止进程。
// ============================================================================
#include <windows.h>
#include <imm.h>
#include <ole2.h>
#include <oleacc.h>
#include <mshtml.h>
#include <string>
// 90CN.cpp 里的 LogLine 在匿名 namespace 内(内部链接),所以走它导出的
// C 链接转发口,保证本模块的日志和安装流程写进同一个 90CN_trace.log。
extern "C" void __cdecl CnmLogLine(const char* format, ...);
#define LogLine CnmLogLine
// 自绘窗口模块(90CN_gm_window.cpp)。WebView2 需要由同一个线程持续泵消息,
// 所以这里只做“开/关/查询”,消息泵由下面的 WorkerThread 统一喂。
extern "C" bool GmWindowShow(const wchar_t* url, int width, int height);
extern "C" void GmWindowHide();
extern "C" bool GmWindowIsVisible();
extern "C" bool GmWindowFailed();
extern "C" void GmPumpMessages(unsigned timeoutMs);
extern "C" void GmWindowFollowGame();
extern "C" void GmWindowAssertTop();
namespace {
const wchar_t kIniName[] = L"90CN_web.ini";
const wchar_t kIeServerClass[] = L"Internet Explorer_Server";
// 我们自己的窗口标题,必须和 gmweb/index.html 的 <title> 完全一致。
// 用它来判断窗口是否还开着,比跟踪进程句柄可靠(Chromium 会转手进程)。
const wchar_t kOwnWindowTitle[] = L"GM 控制台";
struct WebConfig {
bool enabled;
bool force;
bool external; // true = 用我们自己的窗口,false = 老的内嵌 IE 跳转
bool suppress; // true = 我们的窗口开着时隐藏游戏内嵌面板
bool closePanel; // true = 关闭我们的窗口时直接把面板关掉,false = 放回去
bool minimizeGame; // true = 全屏游戏时先把游戏最小化,否则窗口会被盖住
int delayMs;
int width;
int height;
std::wstring url;
std::wstring browser; // 空 = 自动查找 Edge/Chrome
};
WebConfig g_cfg = { true, false, true, true, false, false, 1200, 1040, 720,
L"http://127.0.0.1:18112/", std::wstring() };
DWORD g_threadId = 0;
volatile LONG g_stop = 0;
std::wstring WideFromAnsi(const char* s)
{
if (!s || !*s) return std::wstring();
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
if (need <= 1) {
need = MultiByteToWideChar(CP_ACP, 0, s, -1, nullptr, 0);
if (need <= 1) return std::wstring();
std::wstring out(static_cast<size_t>(need - 1), L'\0');
MultiByteToWideChar(CP_ACP, 0, s, -1, &out[0], need);
return out;
}
std::wstring out(static_cast<size_t>(need - 1), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
return out;
}
std::wstring ModuleDirectory()
{
wchar_t path[MAX_PATH] = {};
HMODULE self = nullptr;
// 用数据地址反查自身模块:函数地址在某些加载方式下解析不到。
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(&g_cfg), &self) ||
!self) {
return std::wstring();
}
if (!GetModuleFileNameW(self, path, MAX_PATH)) return std::wstring();
std::wstring full(path);
const size_t slash = full.find_last_of(L"\\/");
return slash == std::wstring::npos ? std::wstring() : full.substr(0, slash + 1);
}
void LoadConfig()
{
const std::wstring dir = ModuleDirectory();
if (dir.empty()) {
LogLine("web redirect: module dir unresolved, using built-in defaults");
return;
}
const std::wstring ini = dir + kIniName;
if (GetFileAttributesW(ini.c_str()) == INVALID_FILE_ATTRIBUTES) {
LogLine("web redirect: %ls not found, defaults enabled=1 url=%ls", ini.c_str(), g_cfg.url.c_str());
return;
}
const UINT enabled = GetPrivateProfileIntW(L"web", L"enabled", 1, ini.c_str());
const UINT force = GetPrivateProfileIntW(L"web", L"force", 0, ini.c_str());
const UINT delay = GetPrivateProfileIntW(L"web", L"delay", 1200, ini.c_str());
const UINT external = GetPrivateProfileIntW(L"web", L"external", 1, ini.c_str());
const UINT suppress = GetPrivateProfileIntW(L"web", L"suppress", 1, ini.c_str());
const UINT closeAction = GetPrivateProfileIntW(L"web", L"close_action", 0, ini.c_str());
const UINT minimizeGame = GetPrivateProfileIntW(L"web", L"minimize_game", 0, ini.c_str());
const UINT width = GetPrivateProfileIntW(L"web", L"width", 1040, ini.c_str());
const UINT height = GetPrivateProfileIntW(L"web", L"height", 720, ini.c_str());
wchar_t buf[1024] = {};
GetPrivateProfileStringW(L"web", L"url", L"http://127.0.0.1:18112/", buf,
static_cast<DWORD>(sizeof(buf) / sizeof(buf[0])), ini.c_str());
wchar_t browser[1024] = {};
GetPrivateProfileStringW(L"web", L"browser", L"", browser,
static_cast<DWORD>(sizeof(browser) / sizeof(browser[0])), ini.c_str());
g_cfg.enabled = enabled != 0;
g_cfg.force = force != 0;
g_cfg.external = external != 0;
g_cfg.suppress = suppress != 0;
g_cfg.closePanel = closeAction != 0;
g_cfg.minimizeGame = minimizeGame != 0;
g_cfg.delayMs = delay > 200 ? static_cast<int>(delay) : 200;
g_cfg.width = width >= 640 ? static_cast<int>(width) : 1040;
g_cfg.height = height >= 480 ? static_cast<int>(height) : 720;
if (buf[0]) g_cfg.url = buf;
if (browser[0]) g_cfg.browser = browser;
LogLine("web redirect: config enabled=%d external=%d suppress=%d force=%d delay=%d size=%dx%d url=%ls",
g_cfg.enabled ? 1 : 0, g_cfg.external ? 1 : 0, g_cfg.suppress ? 1 : 0,
g_cfg.force ? 1 : 0, g_cfg.delayMs, g_cfg.width, g_cfg.height, g_cfg.url.c_str());
}
bool IsLocalUrl(const std::wstring& url)
{
// 只认 "http://127.0.0.1:<port>/" 前缀,用来判断当前文档是否已经在我们的页面上。
if (g_cfg.url.empty() || url.size() < g_cfg.url.size()) return false;
return _wcsnicmp(url.c_str(), g_cfg.url.c_str(), g_cfg.url.size()) == 0;
}
struct FindContext {
DWORD pid;
HWND found;
};
BOOL CALLBACK OnChildWindow(HWND hwnd, LPARAM param)
{
FindContext* ctx = reinterpret_cast<FindContext*>(param);
wchar_t cls[128] = {};
if (GetClassNameW(hwnd, cls, 128) && _wcsicmp(cls, kIeServerClass) == 0) {
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid == ctx->pid) {
ctx->found = hwnd;
return FALSE;
}
}
return TRUE;
}
BOOL CALLBACK OnTopWindow(HWND hwnd, LPARAM param)
{
FindContext* ctx = reinterpret_cast<FindContext*>(param);
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid != ctx->pid) return TRUE;
EnumChildWindows(hwnd, OnChildWindow, param);
return ctx->found ? FALSE : TRUE;
}
HWND FindIeServerWindow()
{
FindContext ctx = { GetCurrentProcessId(), nullptr };
EnumWindows(OnTopWindow, reinterpret_cast<LPARAM>(&ctx));
return ctx.found;
}
// 小井盖面板的顶层窗口。类名是 "Browser Example",但不同版本可能换壳,
// 所以退一步:只要能包含 Internet Explorer_Server 子窗口,也算。
BOOL CALLBACK OnPanelWindow(HWND hwnd, LPARAM param)
{
FindContext* ctx = reinterpret_cast<FindContext*>(param);
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid != ctx->pid) return TRUE;
wchar_t cls[128] = {};
if (GetClassNameW(hwnd, cls, 128) && _wcsicmp(cls, L"Browser Example") == 0) {
ctx->found = hwnd;
return FALSE;
}
FindContext sub = { ctx->pid, nullptr };
EnumChildWindows(hwnd, OnChildWindow, reinterpret_cast<LPARAM>(&sub));
if (sub.found) {
ctx->found = hwnd;
return FALSE;
}
return TRUE;
}
HWND FindWebPanelWindow()
{
FindContext ctx = { GetCurrentProcessId(), nullptr };
EnumWindows(OnPanelWindow, reinterpret_cast<LPARAM>(&ctx));
return ctx.found;
}
// 游戏主窗口:本进程里「可见 + 基本铺满整个显示器」的那个顶层窗口。
// 用来判断是不是独占全屏 —— 那种情况下普通窗口会被游戏画面压住,看不见。
BOOL CALLBACK OnGameWindow(HWND hwnd, LPARAM param)
{
FindContext* ctx = reinterpret_cast<FindContext*>(param);
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid != ctx->pid) return TRUE;
if (!IsWindowVisible(hwnd)) return TRUE;
if (GetWindow(hwnd, GW_OWNER)) return TRUE;
wchar_t cls[128] = {};
if (GetClassNameW(hwnd, cls, 128)) {
if (_wcsicmp(cls, L"90CN_GMHostWindow") == 0) return TRUE;
if (_wcsicmp(cls, L"Browser Example") == 0) return TRUE;
}
RECT rc = {};
if (!GetWindowRect(hwnd, &rc)) return TRUE;
MONITORINFO mi = {};
mi.cbSize = sizeof(mi);
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &mi)) return TRUE;
const int mw = mi.rcMonitor.right - mi.rcMonitor.left;
const int mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
const int w = rc.right - rc.left;
const int h = rc.bottom - rc.top;
if (mw <= 0 || mh <= 0) return TRUE;
if (w * 10 >= mw * 9 && h * 10 >= mh * 9) {
ctx->found = hwnd;
return FALSE;
}
return TRUE;
}
HWND FindGameWindow()
{
FindContext ctx = { GetCurrentProcessId(), nullptr };
EnumWindows(OnGameWindow, reinterpret_cast<LPARAM>(&ctx));
return ctx.found;
}
// 独占全屏时普通窗口被压在游戏画面下面,先把游戏最小化,控制台才真的看得见。
// 关掉控制台再还原,游戏回到原来的全屏状态。
HWND g_minimizedGame = nullptr;
void MakeRoomForConsole()
{
if (!g_cfg.minimizeGame) return;
HWND game = FindGameWindow();
if (!game) return;
if (g_minimizedGame == game) return;
ShowWindow(game, SW_MINIMIZE);
g_minimizedGame = game;
LogLine("web window: game minimized for console hwnd=%p (fullscreen)", (void*)game);
}
void RestoreGameFromConsole()
{
if (!g_minimizedGame) return;
if (IsWindow(g_minimizedGame)) {
ShowWindow(g_minimizedGame, SW_RESTORE);
SetForegroundWindow(g_minimizedGame);
LogLine("web window: game restored hwnd=%p", (void*)g_minimizedGame);
}
g_minimizedGame = nullptr;
}
// 我们自己的窗口:Chromium 的 app 窗口类名固定是 Chrome_WidgetWin_1,
// 靠「类名 + 标题」双条件识别,避免和游戏内嵌面板(标题同为页面标题)撞车。
BOOL CALLBACK OnOwnWindow(HWND hwnd, LPARAM param)
{
HWND* out = reinterpret_cast<HWND*>(param);
if (!IsWindowVisible(hwnd)) return TRUE;
wchar_t cls[128] = {};
if (!GetClassNameW(hwnd, cls, 128)) return TRUE;
if (_wcsnicmp(cls, L"Chrome_WidgetWin", 16) != 0) return TRUE;
wchar_t title[256] = {};
if (!GetWindowTextW(hwnd, title, 256)) return TRUE;
if (_wcsicmp(title, kOwnWindowTitle) != 0) return TRUE;
*out = hwnd;
return FALSE;
}
HWND FindOwnWindow()
{
HWND found = nullptr;
EnumWindows(OnOwnWindow, reinterpret_cast<LPARAM>(&found));
return found;
}
std::wstring EnvValue(const wchar_t* name)
{
wchar_t buf[MAX_PATH] = {};
const DWORD got = GetEnvironmentVariableW(name, buf, MAX_PATH);
return got ? std::wstring(buf, got) : std::wstring();
}
bool FileExists(const std::wstring& path)
{
return !path.empty() && GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES;
}
// 优先用 Edge(Win10/11 自带),没有再找 Chrome。
std::wstring FindBrowser()
{
if (!g_cfg.browser.empty() && FileExists(g_cfg.browser)) return g_cfg.browser;
const std::wstring pf = EnvValue(L"ProgramFiles");
const std::wstring pf86 = EnvValue(L"ProgramFiles(x86)");
const std::wstring lad = EnvValue(L"LOCALAPPDATA");
const std::wstring candidates[] = {
pf86 + L"\\Microsoft\\Edge\\Application\\msedge.exe",
pf + L"\\Microsoft\\Edge\\Application\\msedge.exe",
pf86 + L"\\Google\\Chrome\\Application\\chrome.exe",
pf + L"\\Google\\Chrome\\Application\\chrome.exe",
lad + L"\\Google\\Chrome\\Application\\chrome.exe",
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
if (FileExists(candidates[i])) return candidates[i];
}
return std::wstring();
}
// 给独立窗口单独开一个浏览器配置目录,避免和玩家日常浏览器的进程/配置打架:
// 只有独立目录,才能真正意义上“关窗口 = 进程退出”,也才不会占用他的登录态。
std::wstring WindowProfileDir()
{
const std::wstring base = EnvValue(L"LOCALAPPDATA");
if (base.empty()) return std::wstring();
const std::wstring root = base + L"\\90CN";
CreateDirectoryW(root.c_str(), nullptr);
const std::wstring dir = root + L"\\gmwin";
CreateDirectoryW(dir.c_str(), nullptr);
return dir;
}
bool LaunchOwnWindow()
{
const std::wstring browser = FindBrowser();
if (browser.empty()) {
LogLine("web window: no Edge/Chrome found");
return false;
}
const std::wstring profile = WindowProfileDir();
if (profile.empty()) {
LogLine("web window: LOCALAPPDATA missing, cannot create profile dir");
return false;
}
wchar_t sizeArg[64] = {};
wsprintfW(sizeArg, L"--window-size=%d,%d", g_cfg.width, g_cfg.height);
std::wstring cmd = L"\"";
cmd += browser;
cmd += L"\" --app=";
cmd += g_cfg.url;
cmd += L" ";
cmd += sizeArg;
cmd += L" --user-data-dir=\"";
cmd += profile;
cmd += L"\" --no-first-run --no-default-browser-check --disable-sync"
L" --disable-background-mode --lang=zh-CN";
STARTUPINFOW si = {};
si.cb = sizeof(si);
PROCESS_INFORMATION pi = {};
std::wstring dir = ModuleDirectory();
const BOOL ok = CreateProcessW(nullptr, &cmd[0], nullptr, nullptr, FALSE,
CREATE_UNICODE_ENVIRONMENT, nullptr, dir.empty() ? nullptr : dir.c_str(), &si, &pi);
if (!ok) {
LogLine("web window: CreateProcess failed err=%u browser=%ls", GetLastError(), browser.c_str());
return false;
}
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
LogLine("web window: launched pid=%u profile=%ls", pi.dwProcessId, profile.c_str());
return true;
}
// ---------------------------------------------------------------- 输入法
//
// 症状:页面里的输入框只能打字母,输入法候选框不出现。
// 原因:IME 上下文是窗口级的,DNF 主窗口把它握在自己手里,内嵌 IE 控件
// 这条窗口链上没有可用的上下文,输入法转换根本不会发生。
// 做法分三步,缺一不可:
// 1. AttachThreadInput 到控件所属线程 —— 跨线程直接改别人的 IME 关联
// 会“返回成功但实际不生效”,这是第一次改完日志打 ok 却仍无候选框的原因;
// 2. 取窗口当前上下文,没有就 ImmCreateContext 建一个挂上去;
// 3. 显式 ImmSetOpenStatus(TRUE) + ImmSetConversionStatus(NATIVE),
// 把上下文推到“中文打开”状态,否则候选框还是不出来。
struct ImeProbe {
DWORD tid;
BOOL attached;
HIMC existing;
BOOL open;
DWORD conversion;
DWORD sentence;
HKL layout;
wchar_t layoutName[KL_NAMELENGTH];
};
void ProbeIme(HWND hwnd, const wchar_t* tag, bool verbose = true)
{
if (!hwnd) return;
const DWORD self = GetCurrentThreadId();
ImeProbe probe = {};
probe.tid = GetWindowThreadProcessId(hwnd, nullptr);
if (probe.tid && probe.tid != self) {
probe.attached = AttachThreadInput(self, probe.tid, TRUE);
}
probe.existing = ImmGetContext(hwnd);
if (probe.existing) {
probe.open = ImmGetOpenStatus(probe.existing);
ImmGetConversionStatus(probe.existing, &probe.conversion, &probe.sentence);
ImmReleaseContext(hwnd, probe.existing);
}
probe.layout = probe.tid ? GetKeyboardLayout(probe.tid) : nullptr;
GetKeyboardLayoutNameW(probe.layoutName);
if (verbose) {
LogLine("web ime: probe %ls hwnd=%p tid=%u attach=%d ctx=%p open=%d conv=0x%X sent=0x%X hkl=0x%X kl=%ls",
tag, (void*)hwnd, probe.tid, probe.attached ? 1 : 0, (void*)probe.existing,
probe.open ? 1 : 0, probe.conversion, probe.sentence,
(unsigned)(UINT_PTR)probe.layout, probe.layoutName[0] ? probe.layoutName : L"-");
}
if (verbose) {
// 只在首次接管时把输入法推到中文打开状态。之后不再干预,
// 否则玩家自己切到英文会被我们每 700ms 强行掰回来。
if (probe.existing) {
HIMC ctx = ImmGetContext(hwnd);
if (ctx) {
ImmSetOpenStatus(ctx, TRUE);
DWORD conversion = 0, sentence = 0;
ImmGetConversionStatus(ctx, &conversion, &sentence);
ImmSetConversionStatus(ctx, conversion | IME_CMODE_NATIVE, sentence);
ImmReleaseContext(hwnd, ctx);
LogLine("web ime: opened existing context hwnd=%p", (void*)hwnd);
}
} else {
HIMC created = ImmCreateContext();
if (!created) {
// ImmCreateContext 失败基本等于本线程被 ImmDisableIME 掉了。
LogLine("web ime: ImmCreateContext failed hwnd=%p err=%u (thread IME disabled?)",
(void*)hwnd, GetLastError());
} else if (ImmAssociateContext(hwnd, created)) {
ImmSetOpenStatus(created, TRUE);
ImmSetConversionStatus(created, IME_CMODE_NATIVE, IME_SMODE_NONE);
LogLine("web ime: created+associated context hwnd=%p ctx=%p", (void*)hwnd, (void*)created);
} else {
ImmDestroyContext(created);
LogLine("web ime: ImmAssociateContext failed hwnd=%p err=%u", (void*)hwnd, GetLastError());
}
}
}
if (probe.attached) {
AttachThreadInput(self, probe.tid, FALSE);
}
}
bool EnsureImeChain(HWND hwnd, bool verbose)
{
if (!hwnd) return false;
// 只给 IE_Server 挂还不够:焦点落在父窗口(Browser Example)时,
// 输入法又会被游戏主窗口抢回去,所以父链一起处理。
HWND chain[3] = { hwnd, GetParent(hwnd), nullptr };
if (chain[1]) chain[2] = GetParent(chain[1]);
bool any = false;
for (int i = 0; i < 3; i++) {
if (!chain[i]) continue;
ProbeIme(chain[i], i == 0 ? L"ie" : (i == 1 ? L"doc" : L"host"), verbose);
any = true;
}
return any;
}
// 取文档对象。WM_HTML_GETOBJECT 的返回值经 ObjectFromLresult 还原成接口指针。
IHTMLDocument2* GetDocument(HWND hwnd)
{
static const UINT msg = RegisterWindowMessageW(L"WM_HTML_GETOBJECT");
if (!msg) return nullptr;
DWORD_PTR result = 0;
const LRESULT ok = SendMessageTimeoutW(hwnd, msg, 0, 0,
SMTO_ABORTIFHUNG | SMTO_BLOCK, 4000, &result);
if (!ok || !result) return nullptr;
IHTMLDocument2* doc = nullptr;
if (FAILED(ObjectFromLresult(static_cast<LRESULT>(result), IID_IHTMLDocument2, 0,
reinterpret_cast<void**>(&doc)))) {
return nullptr;
}
return doc;
}
std::wstring DocumentUrl(IHTMLDocument2* doc)
{
BSTR url = nullptr;
std::wstring out;
if (doc && SUCCEEDED(doc->get_URL(&url)) && url) {
out.assign(url, SysStringLen(url));
}
if (url) SysFreeString(url);
return out;
}
bool NavigateDocument(IHTMLDocument2* doc)
{
IHTMLWindow2* win = nullptr;
if (FAILED(doc->get_parentWindow(&win)) || !win) return false;
// 用 location.replace 而不是 href:不往历史里塞记录,避免用户按返回键回到原页。
std::wstring script = L"location.replace('";
script += g_cfg.url;
script += L"')";
BSTR code = SysAllocString(script.c_str());
BSTR lang = SysAllocString(L"JavaScript");
VARIANT ret;
VariantInit(&ret);
HRESULT hr = win->execScript(code, lang, &ret);
VariantClear(&ret);
SysFreeString(code);
SysFreeString(lang);
win->Release();
return SUCCEEDED(hr);
}
// 老行为:把内嵌 IE 的文档导航到本地页面。
void InlineRedirectPass(HWND ieHwnd, HWND* lastHandled)
{
if (!ieHwnd) {
*lastHandled = nullptr;
return;
}
if (ieHwnd == *lastHandled) {
if (!g_cfg.force) return;
// force 模式下防止用户点原生标签页跳走。
IHTMLDocument2* doc = GetDocument(ieHwnd);
if (doc) {
if (!IsLocalUrl(DocumentUrl(doc))) {
if (NavigateDocument(doc)) LogLine("web redirect: pulled back to %ls", g_cfg.url.c_str());
}
doc->Release();
}
return;
}
LogLine("web redirect: IE_Server window found hwnd=%p, waiting %dms", (void*)ieHwnd, g_cfg.delayMs);
// 等原生导航把文档对象建好,太早拿会拿到 about:blank。
for (int waited = 0; waited < g_cfg.delayMs && !g_stop; waited += 100) {
Sleep(100);
}
IHTMLDocument2* doc = GetDocument(ieHwnd);
if (!doc) {
LogLine("web redirect: document object unavailable hwnd=%p", (void*)ieHwnd);
*lastHandled = ieHwnd;
return;
}
const std::wstring current = DocumentUrl(doc);
LogLine("web redirect: current url=%ls", current.c_str());
if (IsLocalUrl(current)) {
LogLine("web redirect: already on local page, skip");
} else if (NavigateDocument(doc)) {
LogLine("web redirect: navigated to %ls", g_cfg.url.c_str());
} else {
LogLine("web redirect: execScript failed hwnd=%p", (void*)ieHwnd);
}
doc->Release();
*lastHandled = ieHwnd; // 失败也不无限重试同一窗口
}
// 窗口方案。自绘窗口依赖 WebView2,缺运行时/初始化失败时依次退到
// Edge 应用窗口、内嵌 IE 跳转,任何环境都不至于没得用。
enum WindowMode {
MODE_GM_WINDOW = 0, // 自绘外壳 + WebView2(默认)
MODE_APP_WINDOW, // Edge/Chrome 应用窗口
MODE_INLINE, // 老的内嵌 IE 跳转
};
bool OwnWindowVisible(int mode)
{
if (mode == MODE_GM_WINDOW) return GmWindowIsVisible();
if (mode == MODE_APP_WINDOW) return FindOwnWindow() != nullptr;
return false;
}
DWORD WINAPI WorkerThread(void*)
{
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
HWND lastHandled = nullptr;
HWND imeHwnd = nullptr;
unsigned imeTick = 0;
bool ownShown = false;
int mode = g_cfg.external ? MODE_GM_WINDOW : MODE_INLINE;
DWORD lastPoll = 0;
DWORD lastLaunch = 0;
// 玩家关掉控制台之后的抑制期:这段时间里面板只要露头就继续藏、继续请它关闭,
// 免得游戏的关闭动画把旧页面又画出来(残留)。
DWORD suppressUntil = 0;
int closeAttempts = 0;
for (;;) {
if (InterlockedCompareExchange(&g_stop, 0, 0) != 0) break;
const DWORD now = GetTickCount();
const bool suppressing = suppressUntil && now < suppressUntil;
// WebView2 要靠消息泵跑起来,窗口开着时必须泵得勤,否则页面会顿。
GmPumpMessages((ownShown || suppressing) ? 40 : 120);
// 原面板绝不能出现在画面上,所以轮询要快;空闲时放宽一点省 CPU。
const DWORD interval = (ownShown || suppressing) ? 40 : 60;
if (now - lastPoll < interval) continue;
lastPoll = now;
const HWND panel = FindWebPanelWindow();
const bool panelVisible = panel != nullptr && IsWindowVisible(panel) != FALSE;
if (mode == MODE_INLINE) {
HWND ieHwnd = FindIeServerWindow();
if (ieHwnd) {
const bool first = (ieHwnd != imeHwnd);
if (first || (++imeTick % 8) == 0) {
EnsureImeChain(ieHwnd, first);
if (first) imeHwnd = ieHwnd;
}
} else {
imeHwnd = nullptr;
}
InlineRedirectPass(ieHwnd, &lastHandled);
if (!panel) lastHandled = nullptr;
continue;
}
// 控制台开着:原面板全程压制,一露头就藏,绝不让旧 IE 页面出现在画面上。
if (ownShown) {
if (!OwnWindowVisible(mode)) {
// 玩家把控制台关了:直接把原面板关掉并进入抑制期,不再放回游戏,
// 这样游戏自己的关闭动画不会把旧页面又画出来(残留)。
ownShown = false;
if (panel) {
if (panelVisible) ShowWindow(panel, SW_HIDE);
if (g_cfg.closePanel) {
PostMessageW(panel, WM_CLOSE, 0, 0);
suppressUntil = now + 2500;
closeAttempts = 1;
LogLine("web window: console closed, panel closed hwnd=%p", (void*)panel);
} else {
ShowWindow(panel, SW_SHOW);
LogLine("web window: console closed, panel restored hwnd=%p", (void*)panel);
}
}
RestoreGameFromConsole();
continue;
}
if (panelVisible) {
ShowWindow(panel, SW_HIDE);
LogLine("web window: panel suppressed hwnd=%p", (void*)panel);
}
// 全屏游戏会抢窗口栈,每轮确认一次置顶;同时跟着游戏窗口尺寸走
GmWindowFollowGame();
GmWindowAssertTop();
continue;
}
// 抑制期:面板只要还露着就继续藏、继续请它关闭,期间不重新弹控制台。
if (suppressUntil) {
if (!panel || now > suppressUntil) {
suppressUntil = 0;
closeAttempts = 0;
} else {
if (panelVisible) {
ShowWindow(panel, SW_HIDE);
if (g_cfg.closePanel && closeAttempts < 4) {
PostMessageW(panel, WM_CLOSE, 0, 0);
++closeAttempts;
}
LogLine("web window: panel re-hidden after close hwnd=%p attempts=%d",
(void*)panel, closeAttempts);
}
continue;
}
}
// 面板不存在,或者存在但被游戏自己藏着 —— 都不该弹控制台。
// 只有「玩家点了小井盖、面板真的显示出来」才算一次打开请求。
if (!panelVisible) continue;
if (mode == MODE_GM_WINDOW) {
if (GmWindowFailed()) {
LogLine("web window: webview unavailable, switching to app window");
mode = MODE_APP_WINDOW;
continue;
}
// 先藏面板再建窗口,避免旧页面在中间闪一下
ShowWindow(panel, SW_HIDE);
if (GmWindowShow(g_cfg.url.c_str(), g_cfg.width, g_cfg.height)) {
ownShown = true;
// 独占全屏时先让游戏让位,否则控制台会被游戏画面压住
MakeRoomForConsole();
LogLine("web window: gm window shown, panel suppressed hwnd=%p", (void*)panel);
} else {
LogLine("web window: gm window show failed hwnd=%p", (void*)panel);
}
continue;
}
// MODE_APP_WINDOW
if (now - lastLaunch > 3000) {
lastLaunch = now;
ShowWindow(panel, SW_HIDE);
if (LaunchOwnWindow()) {
ownShown = true;
ShowWindow(panel, SW_HIDE);
LogLine("web window: app window launched");
} else {
mode = MODE_INLINE;
LogLine("web window: app window failed, fallback to in-page redirect");
}
}
}
CoUninitialize();
return 0;
}
} // namespace
extern "C" void InstallWebRedirect()
{
LoadConfig();
if (!g_cfg.enabled) {
LogLine("web redirect: disabled by config");
return;
}
if (g_cfg.url.find(L'\'') != std::wstring::npos) {
LogLine("web redirect: url contains a quote, refused");
return;
}
InterlockedExchange(&g_stop, 0);
HANDLE thread = CreateThread(nullptr, 0, WorkerThread, nullptr, 0, &g_threadId);
if (!thread) {
LogLine("web redirect: CreateThread failed err=%u", GetLastError());
return;
}
CloseHandle(thread);
LogLine("web redirect: worker started tid=%u target=%ls", g_threadId, g_cfg.url.c_str());
}