// ============================================================================ // 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 #include #include #include #include #include // 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 GmWindowAssertTop(); namespace { const wchar_t kIniName[] = L"90CN_web.ini"; const wchar_t kIeServerClass[] = L"Internet Explorer_Server"; // 我们自己的窗口标题,必须和 gmweb/index.html 的 完全一致。 // 用它来判断窗口是否还开着,比跟踪进程句柄可靠(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); } // 全屏游戏会抢窗口栈,每轮确认一次置顶 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()); }