小井盖改为自绘 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
+1247 -98

No files matched your search

+520
View File
@@ -0,0 +1,520 @@
// ============================================================================
// 90CN_gm_window.cpp -- 我们自己绘制的 GM 窗口(自绘外壳 + Edge 内核内容)
//
// 为什么要自己画:游戏内嵌的 IE 控件在这个进程里输入法候选框出不来,
// 弹一个浏览器窗口又太“外部”。这里自己建一个无系统边框的窗口,标题栏、
// 边框、按钮全部由 GDI 绘制成和游戏一致的深色风格;内容区放一个 WebView2
// 控件(Edge 内核),中文输入由内核处理,候选框正常。
//
// 窗口生命周期:
// GmWindowShow() 第一次调用时创建窗口并初始化内嵌浏览器,之后复用
// GmWindowHide() 只是隐藏,不销毁,下次打开秒开
// GmPumpMessages() 由重定向线程每轮调用,负责把消息泵喂给窗口和 WebView2
//
// 失败不影响游戏:WebView2 初始化失败会把 g_failed 置位,由调用方回退到
// Edge 应用窗口或内嵌 IE 方案。
// ============================================================================
#include <windows.h>
#include <windowsx.h>
#include <wrl.h>
#include <string>
#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<ICoreWebView2Environment> g_env;
ComPtr<ICoreWebView2Controller> g_ctrl;
ComPtr<ICoreWebView2> 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<HBRUSH>(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<HFONT>(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<HPEN>(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<HPEN>(SelectObject(dc, linePen));
MoveToEx(dc, 0, kTitleH - 1, nullptr);
LineTo(dc, rc.right, kTitleH - 1);
HBRUSH hollow = static_cast<HBRUSH>(GetStockObject(NULL_BRUSH));
HBRUSH prevBrush = static_cast<HBRUSH>(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<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>(
[](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<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
[](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<ICoreWebView2Settings> 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<MINMAXINFO*>(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);
}
}
+20 -4
View File
@@ -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
+524 -88
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);
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;
}
bool EnsureImeChain(HWND hwnd)
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);
}
DWORD WINAPI WorkerThread(void*)
// 老行为:把内嵌 IE 的文档导航到本地页面。
void InlineRedirectPass(HWND ieHwnd, HWND* lastHandled)
{
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 (!ieHwnd) {
*lastHandled = nullptr;
return;
}
}
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) {
if (ieHwnd == *lastHandled) {
if (!g_cfg.force) return;
// force 模式下防止用户点原生标签页跳走。
IHTMLDocument2* doc = GetDocument(hwnd);
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;
}
if (!hwnd) { // 窗口被销毁后允许下次重新接管
lastHandled = nullptr;
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;
// WebView2 要靠消息泵跑起来,窗口开着时必须泵得勤,否则页面会顿。
GmPumpMessages(ownShown ? 50 : 250);
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 {
imeHwnd = nullptr;
}
for (int waited = 0; waited < 700 && !g_stop; waited += 100) {
Sleep(100);
InlineRedirectPass(ieHwnd, &lastHandled);
if (!panel) lastHandled = nullptr;
continue;
}
if (!panel) {
// 面板被游戏关掉了:把我们自己的窗口也收起来,状态归零
if (mode == MODE_GM_WINDOW && GmWindowIsVisible()) GmWindowHide();
RestoreGameFromConsole();
hiddenPanel = nullptr;
consumedPanel = nullptr;
ownShown = false;
continue;
}
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");
}
}
}
+75 -2
View File
@@ -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();
+11 -1
View File
@@ -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>
+12
View File
@@ -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;
}
+32
View File
@@ -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")
+4
View File
@@ -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}
+47 -1
View File
@@ -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,8 +56,49 @@
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;
@@ -10,8 +10,8 @@ const (
boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1"
boundMovieBytes = int64(22846532)
boundDllSHA256 = "266334040E714A429F36AB4CB69BB6824F2D7DD39AAB97D07AFBCF73545E5640"
boundDllBytes = int64(1223680)
boundDllSHA256 = "7A179B1BE8348F4582F0DF89E6E493AEA105FAD0C19D8AC09517EA850788F1E3"
boundDllBytes = int64(1258496)
)