- 触发条件改为「面板真的可见」而不是「窗口存在」,避免隐藏缓存的旧面板被当成点击 - 控制台打开期间面板一露头就 SW_HIDE,关控制台时关面板并在 2.5 秒内持续压制、最多再请 4 次关闭 - 轮询从 250ms 提到 40/60ms,消除旧页面闪现 - 服务端指纹回绑 CE86D4DF… (1258496)
791 lines
29 KiB
C++
791 lines
29 KiB
C++
// ============================================================================
|
||
// 90CN_web_redirect.cpp -- 把小井盖(小助手)变成我们自己的 GM 窗口
|
||
//
|
||
// 背景:DNF 客户端里那个「小井盖 / 小助手」窗口是 IE 内核(窗口类链为
|
||
// Browser Example -> Shell DocObject View -> Internet Explorer_Server)。
|
||
// 往这个控件里塞我们的页面可以显示,但中文输入法候选框死活不出来
|
||
// (MSHTML 的 TSF 通道在这个进程里接不上),所以不再折腾它。
|
||
//
|
||
// 两种模式(90CN_web.ini 的 external 开关):
|
||
// external=1(默认) 点小井盖 → 游戏面板立刻隐藏 → 弹出我们自己的独立窗口
|
||
// (Edge/Chrome 的 app 模式,无地址栏,标题栏是我们的
|
||
// 页面标题)。输入法由系统正常处理,中文没问题。
|
||
// 关掉独立窗口后把游戏面板放回去,游戏状态保持原样。
|
||
// external=0 老行为:仍用内嵌 IE,等控件创建出来再把文档导航到
|
||
// location.replace(我们的页面)。
|
||
//
|
||
// 配置:DLL 同目录的 90CN_web.ini,没有配置文件时用内置默认值。
|
||
//
|
||
// 失败不影响游戏:找不到浏览器时自动退回内嵌 IE 模式,任何一步取不到对象
|
||
// 都只写日志,不会终止进程。
|
||
// ============================================================================
|
||
|
||
#include <windows.h>
|
||
#include <imm.h>
|
||
#include <ole2.h>
|
||
#include <oleacc.h>
|
||
#include <mshtml.h>
|
||
#include <string>
|
||
|
||
// 90CN.cpp 里的 LogLine 在匿名 namespace 内(内部链接),所以走它导出的
|
||
// C 链接转发口,保证本模块的日志和安装流程写进同一个 90CN_trace.log。
|
||
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, 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;
|
||
|
||
std::wstring WideFromAnsi(const char* s)
|
||
{
|
||
if (!s || !*s) return std::wstring();
|
||
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
|
||
if (need <= 1) {
|
||
need = MultiByteToWideChar(CP_ACP, 0, s, -1, nullptr, 0);
|
||
if (need <= 1) return std::wstring();
|
||
std::wstring out(static_cast<size_t>(need - 1), L'\0');
|
||
MultiByteToWideChar(CP_ACP, 0, s, -1, &out[0], need);
|
||
return out;
|
||
}
|
||
std::wstring out(static_cast<size_t>(need - 1), L'\0');
|
||
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
|
||
return out;
|
||
}
|
||
|
||
std::wstring ModuleDirectory()
|
||
{
|
||
wchar_t path[MAX_PATH] = {};
|
||
HMODULE self = nullptr;
|
||
// 用数据地址反查自身模块:函数地址在某些加载方式下解析不到。
|
||
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||
reinterpret_cast<LPCWSTR>(&g_cfg), &self) ||
|
||
!self) {
|
||
return std::wstring();
|
||
}
|
||
if (!GetModuleFileNameW(self, path, MAX_PATH)) return std::wstring();
|
||
std::wstring full(path);
|
||
const size_t slash = full.find_last_of(L"\\/");
|
||
return slash == std::wstring::npos ? std::wstring() : full.substr(0, slash + 1);
|
||
}
|
||
|
||
void LoadConfig()
|
||
{
|
||
const std::wstring dir = ModuleDirectory();
|
||
if (dir.empty()) {
|
||
LogLine("web redirect: module dir unresolved, using built-in defaults");
|
||
return;
|
||
}
|
||
const std::wstring ini = dir + kIniName;
|
||
if (GetFileAttributesW(ini.c_str()) == INVALID_FILE_ATTRIBUTES) {
|
||
LogLine("web redirect: %ls not found, defaults enabled=1 url=%ls", ini.c_str(), g_cfg.url.c_str());
|
||
return;
|
||
}
|
||
|
||
const UINT enabled = GetPrivateProfileIntW(L"web", L"enabled", 1, ini.c_str());
|
||
const UINT force = GetPrivateProfileIntW(L"web", L"force", 0, ini.c_str());
|
||
const UINT delay = GetPrivateProfileIntW(L"web", L"delay", 1200, ini.c_str());
|
||
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 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)
|
||
{
|
||
// 只认 "http://127.0.0.1:<port>/" 前缀,用来判断当前文档是否已经在我们的页面上。
|
||
if (g_cfg.url.empty() || url.size() < g_cfg.url.size()) return false;
|
||
return _wcsnicmp(url.c_str(), g_cfg.url.c_str(), g_cfg.url.size()) == 0;
|
||
}
|
||
|
||
struct FindContext {
|
||
DWORD pid;
|
||
HWND found;
|
||
};
|
||
|
||
BOOL CALLBACK OnChildWindow(HWND hwnd, LPARAM param)
|
||
{
|
||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||
wchar_t cls[128] = {};
|
||
if (GetClassNameW(hwnd, cls, 128) && _wcsicmp(cls, kIeServerClass) == 0) {
|
||
DWORD pid = 0;
|
||
GetWindowThreadProcessId(hwnd, &pid);
|
||
if (pid == ctx->pid) {
|
||
ctx->found = hwnd;
|
||
return FALSE;
|
||
}
|
||
}
|
||
return TRUE;
|
||
}
|
||
|
||
BOOL CALLBACK OnTopWindow(HWND hwnd, LPARAM param)
|
||
{
|
||
FindContext* ctx = reinterpret_cast<FindContext*>(param);
|
||
DWORD pid = 0;
|
||
GetWindowThreadProcessId(hwnd, &pid);
|
||
if (pid != ctx->pid) return TRUE;
|
||
EnumChildWindows(hwnd, OnChildWindow, param);
|
||
return ctx->found ? FALSE : TRUE;
|
||
}
|
||
|
||
HWND FindIeServerWindow()
|
||
{
|
||
FindContext ctx = { GetCurrentProcessId(), nullptr };
|
||
EnumWindows(OnTopWindow, reinterpret_cast<LPARAM>(&ctx));
|
||
return ctx.found;
|
||
}
|
||
|
||
// 小井盖面板的顶层窗口。类名是 "Browser Example",但不同版本可能换壳,
|
||
// 所以退一步:只要能包含 Internet Explorer_Server 子窗口,也算。
|
||
BOOL CALLBACK OnPanelWindow(HWND hwnd, LPARAM param)
|
||
{
|
||
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;
|
||
}
|
||
|
||
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)时,
|
||
// 输入法又会被游戏主窗口抢回去,所以父链一起处理。
|
||
HWND chain[3] = { hwnd, GetParent(hwnd), nullptr };
|
||
if (chain[1]) chain[2] = GetParent(chain[1]);
|
||
bool any = false;
|
||
for (int i = 0; i < 3; i++) {
|
||
if (!chain[i]) continue;
|
||
ProbeIme(chain[i], i == 0 ? L"ie" : (i == 1 ? L"doc" : L"host"), verbose);
|
||
any = true;
|
||
}
|
||
return any;
|
||
}
|
||
|
||
// 取文档对象。WM_HTML_GETOBJECT 的返回值经 ObjectFromLresult 还原成接口指针。
|
||
IHTMLDocument2* GetDocument(HWND hwnd)
|
||
{
|
||
static const UINT msg = RegisterWindowMessageW(L"WM_HTML_GETOBJECT");
|
||
if (!msg) return nullptr;
|
||
|
||
DWORD_PTR result = 0;
|
||
const LRESULT ok = SendMessageTimeoutW(hwnd, msg, 0, 0,
|
||
SMTO_ABORTIFHUNG | SMTO_BLOCK, 4000, &result);
|
||
if (!ok || !result) return nullptr;
|
||
|
||
IHTMLDocument2* doc = nullptr;
|
||
if (FAILED(ObjectFromLresult(static_cast<LRESULT>(result), IID_IHTMLDocument2, 0,
|
||
reinterpret_cast<void**>(&doc)))) {
|
||
return nullptr;
|
||
}
|
||
return doc;
|
||
}
|
||
|
||
std::wstring DocumentUrl(IHTMLDocument2* doc)
|
||
{
|
||
BSTR url = nullptr;
|
||
std::wstring out;
|
||
if (doc && SUCCEEDED(doc->get_URL(&url)) && url) {
|
||
out.assign(url, SysStringLen(url));
|
||
}
|
||
if (url) SysFreeString(url);
|
||
return out;
|
||
}
|
||
|
||
bool NavigateDocument(IHTMLDocument2* doc)
|
||
{
|
||
IHTMLWindow2* win = nullptr;
|
||
if (FAILED(doc->get_parentWindow(&win)) || !win) return false;
|
||
|
||
// 用 location.replace 而不是 href:不往历史里塞记录,避免用户按返回键回到原页。
|
||
std::wstring script = L"location.replace('";
|
||
script += g_cfg.url;
|
||
script += L"')";
|
||
|
||
BSTR code = SysAllocString(script.c_str());
|
||
BSTR lang = SysAllocString(L"JavaScript");
|
||
VARIANT ret;
|
||
VariantInit(&ret);
|
||
HRESULT hr = win->execScript(code, lang, &ret);
|
||
VariantClear(&ret);
|
||
SysFreeString(code);
|
||
SysFreeString(lang);
|
||
win->Release();
|
||
return SUCCEEDED(hr);
|
||
}
|
||
|
||
// 老行为:把内嵌 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;
|
||
|
||
bool ownShown = false;
|
||
int mode = g_cfg.external ? MODE_GM_WINDOW : MODE_INLINE;
|
||
DWORD lastPoll = 0;
|
||
DWORD lastLaunch = 0;
|
||
// 玩家关掉控制台之后的抑制期:这段时间里面板只要露头就继续藏、继续请它关闭,
|
||
// 免得游戏的关闭动画把旧页面又画出来(残留)。
|
||
DWORD suppressUntil = 0;
|
||
int closeAttempts = 0;
|
||
|
||
for (;;) {
|
||
if (InterlockedCompareExchange(&g_stop, 0, 0) != 0) break;
|
||
|
||
const DWORD now = GetTickCount();
|
||
const bool suppressing = suppressUntil && now < suppressUntil;
|
||
|
||
// WebView2 要靠消息泵跑起来,窗口开着时必须泵得勤,否则页面会顿。
|
||
GmPumpMessages((ownShown || suppressing) ? 40 : 120);
|
||
|
||
// 原面板绝不能出现在画面上,所以轮询要快;空闲时放宽一点省 CPU。
|
||
const DWORD interval = (ownShown || suppressing) ? 40 : 60;
|
||
if (now - lastPoll < interval) continue;
|
||
lastPoll = now;
|
||
|
||
const HWND panel = FindWebPanelWindow();
|
||
const bool panelVisible = panel != nullptr && IsWindowVisible(panel) != FALSE;
|
||
|
||
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;
|
||
}
|
||
InlineRedirectPass(ieHwnd, &lastHandled);
|
||
if (!panel) lastHandled = nullptr;
|
||
continue;
|
||
}
|
||
|
||
// 控制台开着:原面板全程压制,一露头就藏,绝不让旧 IE 页面出现在画面上。
|
||
if (ownShown) {
|
||
if (!OwnWindowVisible(mode)) {
|
||
// 玩家把控制台关了:直接把原面板关掉并进入抑制期,不再放回游戏,
|
||
// 这样游戏自己的关闭动画不会把旧页面又画出来(残留)。
|
||
ownShown = false;
|
||
if (panel) {
|
||
if (panelVisible) ShowWindow(panel, SW_HIDE);
|
||
if (g_cfg.closePanel) {
|
||
PostMessageW(panel, WM_CLOSE, 0, 0);
|
||
suppressUntil = now + 2500;
|
||
closeAttempts = 1;
|
||
LogLine("web window: console closed, panel closed hwnd=%p", (void*)panel);
|
||
} else {
|
||
ShowWindow(panel, SW_SHOW);
|
||
LogLine("web window: console closed, panel restored hwnd=%p", (void*)panel);
|
||
}
|
||
}
|
||
RestoreGameFromConsole();
|
||
continue;
|
||
}
|
||
|
||
if (panelVisible) {
|
||
ShowWindow(panel, SW_HIDE);
|
||
LogLine("web window: panel suppressed hwnd=%p", (void*)panel);
|
||
}
|
||
// 全屏游戏会抢窗口栈,每轮确认一次置顶
|
||
GmWindowAssertTop();
|
||
continue;
|
||
}
|
||
|
||
// 抑制期:面板只要还露着就继续藏、继续请它关闭,期间不重新弹控制台。
|
||
if (suppressUntil) {
|
||
if (!panel || now > suppressUntil) {
|
||
suppressUntil = 0;
|
||
closeAttempts = 0;
|
||
} else {
|
||
if (panelVisible) {
|
||
ShowWindow(panel, SW_HIDE);
|
||
if (g_cfg.closePanel && closeAttempts < 4) {
|
||
PostMessageW(panel, WM_CLOSE, 0, 0);
|
||
++closeAttempts;
|
||
}
|
||
LogLine("web window: panel re-hidden after close hwnd=%p attempts=%d",
|
||
(void*)panel, closeAttempts);
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 面板不存在,或者存在但被游戏自己藏着 —— 都不该弹控制台。
|
||
// 只有「玩家点了小井盖、面板真的显示出来」才算一次打开请求。
|
||
if (!panelVisible) continue;
|
||
|
||
if (mode == MODE_GM_WINDOW) {
|
||
if (GmWindowFailed()) {
|
||
LogLine("web window: webview unavailable, switching to app window");
|
||
mode = MODE_APP_WINDOW;
|
||
continue;
|
||
}
|
||
// 先藏面板再建窗口,避免旧页面在中间闪一下
|
||
ShowWindow(panel, SW_HIDE);
|
||
if (GmWindowShow(g_cfg.url.c_str(), g_cfg.width, g_cfg.height)) {
|
||
ownShown = true;
|
||
// 独占全屏时先让游戏让位,否则控制台会被游戏画面压住
|
||
MakeRoomForConsole();
|
||
LogLine("web window: gm window shown, panel suppressed hwnd=%p", (void*)panel);
|
||
} else {
|
||
LogLine("web window: gm window show failed hwnd=%p", (void*)panel);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// MODE_APP_WINDOW
|
||
if (now - lastLaunch > 3000) {
|
||
lastLaunch = now;
|
||
ShowWindow(panel, SW_HIDE);
|
||
if (LaunchOwnWindow()) {
|
||
ownShown = true;
|
||
ShowWindow(panel, SW_HIDE);
|
||
LogLine("web window: app window launched");
|
||
} else {
|
||
mode = MODE_INLINE;
|
||
LogLine("web window: app window failed, fallback to in-page redirect");
|
||
}
|
||
}
|
||
}
|
||
|
||
CoUninitialize();
|
||
return 0;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
extern "C" void InstallWebRedirect()
|
||
{
|
||
LoadConfig();
|
||
if (!g_cfg.enabled) {
|
||
LogLine("web redirect: disabled by config");
|
||
return;
|
||
}
|
||
if (g_cfg.url.find(L'\'') != std::wstring::npos) {
|
||
LogLine("web redirect: url contains a quote, refused");
|
||
return;
|
||
}
|
||
|
||
InterlockedExchange(&g_stop, 0);
|
||
HANDLE thread = CreateThread(nullptr, 0, WorkerThread, nullptr, 0, &g_threadId);
|
||
if (!thread) {
|
||
LogLine("web redirect: CreateThread failed err=%u", GetLastError());
|
||
return;
|
||
}
|
||
CloseHandle(thread);
|
||
LogLine("web redirect: worker started tid=%u target=%ls", g_threadId, g_cfg.url.c_str());
|
||
}
|