小井盖改为自绘 GM 窗口(WebView2)+ 背包可见 + 启动器互斥

- 90CN.dll:新增 90CN_gm_window.cpp,自绘标题栏/边框的置顶窗口,内嵌 WebView2 承载 gmweb 页面
- 点小井盖时隐藏游戏内嵌 IE 面板、弹出控制台;关掉后还原,独占全屏压不住时可选 minimize_game
- 窗口每轮确认置顶,避免游戏抢窗口栈后消失
- gmweb:新增背包页,发放后自动跳转并高亮新入包的行(GET /local/dnf-gm/inventory/list)
- 启动器:新增 /console-status,控制台运行时置灰 GM 工具入口并阻止进入
- 服务端指纹回绑新 DLL(1258496 / 7A179B1B…)
This commit is contained in:
hanfuit committed 2026-11-05 13:16:52 +08:00
1 parent e9a3952a9d
commit b22579658e
10 files changed
+1246 -97

No files matched your search

+523 -87
View File
@@ -1,27 +1,23 @@
// ============================================================================
// 90CN_web_redirect.cpp -- 把客户端内嵌网页窗口跳到本地 GM 页面
// 90CN_web_redirect.cpp -- 把小井盖(小助手)变成我们自己的 GM 窗口
//
// 背景:DNF 客户端里那个「小井盖 / 小助手」窗口是 IE 内核(窗口类链为
// Browser Example -> Shell DocObject View -> Internet Explorer_Server),
// 地址在运行时拼接,客户端二进制与 PVF 里都没有明文域名,硬改字符串
// 不可行。所以这里不碰原地址,而是等控件创建出来后,直接把文档导航
// 到我们自己的页面。
// Browser Example -> Shell DocObject View -> Internet Explorer_Server)。
// 往这个控件里塞我们的页面可以显示,但中文输入法候选框死活不出来
// (MSHTML 的 TSF 通道在这个进程里接不上),所以不再折腾它。
//
// 做法:起一个后台线程轮询本进程内的 Internet Explorer_Server 窗口;一旦
// 发现新窗口,等原生导航落定,再用 WM_HTML_GETOBJECT + ObjectFromLresult
// 取到 IHTMLDocument2,经 IHTMLWindow2::execScript 执行
// location.replace(...) 完成跳转。同一窗口只跳一次(force=1 时若发现
// 文档又跑到别的域名,会再次拉回)。
// 两种模式(90CN_web.ini 的 external 开关):
// external=1(默认) 点小井盖 → 游戏面板立刻隐藏 → 弹出我们自己的独立窗口
// (Edge/Chrome 的 app 模式,无地址栏,标题栏是我们的
// 页面标题)。输入法由系统正常处理,中文没问题。
// 关掉独立窗口后把游戏面板放回去,游戏状态保持原样。
// external=0 老行为:仍用内嵌 IE,等控件创建出来再把文档导航到
// location.replace(我们的页面)。
//
// 配置:DLL 同目录的 90CN_web.ini
// [web]
// enabled=1
// url=http://127.0.0.1:18112/
// force=0
// delay=1200
// 没有配置文件时用上面的默认值(enabled=1)。
// 配置:DLL 同目录的 90CN_web.ini,没有配置文件时用内置默认值。
//
// 失败不影响游戏:任何一步取不到对象都只写日志,不会终止进程。
// 失败不影响游戏:找不到浏览器时自动退回内嵌 IE 模式,任何一步取不到对象
// 都只写日志,不会终止进程。
// ============================================================================
#include <windows.h>
@@ -36,19 +32,39 @@
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 的 <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, 1200, L"http://127.0.0.1:18112/" };
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;
@@ -102,18 +118,36 @@ void LoadConfig()
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 force=%d delay=%d url=%ls",
g_cfg.enabled ? 1 : 0, g_cfg.force ? 1 : 0, g_cfg.delayMs, g_cfg.url.c_str());
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)
@@ -160,27 +194,308 @@ HWND FindIeServerWindow()
return ctx.found;
}
// ---------------------------------------------------------------- 输入法
//
// 症状:页面里的输入框只能打字母,切不到中文。
// 原因:IME 上下文是窗口级的,DNF 主窗口把它握在自己手里,内嵌 IE 控件
// 这条窗口链上没有上下文,输入法候选项压根不会出现。
// 做法:给控件窗口及其子窗口重新关联线程默认 IME 上下文。这个调用是幂等
// 的,每次轮询顺手做一遍,防止游戏在切图/进出聊天时把它重置掉。
bool AttachIme(HWND hwnd)
// 小井盖面板的顶层窗口。类名是 "Browser Example",但不同版本可能换壳,
// 所以退一步:只要能包含 Internet Explorer_Server 子窗口,也算。
BOOL CALLBACK OnPanelWindow(HWND hwnd, LPARAM param)
{
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;
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;
}
bool EnsureImeChain(HWND hwnd)
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)时,
@@ -189,7 +504,9 @@ bool EnsureImeChain(HWND hwnd)
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;
if (!chain[i]) continue;
ProbeIme(chain[i], i == 0 ? L"ie" : (i == 1 ? L"doc" : L"host"), verbose);
any = true;
}
return any;
}
@@ -246,68 +563,187 @@ bool NavigateDocument(IHTMLDocument2* doc)
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;
HWND hiddenPanel = nullptr;
HWND consumedPanel = nullptr;
bool ownShown = false;
int mode = g_cfg.external ? MODE_GM_WINDOW : MODE_INLINE;
DWORD lastPoll = 0;
DWORD lastLaunch = 0;
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);
}
// WebView2 要靠消息泵跑起来,窗口开着时必须泵得勤,否则页面会顿。
GmPumpMessages(ownShown ? 50 : 250);
IHTMLDocument2* doc = GetDocument(hwnd);
if (!doc) {
LogLine("web redirect: document object unavailable hwnd=%p", (void*)hwnd);
lastHandled = hwnd; // 失败也不无限重试同一窗口
const DWORD now = GetTickCount();
if (now - lastPoll < 250) continue;
lastPoll = now;
const HWND panel = FindWebPanelWindow();
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 {
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();
imeHwnd = nullptr;
}
InlineRedirectPass(ieHwnd, &lastHandled);
if (!panel) lastHandled = nullptr;
continue;
}
if (!hwnd) { // 窗口被销毁后允许下次重新接管
lastHandled = nullptr;
imeHwnd = nullptr;
if (!panel) {
// 面板被游戏关掉了:把我们自己的窗口也收起来,状态归零
if (mode == MODE_GM_WINDOW && GmWindowIsVisible()) GmWindowHide();
RestoreGameFromConsole();
hiddenPanel = nullptr;
consumedPanel = nullptr;
ownShown = false;
continue;
}
for (int waited = 0; waited < 700 && !g_stop; waited += 100) {
Sleep(100);
const bool mine = OwnWindowVisible(mode);
// 玩家把我们的窗口关了:把面板放回去(或直接关掉),交回游戏
if (ownShown && !mine) {
ownShown = false;
HWND target = (hiddenPanel && IsWindow(hiddenPanel)) ? hiddenPanel : panel;
if (g_cfg.closePanel) {
PostMessageW(target, WM_CLOSE, 0, 0);
LogLine("web window: own window closed, panel closed hwnd=%p", (void*)target);
} else {
if (!IsWindowVisible(target)) ShowWindow(target, SW_SHOW);
LogLine("web window: own window closed, panel restored hwnd=%p", (void*)target);
}
consumedPanel = target;
hiddenPanel = nullptr;
RestoreGameFromConsole();
continue;
}
if (mine) {
ownShown = true;
// 全屏游戏会抢窗口栈,每轮确认一次置顶
GmWindowAssertTop();
if (g_cfg.suppress && IsWindowVisible(panel)) {
ShowWindow(panel, SW_HIDE);
hiddenPanel = panel;
LogLine("web window: panel hidden hwnd=%p", (void*)panel);
}
continue;
}
// 这一块面板玩家已经关过一次了,别再自动弹,等他重新点小井盖
if (consumedPanel == panel) continue;
if (mode == MODE_GM_WINDOW) {
if (GmWindowFailed()) {
LogLine("web window: webview unavailable, switching to app window");
mode = MODE_APP_WINDOW;
continue;
}
if (GmWindowShow(g_cfg.url.c_str(), g_cfg.width, g_cfg.height)) {
ownShown = true;
if (g_cfg.suppress && IsWindowVisible(panel)) {
ShowWindow(panel, SW_HIDE);
hiddenPanel = panel;
LogLine("web window: panel hidden while showing hwnd=%p", (void*)panel);
}
// 独占全屏时先让游戏让位,否则控制台会被游戏画面压住
MakeRoomForConsole();
LogLine("web window: gm window shown");
}
continue;
}
// MODE_APP_WINDOW
if (now - lastLaunch > 3000) {
lastLaunch = now;
if (LaunchOwnWindow()) {
ownShown = true;
if (g_cfg.suppress && IsWindowVisible(panel)) {
ShowWindow(panel, SW_HIDE);
hiddenPanel = panel;
}
LogLine("web window: app window launched");
} else {
mode = MODE_INLINE;
LogLine("web window: app window failed, fallback to in-page redirect");
}
}
}