2 Commits

Author SHA1 Message Date
24384d70ba fix(desktop-wake): never inject SearchResultWindow2 (1.2.9)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 32s
Soft-wake search popup only; n2-wake on float crashed WeCom via wecom_wake.dll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:32:23 +08:00
17ac1b4e11 fix(desktop-wake): multi-hwnd subclass + no maximize on search popup (1.2.8)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 37s
SearchResultWindow2 can be warmed after main is already warm; desktop-wake no longer maximizes the popup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:10:15 +08:00
11 changed files with 153 additions and 39 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jiangchang-platform-kit" name = "jiangchang-platform-kit"
version = "1.2.7" version = "1.2.9"
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -1,4 +1,4 @@
# jiangchang_desktop_wake 验收(ensure 主路径,1.2.7+) # jiangchang_desktop_wake 验收(ensure 主路径,1.2.9+)
正式对外路径是 **`ensure`**(内部:`activate` → `desktop-wake --mode n2-wake` → `probe`)。 正式对外路径是 **`ensure`**(内部:`activate` → `desktop-wake --mode n2-wake` → `probe`)。
`n2-wake`:**仅 OBJID_CLIENT** 走 DuiLib;不做进程内 FindAll、不同步 GETOBJECT、不 SPI 读屏;判暖只看进程外 probe。 `n2-wake`:**仅 OBJID_CLIENT** 走 DuiLib;不做进程内 FindAll、不同步 GETOBJECT、不 SPI 读屏;判暖只看进程外 probe。
@@ -7,7 +7,7 @@
| profile | 窗 | 说明 | | profile | 窗 | 说明 |
|---|---|---| |---|---|---|
| `wecom` | `WeWorkWindow` | 主窗(原有) | | `wecom` | `WeWorkWindow` | 主窗(原有) |
| `wecom_search` | `SearchResultWindow2` | 搜索浮层;不 Maximize;**n2-wake 注入同 PID 主窗**(`native_hwnd_class=WeWorkWindow`),probe 仍对浮层 | | `wecom_search` | `SearchResultWindow2` | 搜索浮层;**不对浮层 Maximize / 不注入**(1.2.9+ 仅软唤醒;注入会崩企微) |
## 0. 编译(企微先完全退出,含托盘) ## 0. 编译(企微先完全退出,含托盘)
@@ -77,10 +77,11 @@ python -m jiangchang_desktop_wake probe --profile wecom
--- ---
## C. 搜索浮层 wecom_search(1.2.7+) ## C. 搜索浮层 wecom_search(1.2.9+)
1. 打开企微主窗并登录;在搜索框输入联系人,**保持搜索结果浮层打开**(类名 `SearchResultWindow2`)。 1. **建议先** `ensure --profile wecom` 暖主窗(可与浮层分步)。
2. 验收: 2. 在搜索框输入联系人,**保持搜索结果浮层打开**(类名 `SearchResultWindow2`)。
3. 验收(**禁止**对浮层 n2-wake/注入;profile 仅 `wm_getobject` + `msaa`):
```powershell ```powershell
cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit
@@ -92,9 +93,10 @@ python -m jiangchang_desktop_wake probe --profile wecom_search
| 期望 | 说明 | | 期望 | 说明 |
|---|---| |---|---|
| ensure 退出码 `0` | 浮层已暖 | | ensure **不**崩企微 | 故障模块不应再是 `wecom_wake.dll` |
| `warm=true` | markers 含 `search_result` / `user_item`(或 findall 足够) | | `warm=true`(或 soft poke 后 findall≫2) | markers 含 `search_result` / `user_item` |
| 企微 UI | **不白屏**、无红叹号错误页(1.2.6 对浮层 hwnd 注入曾导致) | | 企微 UI | 浮层保持打开、**不** Maximize、不白屏 |
| 若手动对浮层跑 `desktop-wake … n2-wake` | exe/DLL 应走 `popup_soft_only` / `popup_nudge_only`,**不**子类化浮层 |
| `WINDOW_NOT_FOUND` | 浮层未打开——先展开搜索结果再测 | | `WINDOW_NOT_FOUND` | 浮层未打开——先展开搜索结果再测 |
--- ---

View File

@@ -42,7 +42,7 @@ print(rs.warm, rs.probe_after.findall_count, rs.probe_after.markers_found)
| profile | 窗 | 说明 | | profile | 窗 | 说明 |
|---|---|---| |---|---|---|
| `wecom` | `WeWorkWindow` | 主窗;markers=`con_op`,`searchedit` | | `wecom` | `WeWorkWindow` | 主窗;markers=`con_op`,`searchedit` |
| `wecom_search` | `SearchResultWindow2` | 搜索浮层;markers=`search_result`,`user_item`;不 Maximize;**n2-wake 注入同 PID 主窗**(1.2.7+) | | `wecom_search` | `SearchResultWindow2` | 搜索浮层;markers=`search_result`,`user_item`;**禁止注入/n2-wake**(1.2.9+ 仅进程外软唤醒) |
| CLI | 退出码 | | CLI | 退出码 |
|---|---| |---|---|

View File

@@ -19,6 +19,7 @@ namespace JiangchangDesktopWake
private const uint OBJID_CLIENT = 0xFFFFFFFC; private const uint OBJID_CLIENT = 0xFFFFFFFC;
private const uint OBJID_WINDOW = 0x00000000; private const uint OBJID_WINDOW = 0x00000000;
private const int SW_RESTORE = 9; private const int SW_RESTORE = 9;
private const int SW_SHOW = 5;
private const int SW_MAXIMIZE = 3; private const int SW_MAXIMIZE = 3;
private const int WH_CALLWNDPROC = 4; private const int WH_CALLWNDPROC = 4;
private const uint PROCESS_CREATE_THREAD = 0x0002; private const uint PROCESS_CREATE_THREAD = 0x0002;
@@ -42,6 +43,9 @@ namespace JiangchangDesktopWake
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern bool IsIconic(IntPtr hWnd); private static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
[DllImport("user32.dll")] [DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
@@ -165,6 +169,21 @@ namespace JiangchangDesktopWake
if (!doWakeProvider) if (!doWakeProvider)
EnableScreenReaderFlag(); EnableScreenReaderFlag();
// 1.2.9:SearchResultWindow2 禁止 n2 注入(wecom_wake.dll 子类化/DuiLib 会崩企微)
if (doN2 && IsSearchResultPopup(hwnd))
{
SendGetObject(hwnd);
TryMsaa(hwnd);
bool responsivePopup = CheckUiResponsive(hwnd);
method = "popup_soft_only";
WriteJson(
responsivePopup, false, responsivePopup,
profile, hwnd, 0, 0, new string[0],
"search_popup_no_inject|soft_getobject_msaa",
mode, method);
return responsivePopup ? 0 : 6;
}
if (doN2) if (doN2)
{ {
string n2Detail; string n2Detail;
@@ -578,12 +597,43 @@ namespace JiangchangDesktopWake
return new IntPtr(Convert.ToInt64(s, 16)); return new IntPtr(Convert.ToInt64(s, 16));
} }
private static bool IsSearchResultPopup(IntPtr hwnd)
{
try
{
var sb = new StringBuilder(256);
if (GetClassName(hwnd, sb, sb.Capacity) <= 0) return false;
return string.Equals(sb.ToString(), "SearchResultWindow2", StringComparison.Ordinal);
}
catch { return false; }
}
/// <summary>
/// 主窗可 Maximize;SearchResultWindow2 等浮层禁止 Maximize(会撑爆/关掉浮层)。
/// </summary>
private static bool ShouldMaximize(IntPtr hwnd)
{
try
{
var sb = new StringBuilder(256);
if (GetClassName(hwnd, sb, sb.Capacity) <= 0) return true;
string cls = sb.ToString();
if (string.Equals(cls, "SearchResultWindow2", StringComparison.Ordinal))
return false;
if (cls.IndexOf("Shadow", StringComparison.OrdinalIgnoreCase) >= 0)
return false;
}
catch { }
return true;
}
private static void Activate(IntPtr hwnd) private static void Activate(IntPtr hwnd)
{ {
try try
{ {
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE); if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
else ShowWindow(hwnd, SW_MAXIMIZE); else if (ShouldMaximize(hwnd)) ShowWindow(hwnd, SW_MAXIMIZE);
else ShowWindow(hwnd, SW_SHOW);
SetForegroundWindow(hwnd); SetForegroundWindow(hwnd);
Thread.Sleep(250); Thread.Sleep(250);
} }

View File

@@ -11,6 +11,8 @@
* - UiaRootObjectId 等原样交给旧 WndProc(禁止用 MSAA Lresult 冒充 UIA root) * - UiaRootObjectId 等原样交给旧 WndProc(禁止用 MSAA Lresult 冒充 UIA root)
* - 不做进程内 UIAutomation FindAll;不做同步 SendMessage(GETOBJECT) * - 不做进程内 UIAutomation FindAll;不做同步 SendMessage(GETOBJECT)
* - 重入守卫;判暖交给进程外 probe * - 重入守卫;判暖交给进程外 probe
* 1.2.8:支持多 HWND 子类化(主窗 + SearchResultWindow2),禁止「全局只装一次」。
* 1.2.9:SearchResultWindow2 禁止子类化/DuiLib thiscall(会崩企微);浮层只异步 nudge。
*/ */
#define WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN
#include <windows.h> #include <windows.h>
@@ -40,16 +42,25 @@
#define WM_JC_WAKE (WM_APP + 0x51) #define WM_JC_WAKE (WM_APP + 0x51)
#define WM_JC_QUIT (WM_APP + 0x52) #define WM_JC_QUIT (WM_APP + 0x52)
#define JC_SUBCLASS_ID 0x4A435701u #define JC_SUBCLASS_ID 0x4A435701u
/* 主窗 + 搜索浮层等可能各装一次;禁止全局「只装一次」漏掉 SearchResultWindow2 */
#define JC_MAX_SUBCLASS 8
static HMODULE g_self = NULL; static HMODULE g_self = NULL;
static HWND g_hwnd = NULL; static HWND g_hwnd = NULL;
static WNDPROC g_oldProc = NULL; static WNDPROC g_oldProc = NULL;
static WNDPROC g_duiOldProc = NULL;
static volatile LONG g_getobjectHits = 0; static volatile LONG g_getobjectHits = 0;
static volatile LONG g_hookHits = 0; static volatile LONG g_hookHits = 0;
static volatile LONG g_duiHits = 0; static volatile LONG g_duiHits = 0;
static volatile LONG g_reenterSkip = 0; static volatile LONG g_reenterSkip = 0;
static volatile LONG g_subclassInstalled = 0;
typedef struct JcSubEntry {
HWND hwnd;
WNDPROC oldProc; /* SetWindowLongPtr 路径;SetWindowSubclass 时为 NULL */
int useSubclassApi;
} JcSubEntry;
static JcSubEntry g_subs[JC_MAX_SUBCLASS];
static volatile LONG g_subCount = 0;
static HANDLE g_staThread = NULL; static HANDLE g_staThread = NULL;
static HANDLE g_staReady = NULL; static HANDLE g_staReady = NULL;
@@ -129,6 +140,14 @@ static void NudgeWinEvents(HWND hwnd)
PostMessageW(hwnd, WM_NULL, 0, 0); PostMessageW(hwnd, WM_NULL, 0, 0);
} }
static bool IsSearchResultPopup(HWND hwnd)
{
wchar_t cls[256];
if (!hwnd || !GetClassNameW(hwnd, cls, 256))
return false;
return _wcsicmp(cls, L"SearchResultWindow2") == 0;
}
static bool ResolveDuiLib() static bool ResolveDuiLib()
{ {
if (g_fnGetAccessible && g_fnOnGetObject) return true; if (g_fnGetAccessible && g_fnOnGetObject) return true;
@@ -209,6 +228,16 @@ static LRESULT HandleGetObject(
return r; return r;
} }
static JcSubEntry* FindSubEntry(HWND hwnd)
{
LONG n = g_subCount;
for (LONG i = 0; i < n && i < JC_MAX_SUBCLASS; i++) {
if (g_subs[i].hwnd == hwnd)
return &g_subs[i];
}
return NULL;
}
static LRESULT CALLBACK DuiSubclassProc( static LRESULT CALLBACK DuiSubclassProc(
HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
UINT_PTR uIdSubclass, DWORD_PTR dwRefData) UINT_PTR uIdSubclass, DWORD_PTR dwRefData)
@@ -222,27 +251,46 @@ static LRESULT CALLBACK DuiSubclassProc(
static LRESULT CALLBACK DuiWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) static LRESULT CALLBACK DuiWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{ {
JcSubEntry* e = FindSubEntry(hwnd);
WNDPROC old = e ? e->oldProc : NULL;
if (msg == WM_GETOBJECT) if (msg == WM_GETOBJECT)
return HandleGetObject(hwnd, wParam, lParam, g_duiOldProc, false); return HandleGetObject(hwnd, wParam, lParam, old, false);
if (g_duiOldProc) if (old)
return CallWindowProcW(g_duiOldProc, hwnd, msg, wParam, lParam); return CallWindowProcW(old, hwnd, msg, wParam, lParam);
return DefWindowProcW(hwnd, msg, wParam, lParam); return DefWindowProcW(hwnd, msg, wParam, lParam);
} }
static bool InstallDuiSubclass(HWND hwnd) static bool InstallDuiSubclass(HWND hwnd)
{ {
if (!hwnd || !IsWindow(hwnd)) return false; if (!hwnd || !IsWindow(hwnd)) return false;
if (InterlockedCompareExchange(&g_subclassInstalled, 1, 0) != 0) if (FindSubEntry(hwnd))
return true; return true;
if (g_subCount >= JC_MAX_SUBCLASS) {
WriteMarker("jiangchang_desktop_wake_subclass.txt",
"err=subclass_slots_full\r\n");
return false;
}
ResolveDuiLib(); ResolveDuiLib();
void* userdata = (void*)GetWindowLongPtrW(hwnd, GWLP_USERDATA); void* userdata = (void*)GetWindowLongPtrW(hwnd, GWLP_USERDATA);
/* 先占槽再换 WndProc,避免 GETOBJECT 抢在 Remember 之前 */
LONG idx = InterlockedIncrement(&g_subCount) - 1;
if (idx < 0 || idx >= JC_MAX_SUBCLASS) {
InterlockedDecrement(&g_subCount);
WriteMarker("jiangchang_desktop_wake_subclass.txt",
"err=subclass_slots_full\r\n");
return false;
}
g_subs[idx].hwnd = hwnd;
g_subs[idx].oldProc = NULL;
g_subs[idx].useSubclassApi = 0;
SetLastError(0); SetLastError(0);
g_duiOldProc = (WNDPROC)SetWindowLongPtrW( WNDPROC oldProc = (WNDPROC)SetWindowLongPtrW(
hwnd, GWLP_WNDPROC, (LONG_PTR)DuiWndProc); hwnd, GWLP_WNDPROC, (LONG_PTR)DuiWndProc);
DWORD gle = GetLastError(); DWORD gle = GetLastError();
BOOL ok = (g_duiOldProc != NULL); BOOL ok = (oldProc != NULL);
const char* method = "SetWindowLongPtr"; const char* method = "SetWindowLongPtr";
if (!ok) { if (!ok) {
@@ -255,17 +303,22 @@ static bool InstallDuiSubclass(HWND hwnd)
ok = SetWindowSubclass(hwnd, DuiSubclassProc, JC_SUBCLASS_ID, 0); ok = SetWindowSubclass(hwnd, DuiSubclassProc, JC_SUBCLASS_ID, 0);
gle = GetLastError(); gle = GetLastError();
method = "SetWindowSubclass"; method = "SetWindowSubclass";
g_subs[idx].useSubclassApi = 1;
oldProc = NULL;
} else {
g_subs[idx].oldProc = oldProc;
} }
char line[512]; char line[512];
sprintf_s(line, sprintf_s(line,
"hwnd=%p ok=%d gle=%lu method=%s userdata=%p " "hwnd=%p ok=%d gle=%lu method=%s userdata=%p "
"duilib=%p getacc=%p onget=%p\r\n", "duilib=%p getacc=%p onget=%p slots=%ld\r\n",
hwnd, (int)ok, gle, method, userdata, hwnd, (int)ok, gle, method, userdata,
g_duilib, g_fnGetAccessible, g_fnOnGetObject); g_duilib, g_fnGetAccessible, g_fnOnGetObject, (long)g_subCount);
WriteMarker("jiangchang_desktop_wake_subclass.txt", line); WriteMarker("jiangchang_desktop_wake_subclass.txt", line);
if (!ok) { if (!ok) {
InterlockedExchange(&g_subclassInstalled, 0); g_subs[idx].hwnd = NULL;
InterlockedDecrement(&g_subCount);
return false; return false;
} }
return true; return true;
@@ -278,6 +331,19 @@ static void DoSafeWake(HWND hwnd)
int subclass_ok = 0; int subclass_ok = 0;
int dui_ok = 0; int dui_ok = 0;
/* 浮层:DuiLib thiscall / 换 WndProc 在宿主环境会直接崩企微(fault=wecom_wake.dll) */
if (IsSearchResultPopup(hwnd)) {
NudgeWinEvents(hwnd);
NudgeWinEvents(hwnd);
sprintf_s(line,
"hwnd=%p sta_tid=%lu popup_nudge_only=1 subclass=0 dui=0 "
"dui_hits=%ld getobject_hits=%ld done=1\r\n",
hwnd, GetCurrentThreadId(),
(long)g_duiHits, (long)g_getobjectHits);
WriteMarker("jiangchang_desktop_wake_provider.txt", line);
return;
}
ResolveDuiLib(); ResolveDuiLib();
dui_ok = (g_fnGetAccessible || g_fnOnGetObject) ? 1 : 0; dui_ok = (g_fnGetAccessible || g_fnOnGetObject) ? 1 : 0;
subclass_ok = InstallDuiSubclass(hwnd) ? 1 : 0; subclass_ok = InstallDuiSubclass(hwnd) ? 1 : 0;

View File

@@ -44,15 +44,12 @@ _BUILTIN = {
}, },
"wecom_search": { "wecom_search": {
"id": "wecom_search", "id": "wecom_search",
"description": "企业微信搜索结果浮层 SearchResultWindow2(n2-wake 注入主窗)", "description": "企业微信搜索结果浮层 SearchResultWindow2;仅进程外软唤醒(禁止注入,1.2.9+)",
"window_class": "SearchResultWindow2", "window_class": "SearchResultWindow2",
"warm_min_findall": 20, "warm_min_findall": 20,
"warm_markers": ["search_result", "user_item"], "warm_markers": ["search_result", "user_item"],
"activate_maximize": False, "activate_maximize": False,
"native_hwnd_class": "WeWorkWindow", "wake_strategies": ["wm_getobject", "msaa_from_window"],
"wake_strategies": ["activate", "native_helper"],
"native_helper": "desktop-wake.exe",
"native_args": ["wecom", "0x{hwnd_hex}", "--mode", "n2-wake"],
}, },
} }

View File

@@ -1,15 +1,12 @@
{ {
"id": "wecom_search", "id": "wecom_search",
"description": "企业微信搜索结果浮层 SearchResultWindow2(联系人列表 user_item);n2-wake 注入同 PID 主窗 WeWorkWindow,避免对浮层 hwnd 注入导致白屏", "description": "企业微信搜索结果浮层 SearchResultWindow2;仅进程外软唤醒(禁止 n2-wake/注入,1.2.9+)",
"window_class": "SearchResultWindow2", "window_class": "SearchResultWindow2",
"warm_min_findall": 20, "warm_min_findall": 20,
"warm_markers": ["search_result", "user_item"], "warm_markers": ["search_result", "user_item"],
"activate_maximize": false, "activate_maximize": false,
"native_hwnd_class": "WeWorkWindow",
"wake_strategies": [ "wake_strategies": [
"activate", "wm_getobject",
"native_helper" "msaa_from_window"
], ]
"native_helper": "desktop-wake.exe",
"native_args": ["wecom", "0x{hwnd_hex}", "--mode", "n2-wake"]
} }

View File

@@ -29,8 +29,8 @@ class Profile:
native_args: list[str] = field(default_factory=list) native_args: list[str] = field(default_factory=list)
# 浮层窗(如搜索结果)禁止 Maximize,避免打乱搜索态 # 浮层窗(如搜索结果)禁止 Maximize,避免打乱搜索态
activate_maximize: bool = True activate_maximize: bool = True
# native_helper / n2-wake 注入目标窗 class;空则用 probe 窗本身。 # native_helper / n2-wake 目标窗 class;空则用 probe 窗本身。
# 搜索浮层应对主窗 WeWorkWindow 注入,避免对 SearchResultWindow2 注入导致白屏。 # wecom_search 留空;1.2.9+ 浮层禁止 native 注入,勿再设为浮层自身。
native_hwnd_class: Optional[str] = None native_hwnd_class: Optional[str] = None
@classmethod @classmethod

View File

@@ -34,9 +34,11 @@ class ProfileTests(unittest.TestCase):
self.assertIn("search_result", p.warm_markers) self.assertIn("search_result", p.warm_markers)
self.assertIn("user_item", p.warm_markers) self.assertIn("user_item", p.warm_markers)
self.assertFalse(p.activate_maximize) self.assertFalse(p.activate_maximize)
self.assertEqual(p.native_hwnd_class, "WeWorkWindow") # 1.2.9+:浮层禁止 n2-wake/注入(会崩企微);仅进程外软唤醒
self.assertIn("native_helper", p.wake_strategies) self.assertFalse((p.native_hwnd_class or "").strip())
self.assertIn("n2-wake", p.native_args or []) self.assertNotIn("native_helper", p.wake_strategies)
self.assertIn("wm_getobject", p.wake_strategies)
self.assertIn("msaa_from_window", p.wake_strategies)
def test_resolve_native_hwnd_fallback_without_class(self): def test_resolve_native_hwnd_fallback_without_class(self):
from jiangchang_desktop_wake.wake import resolve_native_hwnd from jiangchang_desktop_wake.wake import resolve_native_hwnd