"""软唤醒策略梯 + native helper(N1+)。""" from __future__ import annotations import json import logging import os import subprocess import time from pathlib import Path from typing import Callable, Optional, Tuple, Union from .errors import DesktopWakeError, ERR_WAKE_FAILED from .probe import probe from .profiles import load_profile from .types import Profile, WakeStepResult from . import uia_backend from .window import ( activate_hwnd, find_hwnd_for_profile, find_same_pid_hwnd_by_class, get_window_pid, require_windows, ) _logger = logging.getLogger("jiangchang_desktop_wake.wake") # strategy -> (detail, status) status: ok | fail | skipped StrategyFn = Callable[[int, Profile], Tuple[str, str]] def resolve_native_hwnd(probe_hwnd: int, profile: Profile) -> int: """native_helper 实际注入 hwnd。 若 profile.native_hwnd_class 已设:优先同 PID 下该 class 窗(如浮层 probe、主窗注入)。 找不到则回退 probe_hwnd,并打 warning。 """ cls = (getattr(profile, "native_hwnd_class", None) or "").strip() if not cls: return int(probe_hwnd) try: pid = get_window_pid(int(probe_hwnd)) hit = find_same_pid_hwnd_by_class(pid=pid, class_name=cls) except Exception as exc: _logger.warning( "resolve_native_hwnd failed profile=%s probe=%s err=%s; fallback probe", profile.id, probe_hwnd, exc, ) return int(probe_hwnd) if not hit: _logger.warning( "resolve_native_hwnd miss profile=%s native_hwnd_class=%s pid=%s " "probe=%s; fallback probe (may crash popup UI)", profile.id, cls, pid, probe_hwnd, ) return int(probe_hwnd) if int(hit) != int(probe_hwnd): _logger.info( "native_hwnd redirect profile=%s probe=%s -> inject=%s class=%s pid=%s", profile.id, probe_hwnd, hit, cls, pid, ) return int(hit) def _strategy_activate(hwnd: int, profile: Profile) -> Tuple[str, str]: maximize = bool(getattr(profile, "activate_maximize", True)) activate_hwnd(hwnd, maximize=maximize) return ("foreground+maximize" if maximize else "foreground"), "ok" def _strategy_wm_getobject(hwnd: int, _profile: Profile) -> Tuple[str, str]: return uia_backend.send_wm_getobject(hwnd), "ok" def _strategy_msaa(hwnd: int, _profile: Profile) -> Tuple[str, str]: return uia_backend.msaa_from_window(hwnd), "ok" def _strategy_uia_walk(hwnd: int, _profile: Profile) -> Tuple[str, str]: return uia_backend.deep_uia_walk(hwnd), "ok" def native_helper_path(name: str) -> Optional[Path]: here = Path(__file__).resolve().parent / "native" / name if here.is_file(): return here env = os.environ.get("JIANGCHANG_DESKTOP_WAKE_NATIVE") if env: p = Path(env) if p.is_file(): return p cand = p / name if cand.is_file(): return cand app_root = os.environ.get("JIANGCHANG_APP_ROOT") or os.environ.get("JIANGCHANG_SKILLS_ROOT") if app_root: for sub in ( Path(app_root) / "resources" / "desktop-wake" / name, Path(app_root) / "desktop-wake" / name, ): if sub.is_file(): return sub return None def _strategy_native(hwnd: int, profile: Profile) -> Tuple[str, str]: helper_name = profile.native_helper or "desktop-wake.exe" path = native_helper_path(helper_name) if path is None: return "skipped:native_helper_missing", "skipped" args = [] for a in profile.native_args or ["0x{hwnd_hex}"]: args.append( a.replace("{hwnd}", str(int(hwnd))) # 必须带 0x:纯 hex 如 542144 会被 C# 当成十进制,导致 window_invalid .replace("{hwnd_hex}", format(int(hwnd), "x")) .replace("{profile}", profile.id) ) cmd = [str(path), *args] mode_n2_wake = "n2-wake" in args # n2-wake:注入 + DuiLib 子类化,需更长超时;暖树由后续 probe 判定 timeout_s = 60 if mode_n2_wake else 25 _logger.info("native_helper cmd=%s timeout=%s", cmd, timeout_s) try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout_s, check=False ) except subprocess.TimeoutExpired: return "timeout", "fail" except OSError as exc: return f"spawn_error:{exc}", "fail" if mode_n2_wake: time.sleep(1.0) out = (proc.stdout or "").strip() err = (proc.stderr or "").strip() detail = f"rc={proc.returncode} out={out[:300]!r} err={err[:120]!r}" native_ok = False if out: # 取最后一行 JSON line = out.splitlines()[-1].strip() try: payload = json.loads(line) if mode_n2_wake: # n2-wake:ok=注入成功且 UI 仍响应;是否暖树看外层 probe native_ok = bool(payload.get("ok")) and bool( payload.get("inject_ok", payload.get("ok")) ) if payload.get("ui_responsive") is False: native_ok = False else: # n1/auto:ok 表示已暖 native_ok = bool(payload.get("ok")) detail = ( f"rc={proc.returncode} ok={native_ok} mode={'n2-wake' if mode_n2_wake else 'other'} " f"inject_ok={payload.get('inject_ok')} ui_responsive={payload.get('ui_responsive')} " f"walk={payload.get('walk_count')} findall={payload.get('findall_count')} " f"markers={payload.get('markers_found')} detail={payload.get('detail')}" ) except json.JSONDecodeError: native_ok = proc.returncode == 0 else: native_ok = proc.returncode == 0 return detail, ("ok" if native_ok else "fail") STRATEGIES: dict[str, StrategyFn] = { "activate": _strategy_activate, "wm_getobject": _strategy_wm_getobject, "msaa_from_window": _strategy_msaa, "uia_attach_walk": _strategy_uia_walk, "native_helper": _strategy_native, } def wake( profile: Union[str, Profile] = "wecom", *, hwnd: Optional[int] = None, stop_when_warm: bool = True, pause_s: float = 0.4, ) -> list[WakeStepResult]: """按 profile.wake_strategies 依次尝试唤醒。 - ``skipped``:策略未真正执行(如缺 exe),不算成功 - ``fail``:执行了但 helper/异常失败 - 每步后若 ``stop_when_warm`` 且 probe 已暖,则提前结束 """ require_windows() prof = load_profile(profile) if isinstance(profile, str) else profile # probe / activate 目标(如 SearchResultWindow2) h = find_hwnd_for_profile(prof, hwnd=hwnd) steps: list[WakeStepResult] = [] for name in prof.wake_strategies: fn = STRATEGIES.get(name) if fn is None: steps.append( WakeStepResult(strategy=name, ok=False, status="fail", detail="unknown_strategy") ) continue try: # native_helper:可重定向到同 PID 主窗注入;其余策略仍作用在 probe 窗 target = resolve_native_hwnd(h, prof) if name == "native_helper" else h detail, status = fn(target, prof) if name == "native_helper" and int(target) != int(h): detail = f"inject_hwnd={target} probe_hwnd={h} | {detail}" steps.append( WakeStepResult( strategy=name, ok=(status == "ok"), status=status, detail=detail, ) ) if status == "skipped": continue except Exception as exc: _logger.debug("strategy %s failed: %s", name, exc) steps.append( WakeStepResult(strategy=name, ok=False, status="fail", detail=str(exc)) ) continue time.sleep(pause_s) if stop_when_warm: try: # 始终对 profile 目标窗(浮层)判暖,不是 inject 窗 pr = probe(prof, hwnd=h) if pr.warm: steps.append( WakeStepResult( strategy="probe_check", ok=True, status="ok", detail=f"warm after {name} findall={pr.findall_count}", ) ) break except Exception as exc: steps.append( WakeStepResult( strategy="probe_check", ok=False, status="fail", detail=str(exc), ) ) return steps def wake_or_raise( profile: Union[str, Profile] = "wecom", *, hwnd: Optional[int] = None, ) -> list[WakeStepResult]: steps = wake(profile, hwnd=hwnd) prof = load_profile(profile) if isinstance(profile, str) else profile h = find_hwnd_for_profile(prof, hwnd=hwnd) pr = probe(prof, hwnd=h) if not pr.warm: raise DesktopWakeError( ERR_WAKE_FAILED, f"唤醒后仍冷 findall={pr.findall_count} missing={pr.markers_missing} " f"steps={[s.strategy + ':' + s.status for s in steps]}", ) return steps