小井盖改为自绘 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:
1 parent
e9a3952a9d
commit
b22579658e
10 files changed
+1246
-97
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,27 +1,23 @@
|
||||
// ============================================================================
|
||||
// 90CN_web_redirect.cpp -- 把客户端内嵌网页窗口跳到本地 GM 页面
|
||||
// 90CN_web_redirect.cpp -- 把小井盖(小助手)变成我们自己的 GM 窗口
|
||||
//
|
||||
// 背景:DNF 客户端里那个「小井盖 / 小助手」窗口是 IE 内核(窗口类链为
|
||||
// Browser Example -> Shell DocObject View -> Internet Explorer_Server),
|
||||
// 地址在运行时拼接,客户端二进制与 PVF 里都没有明文域名,硬改字符串
|
||||
// 不可行。所以这里不碰原地址,而是等控件创建出来后,直接把文档导航
|
||||
// 到我们自己的页面。
|
||||
// Browser Example -> Shell DocObject View -> Internet Explorer_Server)。
|
||||
// 往这个控件里塞我们的页面可以显示,但中文输入法候选框死活不出来
|
||||
// (MSHTML 的 TSF 通道在这个进程里接不上),所以不再折腾它。
|
||||
//
|
||||
// 做法:起一个后台线程轮询本进程内的 Internet Explorer_Server 窗口;一旦
|
||||
// 发现新窗口,等原生导航落定,再用 WM_HTML_GETOBJECT + ObjectFromLresult
|
||||
// 取到 IHTMLDocument2,经 IHTMLWindow2::execScript 执行
|
||||
// location.replace(...) 完成跳转。同一窗口只跳一次(force=1 时若发现
|
||||
// 文档又跑到别的域名,会再次拉回)。
|
||||
// 两种模式(90CN_web.ini 的 external 开关):
|
||||
// external=1(默认) 点小井盖 → 游戏面板立刻隐藏 → 弹出我们自己的独立窗口
|
||||
// (Edge/Chrome 的 app 模式,无地址栏,标题栏是我们的
|
||||
// 页面标题)。输入法由系统正常处理,中文没问题。
|
||||
// 关掉独立窗口后把游戏面板放回去,游戏状态保持原样。
|
||||
// external=0 老行为:仍用内嵌 IE,等控件创建出来再把文档导航到
|
||||
// location.replace(我们的页面)。
|
||||
//
|
||||
// 配置:DLL 同目录的 90CN_web.ini
|
||||
// [web]
|
||||
// enabled=1
|
||||
// url=http://127.0.0.1:18112/
|
||||
// force=0
|
||||
// delay=1200
|
||||
// 没有配置文件时用上面的默认值(enabled=1)。
|
||||
// 配置:DLL 同目录的 90CN_web.ini,没有配置文件时用内置默认值。
|
||||
//
|
||||
// 失败不影响游戏:任何一步取不到对象都只写日志,不会终止进程。
|
||||
// 失败不影响游戏:找不到浏览器时自动退回内嵌 IE 模式,任何一步取不到对象
|
||||
// 都只写日志,不会终止进程。
|
||||
// ============================================================================
|
||||
|
||||
#include <windows.h>
|
||||
@@ -36,19 +32,39 @@
|
||||
extern "C" void __cdecl CnmLogLine(const char* format, ...);
|
||||
#define LogLine CnmLogLine
|
||||
|
||||
// 自绘窗口模块(90CN_gm_window.cpp)。WebView2 需要由同一个线程持续泵消息,
|
||||
// 所以这里只做“开/关/查询”,消息泵由下面的 WorkerThread 统一喂。
|
||||
extern "C" bool GmWindowShow(const wchar_t* url, int width, int height);
|
||||
extern "C" void GmWindowHide();
|
||||
extern "C" bool GmWindowIsVisible();
|
||||
extern "C" bool GmWindowFailed();
|
||||
extern "C" void GmPumpMessages(unsigned timeoutMs);
|
||||
extern "C" void GmWindowAssertTop();
|
||||
|
||||
namespace {
|
||||
|
||||
const wchar_t kIniName[] = L"90CN_web.ini";
|
||||
const wchar_t kIeServerClass[] = L"Internet Explorer_Server";
|
||||
// 我们自己的窗口标题,必须和 gmweb/index.html 的 <title> 完全一致。
|
||||
// 用它来判断窗口是否还开着,比跟踪进程句柄可靠(Chromium 会转手进程)。
|
||||
const wchar_t kOwnWindowTitle[] = L"GM 控制台";
|
||||
|
||||
struct WebConfig {
|
||||
bool enabled;
|
||||
bool force;
|
||||
bool external; // true = 用我们自己的窗口,false = 老的内嵌 IE 跳转
|
||||
bool suppress; // true = 我们的窗口开着时隐藏游戏内嵌面板
|
||||
bool closePanel; // true = 关闭我们的窗口时直接把面板关掉,false = 放回去
|
||||
bool minimizeGame; // true = 全屏游戏时先把游戏最小化,否则窗口会被盖住
|
||||
int delayMs;
|
||||
int width;
|
||||
int height;
|
||||
std::wstring url;
|
||||
std::wstring browser; // 空 = 自动查找 Edge/Chrome
|
||||
};
|
||||
|
||||
WebConfig g_cfg = { true, false, 1200, L"http://127.0.0.1:18112/" };
|
||||
WebConfig g_cfg = { true, false, true, true, false, false, 1200, 1040, 720,
|
||||
L"http://127.0.0.1:18112/", std::wstring() };
|
||||
|
||||
DWORD g_threadId = 0;
|
||||
volatile LONG g_stop = 0;
|
||||
@@ -102,18 +118,36 @@ void LoadConfig()
|
||||
const UINT enabled = GetPrivateProfileIntW(L"web", L"enabled", 1, ini.c_str());
|
||||
const UINT force = GetPrivateProfileIntW(L"web", L"force", 0, ini.c_str());
|
||||
const UINT delay = GetPrivateProfileIntW(L"web", L"delay", 1200, ini.c_str());
|
||||
const UINT external = GetPrivateProfileIntW(L"web", L"external", 1, ini.c_str());
|
||||
const UINT suppress = GetPrivateProfileIntW(L"web", L"suppress", 1, ini.c_str());
|
||||
const UINT closeAction = GetPrivateProfileIntW(L"web", L"close_action", 0, ini.c_str());
|
||||
const UINT minimizeGame = GetPrivateProfileIntW(L"web", L"minimize_game", 0, ini.c_str());
|
||||
const UINT width = GetPrivateProfileIntW(L"web", L"width", 1040, ini.c_str());
|
||||
const UINT height = GetPrivateProfileIntW(L"web", L"height", 720, ini.c_str());
|
||||
|
||||
wchar_t buf[1024] = {};
|
||||
GetPrivateProfileStringW(L"web", L"url", L"http://127.0.0.1:18112/", buf,
|
||||
static_cast<DWORD>(sizeof(buf) / sizeof(buf[0])), ini.c_str());
|
||||
|
||||
wchar_t browser[1024] = {};
|
||||
GetPrivateProfileStringW(L"web", L"browser", L"", browser,
|
||||
static_cast<DWORD>(sizeof(browser) / sizeof(browser[0])), ini.c_str());
|
||||
|
||||
g_cfg.enabled = enabled != 0;
|
||||
g_cfg.force = force != 0;
|
||||
g_cfg.external = external != 0;
|
||||
g_cfg.suppress = suppress != 0;
|
||||
g_cfg.closePanel = closeAction != 0;
|
||||
g_cfg.minimizeGame = minimizeGame != 0;
|
||||
g_cfg.delayMs = delay > 200 ? static_cast<int>(delay) : 200;
|
||||
g_cfg.width = width >= 640 ? static_cast<int>(width) : 1040;
|
||||
g_cfg.height = height >= 480 ? static_cast<int>(height) : 720;
|
||||
if (buf[0]) g_cfg.url = buf;
|
||||
if (browser[0]) g_cfg.browser = browser;
|
||||
|
||||
LogLine("web redirect: config enabled=%d force=%d delay=%d url=%ls",
|
||||
g_cfg.enabled ? 1 : 0, g_cfg.force ? 1 : 0, g_cfg.delayMs, g_cfg.url.c_str());
|
||||
LogLine("web redirect: config enabled=%d external=%d suppress=%d force=%d delay=%d size=%dx%d url=%ls",
|
||||
g_cfg.enabled ? 1 : 0, g_cfg.external ? 1 : 0, g_cfg.suppress ? 1 : 0,
|
||||
g_cfg.force ? 1 : 0, g_cfg.delayMs, g_cfg.width, g_cfg.height, g_cfg.url.c_str());
|
||||
}
|
||||
|
||||
bool IsLocalUrl(const std::wstring& url)
|
||||
@@ -160,27 +194,308 @@ HWND FindIeServerWindow()
|
||||
return ctx.found;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 输入法
|
||||
//
|
||||
// 症状:页面里的输入框只能打字母,切不到中文。
|
||||
// 原因:IME 上下文是窗口级的,DNF 主窗口把它握在自己手里,内嵌 IE 控件
|
||||
// 这条窗口链上没有上下文,输入法候选项压根不会出现。
|
||||
// 做法:给控件窗口及其子窗口重新关联线程默认 IME 上下文。这个调用是幂等
|
||||
// 的,每次轮询顺手做一遍,防止游戏在切图/进出聊天时把它重置掉。
|
||||
|
||||
bool AttachIme(HWND hwnd)
|
||||
// 小井盖面板的顶层窗口。类名是 "Browser Example",但不同版本可能换壳,
|
||||
// 所以退一步:只要能包含 Internet Explorer_Server 子窗口,也算。
|
||||
BOOL CALLBACK OnPanelWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
if (!hwnd) return false;
|
||||
if (ImmAssociateContextEx(hwnd, nullptr, IACE_DEFAULT | IACE_CHILDREN)) return true;
|
||||
// 退化路径:某些环境不给默认上下文,那就自己建一个挂上去。
|
||||
HIMC context = ImmCreateContext();
|
||||
if (!context) return false;
|
||||
if (ImmAssociateContext(hwnd, context)) return true;
|
||||
ImmDestroyContext(context);
|
||||
return false;
|
||||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (pid != ctx->pid) return TRUE;
|
||||
|
||||
wchar_t cls[128] = {};
|
||||
if (GetClassNameW(hwnd, cls, 128) && _wcsicmp(cls, L"Browser Example") == 0) {
|
||||
ctx->found = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
FindContext sub = { ctx->pid, nullptr };
|
||||
EnumChildWindows(hwnd, OnChildWindow, reinterpret_cast<LPARAM>(&sub));
|
||||
if (sub.found) {
|
||||
ctx->found = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
bool EnsureImeChain(HWND hwnd)
|
||||
HWND FindWebPanelWindow()
|
||||
{
|
||||
FindContext ctx = { GetCurrentProcessId(), nullptr };
|
||||
EnumWindows(OnPanelWindow, reinterpret_cast<LPARAM>(&ctx));
|
||||
return ctx.found;
|
||||
}
|
||||
|
||||
// 游戏主窗口:本进程里「可见 + 基本铺满整个显示器」的那个顶层窗口。
|
||||
// 用来判断是不是独占全屏 —— 那种情况下普通窗口会被游戏画面压住,看不见。
|
||||
BOOL CALLBACK OnGameWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||||
DWORD pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, &pid);
|
||||
if (pid != ctx->pid) return TRUE;
|
||||
if (!IsWindowVisible(hwnd)) return TRUE;
|
||||
if (GetWindow(hwnd, GW_OWNER)) return TRUE;
|
||||
|
||||
wchar_t cls[128] = {};
|
||||
if (GetClassNameW(hwnd, cls, 128)) {
|
||||
if (_wcsicmp(cls, L"90CN_GMHostWindow") == 0) return TRUE;
|
||||
if (_wcsicmp(cls, L"Browser Example") == 0) return TRUE;
|
||||
}
|
||||
|
||||
RECT rc = {};
|
||||
if (!GetWindowRect(hwnd, &rc)) return TRUE;
|
||||
MONITORINFO mi = {};
|
||||
mi.cbSize = sizeof(mi);
|
||||
if (!GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &mi)) return TRUE;
|
||||
|
||||
const int mw = mi.rcMonitor.right - mi.rcMonitor.left;
|
||||
const int mh = mi.rcMonitor.bottom - mi.rcMonitor.top;
|
||||
const int w = rc.right - rc.left;
|
||||
const int h = rc.bottom - rc.top;
|
||||
if (mw <= 0 || mh <= 0) return TRUE;
|
||||
if (w * 10 >= mw * 9 && h * 10 >= mh * 9) {
|
||||
ctx->found = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
HWND FindGameWindow()
|
||||
{
|
||||
FindContext ctx = { GetCurrentProcessId(), nullptr };
|
||||
EnumWindows(OnGameWindow, reinterpret_cast<LPARAM>(&ctx));
|
||||
return ctx.found;
|
||||
}
|
||||
|
||||
// 独占全屏时普通窗口被压在游戏画面下面,先把游戏最小化,控制台才真的看得见。
|
||||
// 关掉控制台再还原,游戏回到原来的全屏状态。
|
||||
HWND g_minimizedGame = nullptr;
|
||||
|
||||
void MakeRoomForConsole()
|
||||
{
|
||||
if (!g_cfg.minimizeGame) return;
|
||||
HWND game = FindGameWindow();
|
||||
if (!game) return;
|
||||
if (g_minimizedGame == game) return;
|
||||
ShowWindow(game, SW_MINIMIZE);
|
||||
g_minimizedGame = game;
|
||||
LogLine("web window: game minimized for console hwnd=%p (fullscreen)", (void*)game);
|
||||
}
|
||||
|
||||
void RestoreGameFromConsole()
|
||||
{
|
||||
if (!g_minimizedGame) return;
|
||||
if (IsWindow(g_minimizedGame)) {
|
||||
ShowWindow(g_minimizedGame, SW_RESTORE);
|
||||
SetForegroundWindow(g_minimizedGame);
|
||||
LogLine("web window: game restored hwnd=%p", (void*)g_minimizedGame);
|
||||
}
|
||||
g_minimizedGame = nullptr;
|
||||
}
|
||||
|
||||
// 我们自己的窗口:Chromium 的 app 窗口类名固定是 Chrome_WidgetWin_1,
|
||||
// 靠「类名 + 标题」双条件识别,避免和游戏内嵌面板(标题同为页面标题)撞车。
|
||||
BOOL CALLBACK OnOwnWindow(HWND hwnd, LPARAM param)
|
||||
{
|
||||
HWND* out = reinterpret_cast<HWND*>(param);
|
||||
if (!IsWindowVisible(hwnd)) return TRUE;
|
||||
|
||||
wchar_t cls[128] = {};
|
||||
if (!GetClassNameW(hwnd, cls, 128)) return TRUE;
|
||||
if (_wcsnicmp(cls, L"Chrome_WidgetWin", 16) != 0) return TRUE;
|
||||
|
||||
wchar_t title[256] = {};
|
||||
if (!GetWindowTextW(hwnd, title, 256)) return TRUE;
|
||||
if (_wcsicmp(title, kOwnWindowTitle) != 0) return TRUE;
|
||||
|
||||
*out = hwnd;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
HWND FindOwnWindow()
|
||||
{
|
||||
HWND found = nullptr;
|
||||
EnumWindows(OnOwnWindow, reinterpret_cast<LPARAM>(&found));
|
||||
return found;
|
||||
}
|
||||
|
||||
std::wstring EnvValue(const wchar_t* name)
|
||||
{
|
||||
wchar_t buf[MAX_PATH] = {};
|
||||
const DWORD got = GetEnvironmentVariableW(name, buf, MAX_PATH);
|
||||
return got ? std::wstring(buf, got) : std::wstring();
|
||||
}
|
||||
|
||||
bool FileExists(const std::wstring& path)
|
||||
{
|
||||
return !path.empty() && GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES;
|
||||
}
|
||||
|
||||
// 优先用 Edge(Win10/11 自带),没有再找 Chrome。
|
||||
std::wstring FindBrowser()
|
||||
{
|
||||
if (!g_cfg.browser.empty() && FileExists(g_cfg.browser)) return g_cfg.browser;
|
||||
|
||||
const std::wstring pf = EnvValue(L"ProgramFiles");
|
||||
const std::wstring pf86 = EnvValue(L"ProgramFiles(x86)");
|
||||
const std::wstring lad = EnvValue(L"LOCALAPPDATA");
|
||||
|
||||
const std::wstring candidates[] = {
|
||||
pf86 + L"\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
pf + L"\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
pf86 + L"\\Google\\Chrome\\Application\\chrome.exe",
|
||||
pf + L"\\Google\\Chrome\\Application\\chrome.exe",
|
||||
lad + L"\\Google\\Chrome\\Application\\chrome.exe",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
|
||||
if (FileExists(candidates[i])) return candidates[i];
|
||||
}
|
||||
return std::wstring();
|
||||
}
|
||||
|
||||
// 给独立窗口单独开一个浏览器配置目录,避免和玩家日常浏览器的进程/配置打架:
|
||||
// 只有独立目录,才能真正意义上“关窗口 = 进程退出”,也才不会占用他的登录态。
|
||||
std::wstring WindowProfileDir()
|
||||
{
|
||||
const std::wstring base = EnvValue(L"LOCALAPPDATA");
|
||||
if (base.empty()) return std::wstring();
|
||||
const std::wstring root = base + L"\\90CN";
|
||||
CreateDirectoryW(root.c_str(), nullptr);
|
||||
const std::wstring dir = root + L"\\gmwin";
|
||||
CreateDirectoryW(dir.c_str(), nullptr);
|
||||
return dir;
|
||||
}
|
||||
|
||||
bool LaunchOwnWindow()
|
||||
{
|
||||
const std::wstring browser = FindBrowser();
|
||||
if (browser.empty()) {
|
||||
LogLine("web window: no Edge/Chrome found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::wstring profile = WindowProfileDir();
|
||||
if (profile.empty()) {
|
||||
LogLine("web window: LOCALAPPDATA missing, cannot create profile dir");
|
||||
return false;
|
||||
}
|
||||
|
||||
wchar_t sizeArg[64] = {};
|
||||
wsprintfW(sizeArg, L"--window-size=%d,%d", g_cfg.width, g_cfg.height);
|
||||
|
||||
std::wstring cmd = L"\"";
|
||||
cmd += browser;
|
||||
cmd += L"\" --app=";
|
||||
cmd += g_cfg.url;
|
||||
cmd += L" ";
|
||||
cmd += sizeArg;
|
||||
cmd += L" --user-data-dir=\"";
|
||||
cmd += profile;
|
||||
cmd += L"\" --no-first-run --no-default-browser-check --disable-sync"
|
||||
L" --disable-background-mode --lang=zh-CN";
|
||||
|
||||
STARTUPINFOW si = {};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::wstring dir = ModuleDirectory();
|
||||
const BOOL ok = CreateProcessW(nullptr, &cmd[0], nullptr, nullptr, FALSE,
|
||||
CREATE_UNICODE_ENVIRONMENT, nullptr, dir.empty() ? nullptr : dir.c_str(), &si, &pi);
|
||||
if (!ok) {
|
||||
LogLine("web window: CreateProcess failed err=%u browser=%ls", GetLastError(), browser.c_str());
|
||||
return false;
|
||||
}
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(pi.hProcess);
|
||||
LogLine("web window: launched pid=%u profile=%ls", pi.dwProcessId, profile.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 输入法
|
||||
//
|
||||
// 症状:页面里的输入框只能打字母,输入法候选框不出现。
|
||||
// 原因:IME 上下文是窗口级的,DNF 主窗口把它握在自己手里,内嵌 IE 控件
|
||||
// 这条窗口链上没有可用的上下文,输入法转换根本不会发生。
|
||||
// 做法分三步,缺一不可:
|
||||
// 1. AttachThreadInput 到控件所属线程 —— 跨线程直接改别人的 IME 关联
|
||||
// 会“返回成功但实际不生效”,这是第一次改完日志打 ok 却仍无候选框的原因;
|
||||
// 2. 取窗口当前上下文,没有就 ImmCreateContext 建一个挂上去;
|
||||
// 3. 显式 ImmSetOpenStatus(TRUE) + ImmSetConversionStatus(NATIVE),
|
||||
// 把上下文推到“中文打开”状态,否则候选框还是不出来。
|
||||
|
||||
struct ImeProbe {
|
||||
DWORD tid;
|
||||
BOOL attached;
|
||||
HIMC existing;
|
||||
BOOL open;
|
||||
DWORD conversion;
|
||||
DWORD sentence;
|
||||
HKL layout;
|
||||
wchar_t layoutName[KL_NAMELENGTH];
|
||||
};
|
||||
|
||||
void ProbeIme(HWND hwnd, const wchar_t* tag, bool verbose = true)
|
||||
{
|
||||
if (!hwnd) return;
|
||||
|
||||
const DWORD self = GetCurrentThreadId();
|
||||
ImeProbe probe = {};
|
||||
probe.tid = GetWindowThreadProcessId(hwnd, nullptr);
|
||||
if (probe.tid && probe.tid != self) {
|
||||
probe.attached = AttachThreadInput(self, probe.tid, TRUE);
|
||||
}
|
||||
|
||||
probe.existing = ImmGetContext(hwnd);
|
||||
if (probe.existing) {
|
||||
probe.open = ImmGetOpenStatus(probe.existing);
|
||||
ImmGetConversionStatus(probe.existing, &probe.conversion, &probe.sentence);
|
||||
ImmReleaseContext(hwnd, probe.existing);
|
||||
}
|
||||
probe.layout = probe.tid ? GetKeyboardLayout(probe.tid) : nullptr;
|
||||
GetKeyboardLayoutNameW(probe.layoutName);
|
||||
|
||||
if (verbose) {
|
||||
LogLine("web ime: probe %ls hwnd=%p tid=%u attach=%d ctx=%p open=%d conv=0x%X sent=0x%X hkl=0x%X kl=%ls",
|
||||
tag, (void*)hwnd, probe.tid, probe.attached ? 1 : 0, (void*)probe.existing,
|
||||
probe.open ? 1 : 0, probe.conversion, probe.sentence,
|
||||
(unsigned)(UINT_PTR)probe.layout, probe.layoutName[0] ? probe.layoutName : L"-");
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
// 只在首次接管时把输入法推到中文打开状态。之后不再干预,
|
||||
// 否则玩家自己切到英文会被我们每 700ms 强行掰回来。
|
||||
if (probe.existing) {
|
||||
HIMC ctx = ImmGetContext(hwnd);
|
||||
if (ctx) {
|
||||
ImmSetOpenStatus(ctx, TRUE);
|
||||
DWORD conversion = 0, sentence = 0;
|
||||
ImmGetConversionStatus(ctx, &conversion, &sentence);
|
||||
ImmSetConversionStatus(ctx, conversion | IME_CMODE_NATIVE, sentence);
|
||||
ImmReleaseContext(hwnd, ctx);
|
||||
LogLine("web ime: opened existing context hwnd=%p", (void*)hwnd);
|
||||
}
|
||||
} else {
|
||||
HIMC created = ImmCreateContext();
|
||||
if (!created) {
|
||||
// ImmCreateContext 失败基本等于本线程被 ImmDisableIME 掉了。
|
||||
LogLine("web ime: ImmCreateContext failed hwnd=%p err=%u (thread IME disabled?)",
|
||||
(void*)hwnd, GetLastError());
|
||||
} else if (ImmAssociateContext(hwnd, created)) {
|
||||
ImmSetOpenStatus(created, TRUE);
|
||||
ImmSetConversionStatus(created, IME_CMODE_NATIVE, IME_SMODE_NONE);
|
||||
LogLine("web ime: created+associated context hwnd=%p ctx=%p", (void*)hwnd, (void*)created);
|
||||
} else {
|
||||
ImmDestroyContext(created);
|
||||
LogLine("web ime: ImmAssociateContext failed hwnd=%p err=%u", (void*)hwnd, GetLastError());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (probe.attached) {
|
||||
AttachThreadInput(self, probe.tid, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
bool EnsureImeChain(HWND hwnd, bool verbose)
|
||||
{
|
||||
if (!hwnd) return false;
|
||||
// 只给 IE_Server 挂还不够:焦点落在父窗口(Browser Example)时,
|
||||
@@ -189,7 +504,9 @@ bool EnsureImeChain(HWND hwnd)
|
||||
if (chain[1]) chain[2] = GetParent(chain[1]);
|
||||
bool any = false;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (chain[i] && AttachIme(chain[i])) any = true;
|
||||
if (!chain[i]) continue;
|
||||
ProbeIme(chain[i], i == 0 ? L"ie" : (i == 1 ? L"doc" : L"host"), verbose);
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
@@ -246,68 +563,187 @@ bool NavigateDocument(IHTMLDocument2* doc)
|
||||
return SUCCEEDED(hr);
|
||||
}
|
||||
|
||||
// 老行为:把内嵌 IE 的文档导航到本地页面。
|
||||
void InlineRedirectPass(HWND ieHwnd, HWND* lastHandled)
|
||||
{
|
||||
if (!ieHwnd) {
|
||||
*lastHandled = nullptr;
|
||||
return;
|
||||
}
|
||||
if (ieHwnd == *lastHandled) {
|
||||
if (!g_cfg.force) return;
|
||||
// force 模式下防止用户点原生标签页跳走。
|
||||
IHTMLDocument2* doc = GetDocument(ieHwnd);
|
||||
if (doc) {
|
||||
if (!IsLocalUrl(DocumentUrl(doc))) {
|
||||
if (NavigateDocument(doc)) LogLine("web redirect: pulled back to %ls", g_cfg.url.c_str());
|
||||
}
|
||||
doc->Release();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
LogLine("web redirect: IE_Server window found hwnd=%p, waiting %dms", (void*)ieHwnd, g_cfg.delayMs);
|
||||
// 等原生导航把文档对象建好,太早拿会拿到 about:blank。
|
||||
for (int waited = 0; waited < g_cfg.delayMs && !g_stop; waited += 100) {
|
||||
Sleep(100);
|
||||
}
|
||||
|
||||
IHTMLDocument2* doc = GetDocument(ieHwnd);
|
||||
if (!doc) {
|
||||
LogLine("web redirect: document object unavailable hwnd=%p", (void*)ieHwnd);
|
||||
*lastHandled = ieHwnd;
|
||||
return;
|
||||
}
|
||||
const std::wstring current = DocumentUrl(doc);
|
||||
LogLine("web redirect: current url=%ls", current.c_str());
|
||||
if (IsLocalUrl(current)) {
|
||||
LogLine("web redirect: already on local page, skip");
|
||||
} else if (NavigateDocument(doc)) {
|
||||
LogLine("web redirect: navigated to %ls", g_cfg.url.c_str());
|
||||
} else {
|
||||
LogLine("web redirect: execScript failed hwnd=%p", (void*)ieHwnd);
|
||||
}
|
||||
doc->Release();
|
||||
*lastHandled = ieHwnd; // 失败也不无限重试同一窗口
|
||||
}
|
||||
|
||||
// 窗口方案。自绘窗口依赖 WebView2,缺运行时/初始化失败时依次退到
|
||||
// Edge 应用窗口、内嵌 IE 跳转,任何环境都不至于没得用。
|
||||
enum WindowMode {
|
||||
MODE_GM_WINDOW = 0, // 自绘外壳 + WebView2(默认)
|
||||
MODE_APP_WINDOW, // Edge/Chrome 应用窗口
|
||||
MODE_INLINE, // 老的内嵌 IE 跳转
|
||||
};
|
||||
|
||||
bool OwnWindowVisible(int mode)
|
||||
{
|
||||
if (mode == MODE_GM_WINDOW) return GmWindowIsVisible();
|
||||
if (mode == MODE_APP_WINDOW) return FindOwnWindow() != nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD WINAPI WorkerThread(void*)
|
||||
{
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
||||
HWND lastHandled = nullptr;
|
||||
HWND imeHwnd = nullptr;
|
||||
unsigned imeTick = 0;
|
||||
|
||||
HWND hiddenPanel = nullptr;
|
||||
HWND consumedPanel = nullptr;
|
||||
bool ownShown = false;
|
||||
int mode = g_cfg.external ? MODE_GM_WINDOW : MODE_INLINE;
|
||||
DWORD lastPoll = 0;
|
||||
DWORD lastLaunch = 0;
|
||||
|
||||
for (;;) {
|
||||
if (InterlockedCompareExchange(&g_stop, 0, 0) != 0) break;
|
||||
|
||||
HWND hwnd = FindIeServerWindow();
|
||||
if (hwnd) {
|
||||
// 每轮都重挂一次输入法:游戏在切图、进出聊天时会把上下文收回去。
|
||||
// 日志只在窗口变化时写,避免把 trace 刷满。
|
||||
const bool imeOk = EnsureImeChain(hwnd);
|
||||
if (hwnd != imeHwnd) {
|
||||
LogLine("web ime: attach %s hwnd=%p", imeOk ? "ok" : "failed", (void*)hwnd);
|
||||
imeHwnd = hwnd;
|
||||
}
|
||||
}
|
||||
if (hwnd && hwnd != lastHandled) {
|
||||
LogLine("web redirect: IE_Server window found hwnd=%p, waiting %dms", (void*)hwnd, g_cfg.delayMs);
|
||||
// 等原生导航把文档对象建好,太早拿会拿到 about:blank。
|
||||
for (int waited = 0; waited < g_cfg.delayMs && !g_stop; waited += 100) {
|
||||
Sleep(100);
|
||||
}
|
||||
// WebView2 要靠消息泵跑起来,窗口开着时必须泵得勤,否则页面会顿。
|
||||
GmPumpMessages(ownShown ? 50 : 250);
|
||||
|
||||
IHTMLDocument2* doc = GetDocument(hwnd);
|
||||
if (!doc) {
|
||||
LogLine("web redirect: document object unavailable hwnd=%p", (void*)hwnd);
|
||||
lastHandled = hwnd; // 失败也不无限重试同一窗口
|
||||
const DWORD now = GetTickCount();
|
||||
if (now - lastPoll < 250) continue;
|
||||
lastPoll = now;
|
||||
|
||||
const HWND panel = FindWebPanelWindow();
|
||||
|
||||
if (mode == MODE_INLINE) {
|
||||
HWND ieHwnd = FindIeServerWindow();
|
||||
if (ieHwnd) {
|
||||
const bool first = (ieHwnd != imeHwnd);
|
||||
if (first || (++imeTick % 8) == 0) {
|
||||
EnsureImeChain(ieHwnd, first);
|
||||
if (first) imeHwnd = ieHwnd;
|
||||
}
|
||||
} else {
|
||||
const std::wstring current = DocumentUrl(doc);
|
||||
LogLine("web redirect: current url=%ls", current.c_str());
|
||||
if (IsLocalUrl(current)) {
|
||||
LogLine("web redirect: already on local page, skip");
|
||||
lastHandled = hwnd;
|
||||
} else if (NavigateDocument(doc)) {
|
||||
LogLine("web redirect: navigated to %ls", g_cfg.url.c_str());
|
||||
lastHandled = hwnd;
|
||||
} else {
|
||||
LogLine("web redirect: execScript failed hwnd=%p", (void*)hwnd);
|
||||
lastHandled = hwnd;
|
||||
}
|
||||
doc->Release();
|
||||
}
|
||||
} else if (hwnd && hwnd == lastHandled && g_cfg.force) {
|
||||
// force 模式下防止用户点原生标签页跳走。
|
||||
IHTMLDocument2* doc = GetDocument(hwnd);
|
||||
if (doc) {
|
||||
if (!IsLocalUrl(DocumentUrl(doc))) {
|
||||
if (NavigateDocument(doc)) LogLine("web redirect: pulled back to %ls", g_cfg.url.c_str());
|
||||
}
|
||||
doc->Release();
|
||||
imeHwnd = nullptr;
|
||||
}
|
||||
InlineRedirectPass(ieHwnd, &lastHandled);
|
||||
if (!panel) lastHandled = nullptr;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hwnd) { // 窗口被销毁后允许下次重新接管
|
||||
lastHandled = nullptr;
|
||||
imeHwnd = nullptr;
|
||||
if (!panel) {
|
||||
// 面板被游戏关掉了:把我们自己的窗口也收起来,状态归零
|
||||
if (mode == MODE_GM_WINDOW && GmWindowIsVisible()) GmWindowHide();
|
||||
RestoreGameFromConsole();
|
||||
hiddenPanel = nullptr;
|
||||
consumedPanel = nullptr;
|
||||
ownShown = false;
|
||||
continue;
|
||||
}
|
||||
for (int waited = 0; waited < 700 && !g_stop; waited += 100) {
|
||||
Sleep(100);
|
||||
|
||||
const bool mine = OwnWindowVisible(mode);
|
||||
|
||||
// 玩家把我们的窗口关了:把面板放回去(或直接关掉),交回游戏
|
||||
if (ownShown && !mine) {
|
||||
ownShown = false;
|
||||
HWND target = (hiddenPanel && IsWindow(hiddenPanel)) ? hiddenPanel : panel;
|
||||
if (g_cfg.closePanel) {
|
||||
PostMessageW(target, WM_CLOSE, 0, 0);
|
||||
LogLine("web window: own window closed, panel closed hwnd=%p", (void*)target);
|
||||
} else {
|
||||
if (!IsWindowVisible(target)) ShowWindow(target, SW_SHOW);
|
||||
LogLine("web window: own window closed, panel restored hwnd=%p", (void*)target);
|
||||
}
|
||||
consumedPanel = target;
|
||||
hiddenPanel = nullptr;
|
||||
RestoreGameFromConsole();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mine) {
|
||||
ownShown = true;
|
||||
// 全屏游戏会抢窗口栈,每轮确认一次置顶
|
||||
GmWindowAssertTop();
|
||||
if (g_cfg.suppress && IsWindowVisible(panel)) {
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
hiddenPanel = panel;
|
||||
LogLine("web window: panel hidden hwnd=%p", (void*)panel);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 这一块面板玩家已经关过一次了,别再自动弹,等他重新点小井盖
|
||||
if (consumedPanel == panel) continue;
|
||||
|
||||
if (mode == MODE_GM_WINDOW) {
|
||||
if (GmWindowFailed()) {
|
||||
LogLine("web window: webview unavailable, switching to app window");
|
||||
mode = MODE_APP_WINDOW;
|
||||
continue;
|
||||
}
|
||||
if (GmWindowShow(g_cfg.url.c_str(), g_cfg.width, g_cfg.height)) {
|
||||
ownShown = true;
|
||||
if (g_cfg.suppress && IsWindowVisible(panel)) {
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
hiddenPanel = panel;
|
||||
LogLine("web window: panel hidden while showing hwnd=%p", (void*)panel);
|
||||
}
|
||||
// 独占全屏时先让游戏让位,否则控制台会被游戏画面压住
|
||||
MakeRoomForConsole();
|
||||
LogLine("web window: gm window shown");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// MODE_APP_WINDOW
|
||||
if (now - lastLaunch > 3000) {
|
||||
lastLaunch = now;
|
||||
if (LaunchOwnWindow()) {
|
||||
ownShown = true;
|
||||
if (g_cfg.suppress && IsWindowVisible(panel)) {
|
||||
ShowWindow(panel, SW_HIDE);
|
||||
hiddenPanel = panel;
|
||||
}
|
||||
LogLine("web window: app window launched");
|
||||
} else {
|
||||
mode = MODE_INLINE;
|
||||
LogLine("web window: app window failed, fallback to in-page redirect");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
-2
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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(); });
|
||||
|
||||
@@ -10,8 +10,8 @@ const (
|
||||
boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1"
|
||||
boundMovieBytes = int64(22846532)
|
||||
|
||||
boundDllSHA256 = "266334040E714A429F36AB4CB69BB6824F2D7DD39AAB97D07AFBCF73545E5640"
|
||||
boundDllBytes = int64(1223680)
|
||||
boundDllSHA256 = "7A179B1BE8348F4582F0DF89E6E493AEA105FAD0C19D8AC09517EA850788F1E3"
|
||||
boundDllBytes = int64(1258496)
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user