From b22579658ef457d71d532bb978bef8ff1c656982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=AE=9D=E4=BB=99=E5=B0=8A?= Date: Thu, 5 Nov 2026 13:16:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E4=BA=95=E7=9B=96=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E8=87=AA=E7=BB=98=20GM=20=E7=AA=97=E5=8F=A3=EF=BC=88WebView2?= =?UTF-8?q?=EF=BC=89+=20=E8=83=8C=E5=8C=85=E5=8F=AF=E8=A7=81=20+=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=99=A8=E4=BA=92=E6=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 90CN.dll:新增 90CN_gm_window.cpp,自绘标题栏/边框的置顶窗口,内嵌 WebView2 承载 gmweb 页面 - 点小井盖时隐藏游戏内嵌 IE 面板、弹出控制台;关掉后还原,独占全屏压不住时可选 minimize_game - 窗口每轮确认置顶,避免游戏抢窗口栈后消失 - gmweb:新增背包页,发放后自动跳转并高亮新入包的行(GET /local/dnf-gm/inventory/list) - 启动器:新增 /console-status,控制台运行时置灰 GM 工具入口并阻止进入 - 服务端指纹回绑新 DLL(1258496 / 7A179B1B…) --- client-patch/90CN_gm_window.cpp | 520 +++++++++++++++ client-patch/90CN_web.ini | 24 +- client-patch/90CN_web_redirect.cpp | 610 +++++++++++++++--- gmweb/app.js | 77 ++- gmweb/index.html | 12 +- gmweb/style.css | 12 + launcher/server.py | 32 + launcher/shell.css | 4 + launcher/shell.js | 48 +- .../services/dnfbridge/client_build_id.go | 4 +- 10 files changed, 1246 insertions(+), 97 deletions(-) create mode 100644 client-patch/90CN_gm_window.cpp diff --git a/client-patch/90CN_gm_window.cpp b/client-patch/90CN_gm_window.cpp new file mode 100644 index 0000000..175fde2 --- /dev/null +++ b/client-patch/90CN_gm_window.cpp @@ -0,0 +1,520 @@ +// ============================================================================ +// 90CN_gm_window.cpp -- 我们自己绘制的 GM 窗口(自绘外壳 + Edge 内核内容) +// +// 为什么要自己画:游戏内嵌的 IE 控件在这个进程里输入法候选框出不来, +// 弹一个浏览器窗口又太“外部”。这里自己建一个无系统边框的窗口,标题栏、 +// 边框、按钮全部由 GDI 绘制成和游戏一致的深色风格;内容区放一个 WebView2 +// 控件(Edge 内核),中文输入由内核处理,候选框正常。 +// +// 窗口生命周期: +// GmWindowShow() 第一次调用时创建窗口并初始化内嵌浏览器,之后复用 +// GmWindowHide() 只是隐藏,不销毁,下次打开秒开 +// GmPumpMessages() 由重定向线程每轮调用,负责把消息泵喂给窗口和 WebView2 +// +// 失败不影响游戏:WebView2 初始化失败会把 g_failed 置位,由调用方回退到 +// Edge 应用窗口或内嵌 IE 方案。 +// ============================================================================ + +#include +#include +#include +#include + +#include "WebView2.h" + +extern "C" void __cdecl CnmLogLine(const char* format, ...); +#define LogLine CnmLogLine + +using Microsoft::WRL::Callback; +using Microsoft::WRL::ComPtr; + +namespace { + +const wchar_t kWindowClass[] = L"90CN_GMHostWindow"; +const wchar_t kTitleText[] = L"GM 控制台"; + +const int kTitleH = 38; // 自绘标题栏高度 +const int kBtnW = 44; // 标题栏按钮宽度 +const int kEdge = 5; // 拉伸手柄厚度 +const int kMinW = 760; +const int kMinH = 520; + +HWND g_hwnd = nullptr; +HFONT g_fontTitle = nullptr; +HFONT g_fontSmall = nullptr; +HBRUSH g_brBg = nullptr; + +ComPtr g_env; +ComPtr g_ctrl; +ComPtr g_web; + +std::wstring g_url; +std::wstring g_userDataDir; +bool g_envStarted = false; +bool g_failed = false; +bool g_trackingLeave = false; +int g_hover = 0; // 1 = 关闭按钮, 2 = 刷新按钮 + +// 颜色:和 gmweb 的深色主题对齐 +const COLORREF kColBg = RGB(0x12, 0x15, 0x1c); +const COLORREF kColBar = RGB(0x1a, 0x1e, 0x27); +const COLORREF kColLine = RGB(0x2f, 0x35, 0x47); +const COLORREF kColTitle = RGB(0xe8, 0xc9, 0x79); +const COLORREF kColMuted = RGB(0x8d, 0x94, 0xa6); + +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(); +} + +void ResizeWebView() +{ + if (!g_ctrl || !g_hwnd) return; + RECT rc = {}; + GetClientRect(g_hwnd, &rc); + RECT bounds = { 0, kTitleH, rc.right, rc.bottom }; + if (bounds.bottom < bounds.top) bounds.bottom = bounds.top; + g_ctrl->put_Bounds(bounds); +} + +void LayoutCenterOnOwner() +{ + RECT work = {}; + SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0); + RECT rc = {}; + GetWindowRect(g_hwnd, &rc); + const int w = rc.right - rc.left; + const int h = rc.bottom - rc.top; + const int x = work.left + (work.right - work.left - w) / 2; + const int y = work.top + (work.bottom - work.top - h) / 2; + SetWindowPos(g_hwnd, nullptr, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); +} + +void CloseButtonRect(RECT* out) +{ + RECT rc = {}; + GetClientRect(g_hwnd, &rc); + out->right = rc.right; + out->left = rc.right - kBtnW; + out->top = 0; + out->bottom = kTitleH; +} + +void RefreshButtonRect(RECT* out) +{ + RECT close = {}; + CloseButtonRect(&close); + out->right = close.left; + out->left = close.left - kBtnW; + out->top = 0; + out->bottom = kTitleH; +} + +void PaintWindow(HWND hwnd) +{ + PAINTSTRUCT ps = {}; + HDC dc = BeginPaint(hwnd, &ps); + if (!dc) return; + + RECT rc = {}; + GetClientRect(hwnd, &rc); + + // 整窗底色 + HBRUSH bg = g_brBg ? g_brBg : static_cast(GetStockObject(BLACK_BRUSH)); + FillRect(dc, &rc, bg); + + // 标题栏 + RECT bar = { 0, 0, rc.right, kTitleH }; + HBRUSH barBrush = CreateSolidBrush(kColBar); + FillRect(dc, &bar, barBrush); + DeleteObject(barBrush); + + // 金色小竖条 + 标题 + RECT accent = { 14, 11, 17, kTitleH - 11 }; + HBRUSH accentBrush = CreateSolidBrush(kColTitle); + FillRect(dc, &accent, accentBrush); + DeleteObject(accentBrush); + + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, kColTitle); + HFONT old = static_cast(SelectObject(dc, g_fontTitle ? g_fontTitle : GetStockObject(DEFAULT_GUI_FONT))); + RECT textRect = { 24, 0, rc.right - 2 * kBtnW - 8, kTitleH }; + DrawTextW(dc, kTitleText, -1, &textRect, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); + + // 刷新按钮 + RECT refresh = {}; + RefreshButtonRect(&refresh); + if (g_hover == 2) { + HBRUSH hb = CreateSolidBrush(RGB(0x26, 0x2c, 0x3a)); + FillRect(dc, &refresh, hb); + DeleteObject(hb); + } + SetTextColor(dc, kColMuted); + SelectObject(dc, g_fontSmall ? g_fontSmall : GetStockObject(DEFAULT_GUI_FONT)); + DrawTextW(dc, L"刷新", -1, &refresh, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + // 关闭按钮(自己画 ×,不依赖字体里有没有这个字形) + RECT close = {}; + CloseButtonRect(&close); + if (g_hover == 1) { + HBRUSH hb = CreateSolidBrush(RGB(0xc0, 0x39, 0x2b)); + FillRect(dc, &close, hb); + DeleteObject(hb); + } + const int cx = (close.left + close.right) / 2; + const int cy = (close.top + close.bottom) / 2; + const int r = 6; + HPEN pen = CreatePen(PS_SOLID, 2, g_hover == 1 ? RGB(0xff, 0xff, 0xff) : kColMuted); + HPEN oldPen = static_cast(SelectObject(dc, pen)); + MoveToEx(dc, cx - r, cy - r, nullptr); + LineTo(dc, cx + r + 1, cy + r + 1); + MoveToEx(dc, cx + r, cy - r, nullptr); + LineTo(dc, cx - r - 1, cy + r + 1); + SelectObject(dc, oldPen); + DeleteObject(pen); + + // 分隔线与外框 + HPEN linePen = CreatePen(PS_SOLID, 1, kColLine); + HPEN prevPen = static_cast(SelectObject(dc, linePen)); + MoveToEx(dc, 0, kTitleH - 1, nullptr); + LineTo(dc, rc.right, kTitleH - 1); + HBRUSH hollow = static_cast(GetStockObject(NULL_BRUSH)); + HBRUSH prevBrush = static_cast(SelectObject(dc, hollow)); + Rectangle(dc, 0, 0, rc.right, rc.bottom); + SelectObject(dc, prevBrush); + SelectObject(dc, prevPen); + DeleteObject(linePen); + + if (g_failed) { + SetTextColor(dc, kColMuted); + RECT tip = { 0, kTitleH, rc.right, rc.bottom }; + DrawTextW(dc, L"内嵌浏览器初始化失败,请检查 WebView2 运行时是否安装。", + -1, &tip, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + } else if (!g_web) { + SetTextColor(dc, kColMuted); + RECT tip = { 0, kTitleH, rc.right, rc.bottom }; + DrawTextW(dc, L"正在加载 GM 页面…", -1, &tip, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + } + + SelectObject(dc, old); + EndPaint(hwnd, &ps); +} + +// ---------------------------------------------------------------- WebView2 + +std::wstring UserDataDir() +{ + 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"\\wv2"; + CreateDirectoryW(dir.c_str(), nullptr); + return dir; +} + +void StartWebView() +{ + if (g_envStarted || g_failed || !g_hwnd) return; + g_envStarted = true; + + g_userDataDir = UserDataDir(); + if (g_userDataDir.empty()) { + g_failed = true; + LogLine("gm window: LOCALAPPDATA missing, webview cannot start"); + InvalidateRect(g_hwnd, nullptr, FALSE); + return; + } + + const HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(nullptr, g_userDataDir.c_str(), nullptr, + Callback( + [](HRESULT result, ICoreWebView2Environment* env) -> HRESULT { + if (FAILED(result) || !env) { + g_failed = true; + LogLine("gm window: environment failed hr=0x%08X", (unsigned)result); + if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE); + return S_OK; + } + g_env = env; + g_env->CreateCoreWebView2Controller(g_hwnd, + Callback( + [](HRESULT result2, ICoreWebView2Controller* controller) -> HRESULT { + if (FAILED(result2) || !controller) { + g_failed = true; + LogLine("gm window: controller failed hr=0x%08X", (unsigned)result2); + if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE); + return S_OK; + } + g_ctrl = controller; + g_ctrl->get_CoreWebView2(&g_web); + if (g_web) { + // 关掉右键菜单和开发者工具入口,看起来更像应用而不是网页 + ComPtr settings; + if (SUCCEEDED(g_web->get_Settings(&settings)) && settings) { + settings->put_AreDefaultContextMenusEnabled(FALSE); + settings->put_IsStatusBarEnabled(FALSE); + settings->put_AreDevToolsEnabled(FALSE); + } + if (!g_url.empty()) g_web->Navigate(g_url.c_str()); + } + ResizeWebView(); + if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE); + LogLine("gm window: webview ready"); + return S_OK; + }).Get()); + return S_OK; + }).Get()); + + if (FAILED(hr)) { + g_failed = true; + LogLine("gm window: CreateCoreWebView2EnvironmentWithOptions hr=0x%08X (runtime missing?)", (unsigned)hr); + if (g_hwnd) InvalidateRect(g_hwnd, nullptr, FALSE); + } +} + +// ---------------------------------------------------------------- 窗口过程 + +LRESULT HitTest(HWND hwnd) +{ + POINT pt = {}; + GetCursorPos(&pt); + ScreenToClient(hwnd, &pt); + + RECT rc = {}; + GetClientRect(hwnd, &rc); + + RECT close = {}; + CloseButtonRect(&close); + RECT refresh = {}; + RefreshButtonRect(&refresh); + if (PtInRect(&close, pt) || PtInRect(&refresh, pt)) return HTCLIENT; + + // 先判边缘,方便拉伸 + const bool left = pt.x < kEdge; + const bool right = pt.x >= rc.right - kEdge; + const bool top = pt.y < kEdge; + const bool bottom = pt.y >= rc.bottom - kEdge; + if (top && left) return HTTOPLEFT; + if (top && right) return HTTOPRIGHT; + if (bottom && left) return HTBOTTOMLEFT; + if (bottom && right) return HTBOTTOMRIGHT; + if (left) return HTLEFT; + if (right) return HTRIGHT; + if (top) return HTTOP; + if (bottom) return HTBOTTOM; + + if (pt.y < kTitleH) return HTCAPTION; + return HTCLIENT; +} + +LRESULT CALLBACK WindowProc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) +{ + switch (msg) { + case WM_NCCALCSIZE: + if (wparam) return 0; // 去掉系统非客户区,边框和标题栏由我们自己画 + break; + + case WM_NCHITTEST: + return HitTest(hwnd); + + case WM_ERASEBKGND: + return 1; + + case WM_PAINT: + PaintWindow(hwnd); + return 0; + + case WM_SIZE: + ResizeWebView(); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + + case WM_MOUSEMOVE: { + if (!g_trackingLeave) { + TRACKMOUSEEVENT tme = { sizeof(tme), TME_LEAVE, hwnd, 0 }; + TrackMouseEvent(&tme); + g_trackingLeave = true; + } + POINT pt = { GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam) }; + RECT close = {}; + CloseButtonRect(&close); + RECT refresh = {}; + RefreshButtonRect(&refresh); + int hover = 0; + if (PtInRect(&close, pt)) hover = 1; + else if (PtInRect(&refresh, pt)) hover = 2; + if (hover != g_hover) { + g_hover = hover; + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSELEAVE: + g_trackingLeave = false; + if (g_hover != 0) { + g_hover = 0; + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + + case WM_LBUTTONDOWN: { + POINT pt = { GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam) }; + RECT close = {}; + CloseButtonRect(&close); + RECT refresh = {}; + RefreshButtonRect(&refresh); + if (PtInRect(&close, pt)) { + PostMessageW(hwnd, WM_CLOSE, 0, 0); + return 0; + } + if (PtInRect(&refresh, pt)) { + if (g_web) g_web->Reload(); + return 0; + } + break; + } + + case WM_CLOSE: + ShowWindow(hwnd, SW_HIDE); + SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + LogLine("gm window: hidden by user"); + return 0; + + case WM_GETMINMAXINFO: { + MINMAXINFO* info = reinterpret_cast(lparam); + info->ptMinTrackSize.x = kMinW; + info->ptMinTrackSize.y = kMinH; + return 0; + } + + case WM_DESTROY: + g_hwnd = nullptr; + return 0; + + default: + break; + } + return DefWindowProcW(hwnd, msg, wparam, lparam); +} + +bool EnsureWindow() +{ + if (g_hwnd && IsWindow(g_hwnd)) return true; + + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(wc); + wc.style = CS_DROPSHADOW; + wc.lpfnWndProc = WindowProc; + wc.hInstance = GetModuleHandleW(nullptr); + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.hbrBackground = nullptr; + wc.lpszClassName = kWindowClass; + if (!RegisterClassExW(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + LogLine("gm window: RegisterClassEx failed err=%u", GetLastError()); + return false; + } + + if (!g_brBg) g_brBg = CreateSolidBrush(kColBg); + if (!g_fontTitle) { + g_fontTitle = CreateFontW(-16, 0, 0, 0, FW_SEMIBOLD, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, + DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI"); + } + if (!g_fontSmall) { + g_fontSmall = CreateFontW(-13, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, + DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI"); + } + + g_hwnd = CreateWindowExW(WS_EX_TOOLWINDOW, kWindowClass, kTitleText, + WS_POPUP | WS_THICKFRAME | WS_CLIPCHILDREN, + CW_USEDEFAULT, CW_USEDEFAULT, 1040, 720, + nullptr, nullptr, wc.hInstance, nullptr); + if (!g_hwnd) { + LogLine("gm window: CreateWindowEx failed err=%u", GetLastError()); + return false; + } + return true; +} + +} // namespace + +// ---------------------------------------------------------------- 对外接口 + +extern "C" bool GmWindowShow(const wchar_t* url, int width, int height) +{ + if (!EnsureWindow()) return false; + + if (url && *url) g_url = url; + + RECT rc = {}; + GetWindowRect(g_hwnd, &rc); + const int w = width > 0 ? width : (rc.right - rc.left); + const int h = height > 0 ? height : (rc.bottom - rc.top); + if (!IsWindowVisible(g_hwnd)) { + SetWindowPos(g_hwnd, HWND_TOPMOST, 0, 0, w, h, SWP_NOMOVE | SWP_SHOWWINDOW); + } else { + SetWindowPos(g_hwnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } + + if (!g_envStarted) { + LayoutCenterOnOwner(); + StartWebView(); + } else if (g_web && !g_url.empty()) { + // 页面服务可能重启过,重新打开时刷一次,避免停在错误页 + g_web->Reload(); + } + + ShowWindow(g_hwnd, SW_SHOW); + SetForegroundWindow(g_hwnd); + ResizeWebView(); + InvalidateRect(g_hwnd, nullptr, FALSE); + return true; +} + +extern "C" void GmWindowHide() +{ + if (g_hwnd && IsWindow(g_hwnd) && IsWindowVisible(g_hwnd)) { + ShowWindow(g_hwnd, SW_HIDE); + SetWindowPos(g_hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } +} + +extern "C" bool GmWindowIsVisible() +{ + return g_hwnd && IsWindow(g_hwnd) && IsWindowVisible(g_hwnd); +} + +// 游戏有时会在切图、恢复全屏时把窗口栈重排一遍,把我们的窗口压回普通层。 +// 主循环每轮调一次,发现置顶丢失就重新提上去(不抢焦点)。 +extern "C" void GmWindowAssertTop() +{ + if (!g_hwnd || !IsWindow(g_hwnd)) return; + const LONG_PTR ex = GetWindowLongPtrW(g_hwnd, GWL_EXSTYLE); + if ((ex & WS_EX_TOPMOST) == 0) { + SetWindowPos(g_hwnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + LogLine("gm window: re-asserted topmost"); + } +} + +extern "C" bool GmWindowFailed() +{ + return g_failed; +} + +extern "C" void GmPumpMessages(unsigned timeoutMs) +{ + MsgWaitForMultipleObjects(0, nullptr, FALSE, timeoutMs, QS_ALLINPUT); + MSG msg = {}; + while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } +} diff --git a/client-patch/90CN_web.ini b/client-patch/90CN_web.ini index 633a336..97f9d73 100644 --- a/client-patch/90CN_web.ini +++ b/client-patch/90CN_web.ini @@ -1,9 +1,25 @@ [web] -; 1 = redirect the embedded web window to the page below, 0 = leave it alone +; 1 = 接管小井盖窗口, 0 = 完全不管 enabled=1 -; target page. Must be plain http on 127.0.0.1 to avoid IE certificate prompts. +; 1 = 点小井盖时弹出我们自己的窗口(推荐,中文输入正常) +; 0 = 老行为:仍然用游戏内嵌的 IE,只把页面跳转到下面的 url +external=1 +; 我们自己的窗口开着的时候,把游戏内嵌的面板藏起来;关掉窗口后自动放回去 +suppress=1 +; 控制台默认置顶显示,全屏游戏下也能盖在游戏上面。 +; 如果某些机器上独占全屏压不住(窗口看不见),把它改成 1:开控制台时先把游戏最小化, +; 关掉控制台再自动还原全屏。 +minimize_game=0 +; 关掉我们自己的窗口时:0 = 把游戏面板放回去(安全),1 = 直接把面板关掉 +close_action=0 +; 我们自己的窗口尺寸 +width=1040 +height=720 +; 目标页面。必须是本地 http,避免浏览器弹证书提示。 url=http://127.0.0.1:18112/ -; 1 = if the document navigates away (native tabs), pull it back to url +; 备用浏览器路径(仅当 WebView2 不可用、退回 Edge 应用窗口时用),留空自动找 +browser= +; external=0 时:如果页面被跳到别处,是否拉回来 force=0 -; ms to wait after the IE_Server window appears, before navigating +; external=0 时:IE 控件出现后等多久再跳转(毫秒) delay=1200 diff --git a/client-patch/90CN_web_redirect.cpp b/client-patch/90CN_web_redirect.cpp index 056ea51..ab6b2ab 100644 --- a/client-patch/90CN_web_redirect.cpp +++ b/client-patch/90CN_web_redirect.cpp @@ -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 @@ -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 的 完全一致。 +// 用它来判断窗口是否还开着,比跟踪进程句柄可靠(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"); + } } } diff --git a/gmweb/app.js b/gmweb/app.js index 7880c39..1e6ba5a 100644 --- a/gmweb/app.js +++ b/gmweb/app.js @@ -4,7 +4,7 @@ 'use strict'; var S = { - charId: '', charName: '', chars: [], + charId: '', charName: '', chars: [], bagRows: [], itemId: 0, itemName: '', picked: null, items: [], page: 1, pageSize: 20, total: 0 }; @@ -66,6 +66,7 @@ for (var j = 0; j < panes.length; j++) { panes[j].className = 'pane' + (panes[j].id === 'pane-' + name ? ' on' : ''); } + if (name === 'bag') { loadBag(0); } } function bindTabs() { @@ -383,6 +384,10 @@ var data = d.data || {}; log('已发 ' + S.itemName + ' ×' + n + optText(o) + (data.slot != null ? ',格位 ' + data.list_type + ':' + data.slot : ''), 'ok'); + // 发完立刻切到背包页并高亮这一行:到底有没有进包,玩家要看得见 + switchTab('bag'); + loadBag(S.itemId); + setTimeout(function () { loadBag(S.itemId); }, 600); }); } @@ -401,6 +406,64 @@ }); } + /* ---------------------------------------------------------------- 背包 */ + + /* 背包 / 穿戴的实时快照。GM 发完东西,玩家第一件事就是看它到底进没进包, + 所以这里按服务端实际存的行来列,不做任何本地猜测。 */ + function loadBag(highlightItemId) { + if (!S.charId) { + $('bag-list').innerHTML = '<div class="empty">先在「角色」页选一个角色</div>'; + $('bag-info').innerHTML = '-'; + return; + } + api('/local/dnf-gm/inventory/list?character_id=' + encodeURIComponent(S.charId), + 'GET', null, function (d) { + if (!d) { return; } + var data = d.data || {}; + S.bagRows = data.rows || []; + renderBag(highlightItemId || 0); + }); + } + + function renderBag(highlightItemId) { + var box = $('bag-list'); + var rows = S.bagRows || []; + var bagCount = 0, wearCount = 0, i; + for (i = 0; i < rows.length; i++) { + if (rows[i].collection === 'wear') { wearCount++; } else { bagCount++; } + } + $('bag-info').innerHTML = '背包 ' + bagCount + ' 件 · 穿戴 ' + wearCount + ' 件'; + + if (!rows.length) { + box.innerHTML = '<div class="empty">背包是空的</div>'; + return; + } + + // 刚发进去的排在最前面,其余保持服务端顺序 + var ordered = []; + if (highlightItemId) { + for (i = 0; i < rows.length; i++) { + if (rows[i].item_id === highlightItemId) { ordered.push(rows[i]); } + } + } + for (i = 0; i < rows.length; i++) { + if (!highlightItemId || rows[i].item_id !== highlightItemId) { ordered.push(rows[i]); } + } + + var h = []; + for (i = 0; i < ordered.length; i++) { + var r = ordered[i]; + var isNew = highlightItemId && r.item_id === highlightItemId; + h.push('<div class="li' + (isNew ? ' on' : '') + '">' + + '<span class="nm">' + esc(r.item_name || ('物品 ' + r.item_id)) + + (isNew ? ' <em class="new">新</em>' : '') + '</span>' + + '<span class="meta">' + (r.collection === 'wear' ? '穿戴' : '背包') + + ' · ' + esc(r.entry_key) + ' · id ' + esc(r.item_id) + + (r.count > 1 ? ' ×' + esc(r.count) : '') + '</span></div>'); + } + box.innerHTML = h.join(''); + } + /* ---------------------------------------------------------------- 邮件 */ function sendMail() { @@ -452,6 +515,7 @@ bind('btn-prev', function () { if (S.page > 1) { loadItems(S.page - 1); } }); bind('btn-next', function () { loadItems(S.page + 1); }); bind('btn-grant', grantItem); + bind('btn-bag', function () { loadBag(0); }); bind('btn-mailitem', mailItem); bind('btn-mail', sendMail); @@ -473,7 +537,16 @@ } $('q-char').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadChars(); } }; - $('q-item').onkeydown = function (e) { if ((e || window.event).keyCode === 13) { loadItems(1); } }; + $('q-item').onkeydown = function (e) { + e = e || window.event; + // 229 是输入法组合中的键码,这时候的回车是上屏,不能抢。 + if (e.keyCode === 229) { return; } + if (e.keyCode === 13) { + if (e.preventDefault) { e.preventDefault(); } + loadItems(1); + return false; + } + }; fillTypeOptions(); diff --git a/gmweb/index.html b/gmweb/index.html index c88661f..ea78a0f 100644 --- a/gmweb/index.html +++ b/gmweb/index.html @@ -16,6 +16,7 @@ <a class="tab on" data-tab="home" href="javascript:void(0)">状态</a> <a class="tab" data-tab="char" href="javascript:void(0)">角色</a> <a class="tab" data-tab="item" href="javascript:void(0)">发物品</a> + <a class="tab" data-tab="bag" href="javascript:void(0)">背包</a> <a class="tab" data-tab="mail" href="javascript:void(0)">邮件</a> </div> @@ -79,7 +80,7 @@ <button class="btn sm" data-lv="61-90">61-90</button> </div> <div class="row"> - <input id="q-item" class="inp" placeholder="物品名关键字(可留空,用上面分类浏览)"> + <textarea id="q-item" class="inp ta" rows="1" placeholder="物品名关键字(可留空,用上面分类浏览)"></textarea> <select id="in-page-size" class="inp sm"> <option value="10">每页 10 行</option> <option value="20" selected>每页 20 行</option> @@ -126,6 +127,15 @@ <div class="hint">强化 0-31、增幅等级 0-31、锻造 0-8。增幅填了就直接覆盖强化(红字装不能同时白字强化),非装备行忽略这些值。</div> </div> + <div class="pane" id="pane-bag"> + <div class="row"> + <button id="btn-bag" class="btn">刷新背包</button> + <span id="bag-info" class="pg">-</span> + </div> + <div id="bag-list" class="list fixed"></div> + <div class="hint">发到背包的物品会自动跳到这一页并高亮(标「新」)。列表里没有,就说明没进背包:可能是格位满了,或者这个物品类型不能直接进背包(该走邮箱)。</div> + </div> + <div class="pane" id="pane-mail"> <div class="row"><label class="lb">标题</label><input id="in-title" class="inp" value="GM 邮件"></div> <div class="row"><label class="lb">内容</label><input id="in-body" class="inp" value="来自 GM 的问候"></div> diff --git a/gmweb/style.css b/gmweb/style.css index 7918713..35ea91f 100644 --- a/gmweb/style.css +++ b/gmweb/style.css @@ -53,6 +53,12 @@ table.kv td { padding: 4px 0; border-bottom: 1px solid #232734; color: #dfe3ea; } .inp:focus { border-color: #e8c979; ime-mode: active; } input[type="text"], textarea { ime-mode: active; } +/* 搜索框用 textarea:MSHTML 里它的 IME 走编辑引擎,比 input 更容易触发候选框。 + 单行用法,所以要自己压高度、禁掉拉伸和平滚动条。 */ +.inp.ta { + height: 26px; line-height: 18px; resize: none; overflow: hidden; + padding: 3px 6px; vertical-align: middle; +} .inp.sm { width: 110px; } .inp.xs { width: 60px; } .inp.xs2 { width: 84px; } @@ -99,3 +105,9 @@ select.inp { padding: 3px 2px; } #log .err { color: #e07070; } #log .tip { color: #e8c979; } .hint { color: #666e80; font-size: 11px; margin-top: 6px; } + +/* 背包页:刚发进去那一行的「新」标记 */ +.li .new { + font-style: normal; font-size: 11px; padding: 0 4px; margin-left: 4px; + background: #e8c979; color: #1a1e27; border-radius: 2px; +} diff --git a/launcher/server.py b/launcher/server.py index 5dbb294..a23265d 100644 --- a/launcher/server.py +++ b/launcher/server.py @@ -41,6 +41,28 @@ DETACHED_PROCESS = 0x00000008 DEFAULT_PORT = 19527 # 启动器本地端口 +# 游戏内「简易 GM 控制台」的窗口类名(90CN.dll 自绘窗口,见 client-patch/90CN_gm_window.cpp)。 +# 它和这里的完整 GM 工具是两套东西:控制台是精简版、只能发物品/调角色, +# 同时开着会互相覆盖同一个角色的数据,所以控制台在运行时把 GM 工具入口锁掉。 +CONSOLE_WINDOW_CLASS = "90CN_GMHostWindow" + + +def console_running() -> bool: + """游戏内简易 GM 控制台是否正在显示(跨进程按窗口类名查,不需要共享状态文件)。""" + try: + u32 = ctypes.WinDLL("user32", use_last_error=True) + u32.FindWindowW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p] + u32.FindWindowW.restype = ctypes.c_void_p + u32.IsWindowVisible.argtypes = [ctypes.c_void_p] + u32.IsWindowVisible.restype = ctypes.c_bool + hwnd = u32.FindWindowW(CONSOLE_WINDOW_CLASS, None) + if not hwnd: + return False + return bool(u32.IsWindowVisible(hwnd)) + except Exception: + return False + + # 更新检查配置:Gitea 自托管仓库的 Releases 地址 UPDATE_REPO_API = "https://git.hanfuit.com/api/v1/repos/Lingbao/90CNServer/releases" UPDATE_REPO_WEB = "https://git.hanfuit.com/Lingbao/90CNServer/releases" @@ -549,6 +571,8 @@ class Handler(BaseHTTPRequestHandler): # 静态资源 if path in ("/", "/index.html"): self._send_file(os.path.join(RES_DIR, "index.html"), "text/html; charset=utf-8") + elif path == "/console-status": + self._api_console_status() elif path == "/style.css": self._send_file(os.path.join(RES_DIR, "style.css"), "text/css; charset=utf-8") elif path == "/shell.css": @@ -712,6 +736,14 @@ class Handler(BaseHTTPRequestHandler): self._json({"error": "not found"}, 404) # ==================== 启动器接口 ==================== + def _api_console_status(self): + running = console_running() + self._json({ + "ok": True, + "running": running, + "reason": "游戏内简易 GM 控制台正在运行,先关掉它再用完整 GM 工具" if running else "", + }) + def _api_status(self): server_dir = os.path.join(SERVER_ROOT, "Server") client_dir = os.path.join(SERVER_ROOT, "Client") diff --git a/launcher/shell.css b/launcher/shell.css index 8ac4980..62a5ebc 100644 --- a/launcher/shell.css +++ b/launcher/shell.css @@ -389,3 +389,7 @@ body.gm-fullscreen .win-resize{display:none} .gs-switch.on{background:var(--primary)} .gs-switch.on .gs-knob{left:22px} + +/* 简易 GM 控制台运行时,完整 GM 工具入口置灰不可点 */ +.shell-nav-item.nav-disabled{opacity:.38;cursor:not-allowed;filter:grayscale(1)} +.shell-nav-item.nav-disabled:hover{background:transparent;color:inherit} diff --git a/launcher/shell.js b/launcher/shell.js index 61c87dc..664c000 100644 --- a/launcher/shell.js +++ b/launcher/shell.js @@ -11,6 +11,11 @@ function shellGo(page) { if (!pages.includes(page)) page = 'start'; + // 控制台在跑时不允许进 GM(除了点按钮,开始页等地方也可能跳过去) + if (page === 'gm' && window.shellConsoleBusy && window.shellConsoleBusy()) { + shellTip('游戏内简易 GM 控制台正在运行,先关掉它再用完整 GM 工具', 'err'); + return; + } document.querySelectorAll('.shell-page').forEach(el => el.classList.toggle('active', el.id === 'page-' + page)); document.querySelectorAll('.shell-nav-item').forEach(el => el.classList.toggle('active', el.dataset.shellPage === page)); $('shellCrumb').textContent = crumb[page] || page; @@ -51,9 +56,50 @@ window.shellExitGm = shellExitGm; document.querySelectorAll('.shell-nav-item').forEach(el => { - el.addEventListener('click', () => shellGo(el.dataset.shellPage)); + el.addEventListener('click', () => { + if (el.classList.contains('nav-disabled')) { + shellTip(el.getAttribute('title') || '暂时不可用', 'err'); + return; + } + shellGo(el.dataset.shellPage); + }); }); + // 简易 GM 控制台(游戏内那个自绘窗口)在运行时,锁掉完整 GM 工具入口: + // 两边改的是同一份角色数据,同时开会互相覆盖。控制台是精简版,优先保它。 + let consoleBusy = false; + function shellTip(msg, type) { + if (typeof gmToast === 'function') { gmToast(msg, type || 'info'); return; } + alert(msg); + } + function applyConsoleBusy(busy) { + const btn = document.querySelector('.shell-nav-item[data-shell-page="gm"]'); + if (!btn) return; + btn.classList.toggle('nav-disabled', busy); + btn.setAttribute('title', busy + ? '游戏内简易 GM 控制台正在运行,先关掉它再用完整 GM 工具' + : ''); + if (busy && document.body.classList.contains('gm-fullscreen')) { + shellExitGm(); + shellTip('简易 GM 控制台已启动,完整 GM 工具已自动退出', 'info'); + } + } + function pollConsoleStatus() { + fetch('/console-status', { cache: 'no-store' }) + .then(r => r.json()) + .then(d => { + const busy = !!(d && d.running); + if (busy !== consoleBusy) { + consoleBusy = busy; + applyConsoleBusy(busy); + } + }) + .catch(() => {}); + } + window.shellConsoleBusy = () => consoleBusy; + setInterval(pollConsoleStatus, 1500); + setTimeout(pollConsoleStatus, 300); + // 顶部窗口按钮 const win = () => (window.pywebview && window.pywebview.api) || null; const bMin = $('shellWinMin'); if (bMin) bMin.addEventListener('click', () => { const w = win(); if (w && w.minimize) w.minimize(); }); diff --git a/server/internal/services/dnfbridge/client_build_id.go b/server/internal/services/dnfbridge/client_build_id.go index 672e212..2812574 100644 --- a/server/internal/services/dnfbridge/client_build_id.go +++ b/server/internal/services/dnfbridge/client_build_id.go @@ -10,8 +10,8 @@ const ( boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1" boundMovieBytes = int64(22846532) - boundDllSHA256 = "266334040E714A429F36AB4CB69BB6824F2D7DD39AAB97D07AFBCF73545E5640" - boundDllBytes = int64(1223680) + boundDllSHA256 = "7A179B1BE8348F4582F0DF89E6E493AEA105FAD0C19D8AC09517EA850788F1E3" + boundDllBytes = int64(1258496) )