// ============================================================================ // 90CN_web_redirect.cpp -- 把客户端内嵌网页窗口跳到本地 GM 页面 // // 背景:DNF 客户端里那个「小井盖 / 小助手」窗口是 IE 内核(窗口类链为 // Browser Example -> Shell DocObject View -> Internet Explorer_Server), // 地址在运行时拼接,客户端二进制与 PVF 里都没有明文域名,硬改字符串 // 不可行。所以这里不碰原地址,而是等控件创建出来后,直接把文档导航 // 到我们自己的页面。 // // 做法:起一个后台线程轮询本进程内的 Internet Explorer_Server 窗口;一旦 // 发现新窗口,等原生导航落定,再用 WM_HTML_GETOBJECT + ObjectFromLresult // 取到 IHTMLDocument2,经 IHTMLWindow2::execScript 执行 // location.replace(...) 完成跳转。同一窗口只跳一次(force=1 时若发现 // 文档又跑到别的域名,会再次拉回)。 // // 配置:DLL 同目录的 90CN_web.ini // [web] // enabled=1 // url=http://127.0.0.1:18112/ // force=0 // delay=1200 // 没有配置文件时用上面的默认值(enabled=1)。 // // 失败不影响游戏:任何一步取不到对象都只写日志,不会终止进程。 // ============================================================================ #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 namespace { const wchar_t kIniName[] = L"90CN_web.ini"; const wchar_t kIeServerClass[] = L"Internet Explorer_Server"; struct WebConfig { bool enabled; bool force; int delayMs; std::wstring url; }; WebConfig g_cfg = { true, false, 1200, L"http://127.0.0.1:18112/" }; 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(need - 1), L'\0'); MultiByteToWideChar(CP_ACP, 0, s, -1, &out[0], need); return out; } std::wstring out(static_cast(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(&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()); wchar_t buf[1024] = {}; GetPrivateProfileStringW(L"web", L"url", L"http://127.0.0.1:18112/", buf, static_cast(sizeof(buf) / sizeof(buf[0])), ini.c_str()); g_cfg.enabled = enabled != 0; g_cfg.force = force != 0; g_cfg.delayMs = delay > 200 ? static_cast(delay) : 200; if (buf[0]) g_cfg.url = buf; LogLine("web redirect: config enabled=%d force=%d delay=%d url=%ls", g_cfg.enabled ? 1 : 0, g_cfg.force ? 1 : 0, g_cfg.delayMs, g_cfg.url.c_str()); } bool IsLocalUrl(const std::wstring& url) { // 只认 "http://127.0.0.1:/" 前缀,用来判断当前文档是否已经在我们的页面上。 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(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(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(&ctx)); return ctx.found; } // ---------------------------------------------------------------- 输入法 // // 症状:页面里的输入框只能打字母,切不到中文。 // 原因:IME 上下文是窗口级的,DNF 主窗口把它握在自己手里,内嵌 IE 控件 // 这条窗口链上没有上下文,输入法候选项压根不会出现。 // 做法:给控件窗口及其子窗口重新关联线程默认 IME 上下文。这个调用是幂等 // 的,每次轮询顺手做一遍,防止游戏在切图/进出聊天时把它重置掉。 bool AttachIme(HWND hwnd) { if (!hwnd) return false; if (ImmAssociateContextEx(hwnd, nullptr, IACE_DEFAULT | IACE_CHILDREN)) return true; // 退化路径:某些环境不给默认上下文,那就自己建一个挂上去。 HIMC context = ImmCreateContext(); if (!context) return false; if (ImmAssociateContext(hwnd, context)) return true; ImmDestroyContext(context); return false; } bool EnsureImeChain(HWND hwnd) { 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] && AttachIme(chain[i])) 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(result), IID_IHTMLDocument2, 0, reinterpret_cast(&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); } DWORD WINAPI WorkerThread(void*) { CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); HWND lastHandled = nullptr; HWND imeHwnd = nullptr; for (;;) { if (InterlockedCompareExchange(&g_stop, 0, 0) != 0) break; HWND hwnd = FindIeServerWindow(); if (hwnd) { // 每轮都重挂一次输入法:游戏在切图、进出聊天时会把上下文收回去。 // 日志只在窗口变化时写,避免把 trace 刷满。 const bool imeOk = EnsureImeChain(hwnd); if (hwnd != imeHwnd) { LogLine("web ime: attach %s hwnd=%p", imeOk ? "ok" : "failed", (void*)hwnd); imeHwnd = hwnd; } } if (hwnd && hwnd != lastHandled) { LogLine("web redirect: IE_Server window found hwnd=%p, waiting %dms", (void*)hwnd, g_cfg.delayMs); // 等原生导航把文档对象建好,太早拿会拿到 about:blank。 for (int waited = 0; waited < g_cfg.delayMs && !g_stop; waited += 100) { Sleep(100); } IHTMLDocument2* doc = GetDocument(hwnd); if (!doc) { LogLine("web redirect: document object unavailable hwnd=%p", (void*)hwnd); lastHandled = hwnd; // 失败也不无限重试同一窗口 } else { 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"); lastHandled = hwnd; } else if (NavigateDocument(doc)) { LogLine("web redirect: navigated to %ls", g_cfg.url.c_str()); lastHandled = hwnd; } else { LogLine("web redirect: execScript failed hwnd=%p", (void*)hwnd); lastHandled = hwnd; } doc->Release(); } } else if (hwnd && hwnd == lastHandled && g_cfg.force) { // force 模式下防止用户点原生标签页跳走。 IHTMLDocument2* doc = GetDocument(hwnd); if (doc) { if (!IsLocalUrl(DocumentUrl(doc))) { if (NavigateDocument(doc)) LogLine("web redirect: pulled back to %ls", g_cfg.url.c_str()); } doc->Release(); } } if (!hwnd) { // 窗口被销毁后允许下次重新接管 lastHandled = nullptr; imeHwnd = nullptr; } for (int waited = 0; waited < 700 && !g_stop; waited += 100) { Sleep(100); } } 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()); }