feat: add csharp win helper probe
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
# ISphereWinHelper First Phase Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the first C# read-only Windows helper for `version`, `self_check`, `scan_windows`, and `dump_uia` using the agreed Go/C# JSON boundary.
|
||||||
|
|
||||||
|
**Architecture:** The helper is a one-shot CLI process. Go will later send one JSON request on stdin and read one JSON response from stdout. This phase implements only read-only desktop/UIA probing and a PowerShell build script.
|
||||||
|
|
||||||
|
**Tech Stack:** C# .NET Framework, `csc.exe`, Windows UI Automation, Python `unittest` wrapper tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Contract and build test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_win_helper.py`
|
||||||
|
- Create: `scripts/build-win-helper.ps1`
|
||||||
|
- Create: `native/ISphereWinHelper/Program.cs`
|
||||||
|
- Create: `native/ISphereWinHelper/HelperProtocol.cs`
|
||||||
|
- Create: `native/ISphereWinHelper/WindowScanner.cs`
|
||||||
|
- Create: `native/ISphereWinHelper/UiaDumper.cs`
|
||||||
|
|
||||||
|
- [x] Write a Python unittest that expects `scripts/build-win-helper.ps1` to build `runs/win-helper/ISphereWinHelper.exe`.
|
||||||
|
- [x] Run the test and verify it fails because the build script/helper does not exist yet.
|
||||||
|
- [x] Add the minimal C# helper and build script.
|
||||||
|
- [x] Run the build test and verify it passes.
|
||||||
|
|
||||||
|
### Task 2: JSON operation tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_win_helper.py`
|
||||||
|
- Modify: `native/ISphereWinHelper/*.cs`
|
||||||
|
|
||||||
|
- [x] Add tests for `version`, `self_check`, `scan_windows`, invalid JSON, and unsupported op.
|
||||||
|
- [x] Run tests and verify they fail for missing behavior.
|
||||||
|
- [x] Implement JSON request/response handling and the four first-phase operations.
|
||||||
|
- [x] Run tests and verify they pass.
|
||||||
|
|
||||||
|
### Task 3: Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No new production files.
|
||||||
|
|
||||||
|
- [x] Run `python -m unittest discover -s tests -p test_win_helper.py -v`.
|
||||||
|
- [x] Run `python -m compileall -q tests`.
|
||||||
|
- [x] Run `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build-win-helper.ps1`.
|
||||||
|
- [x] Run direct `version`, `scan_windows`, and dedicated WinForms `dump_uia` JSON requests against the helper.
|
||||||
|
|
||||||
|
|
||||||
133
native/ISphereWinHelper/HelperProtocol.cs
Normal file
133
native/ISphereWinHelper/HelperProtocol.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Web.Script.Serialization;
|
||||||
|
|
||||||
|
namespace ISphereWinHelper
|
||||||
|
{
|
||||||
|
internal static class HelperProtocol
|
||||||
|
{
|
||||||
|
public const string Protocol = "isphere.helper.v1";
|
||||||
|
private static readonly JavaScriptSerializer Serializer = new JavaScriptSerializer { MaxJsonLength = 16 * 1024 * 1024, RecursionLimit = 256 };
|
||||||
|
|
||||||
|
public static bool TryParseRequest(string text, out Dictionary<string, object> request, out string requestId, out string op)
|
||||||
|
{
|
||||||
|
request = null;
|
||||||
|
requestId = "";
|
||||||
|
op = "";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object decoded = Serializer.DeserializeObject(text);
|
||||||
|
request = decoded as Dictionary<string, object>;
|
||||||
|
if (request == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string protocol = GetString(request, "protocol", "");
|
||||||
|
requestId = GetString(request, "request_id", "");
|
||||||
|
op = GetString(request, "op", "");
|
||||||
|
|
||||||
|
if (protocol != Protocol || string.IsNullOrWhiteSpace(op))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object> Success(string requestId, string op, object data)
|
||||||
|
{
|
||||||
|
return Envelope(requestId, op, true, data, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object> Failure(string requestId, string op, string code, string message)
|
||||||
|
{
|
||||||
|
var error = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "code", code },
|
||||||
|
{ "message", message }
|
||||||
|
};
|
||||||
|
return Envelope(requestId, op, false, null, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> Envelope(string requestId, string op, bool ok, object data, object error)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "protocol", Protocol },
|
||||||
|
{ "request_id", requestId ?? "" },
|
||||||
|
{ "op", op ?? "" },
|
||||||
|
{ "ok", ok },
|
||||||
|
{ "data", data },
|
||||||
|
{ "error", error },
|
||||||
|
{ "warnings", new object[0] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToJson(object value)
|
||||||
|
{
|
||||||
|
return Serializer.Serialize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object> GetObject(Dictionary<string, object> source, string key)
|
||||||
|
{
|
||||||
|
if (source == null || !source.ContainsKey(key) || source[key] == null)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>();
|
||||||
|
}
|
||||||
|
return source[key] as Dictionary<string, object> ?? new Dictionary<string, object>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetString(Dictionary<string, object> source, string key, string defaultValue)
|
||||||
|
{
|
||||||
|
if (source == null || !source.ContainsKey(key) || source[key] == null)
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return Convert.ToString(source[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool GetBool(Dictionary<string, object> source, string key, bool defaultValue)
|
||||||
|
{
|
||||||
|
if (source == null || !source.ContainsKey(key) || source[key] == null)
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToBoolean(source[key]);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetInt(Dictionary<string, object> source, string key, int defaultValue)
|
||||||
|
{
|
||||||
|
if (source == null || !source.ContainsKey(key) || source[key] == null)
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToInt32(source[key]);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
native/ISphereWinHelper/Program.cs
Normal file
88
native/ISphereWinHelper/Program.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ISphereWinHelper
|
||||||
|
{
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
string requestText = Console.In.ReadToEnd();
|
||||||
|
Dictionary<string, object> request;
|
||||||
|
string requestId = "";
|
||||||
|
string op = "";
|
||||||
|
|
||||||
|
if (!HelperProtocol.TryParseRequest(requestText, out request, out requestId, out op))
|
||||||
|
{
|
||||||
|
Console.WriteLine(HelperProtocol.ToJson(HelperProtocol.Failure(requestId, op, "INVALID_REQUEST", "request must be valid JSON object")));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Dictionary<string, object> opArgs = HelperProtocol.GetObject(request, "args");
|
||||||
|
Dictionary<string, object> response;
|
||||||
|
|
||||||
|
switch (op)
|
||||||
|
{
|
||||||
|
case "version":
|
||||||
|
response = HelperProtocol.Success(requestId, op, VersionData());
|
||||||
|
break;
|
||||||
|
case "self_check":
|
||||||
|
response = HelperProtocol.Success(requestId, op, SelfCheckData());
|
||||||
|
break;
|
||||||
|
case "scan_windows":
|
||||||
|
response = HelperProtocol.Success(requestId, op, WindowScanner.Scan(opArgs));
|
||||||
|
break;
|
||||||
|
case "dump_uia":
|
||||||
|
response = UiaDumper.Dump(requestId, op, opArgs);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
response = HelperProtocol.Failure(requestId, op, "UNSUPPORTED_OP", "unsupported op: " + op);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(HelperProtocol.ToJson(response));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(ex.ToString());
|
||||||
|
Console.WriteLine(HelperProtocol.ToJson(HelperProtocol.Failure(requestId, op, "HELPER_FAILED", ex.Message)));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> VersionData()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "helper_name", "ISphereWinHelper" },
|
||||||
|
{ "helper_version", "0.1.0" },
|
||||||
|
{ "protocol", HelperProtocol.Protocol },
|
||||||
|
{ "runtime", ".NET Framework " + Environment.Version }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> SelfCheckData()
|
||||||
|
{
|
||||||
|
bool uiaAvailable;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
uiaAvailable = System.Windows.Automation.AutomationElement.RootElement != null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
uiaAvailable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "desktop_access", Environment.UserInteractive },
|
||||||
|
{ "uia_available", uiaAvailable }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
native/ISphereWinHelper/UiaDumper.cs
Normal file
161
native/ISphereWinHelper/UiaDumper.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Windows.Automation;
|
||||||
|
|
||||||
|
namespace ISphereWinHelper
|
||||||
|
{
|
||||||
|
internal static class UiaDumper
|
||||||
|
{
|
||||||
|
public static Dictionary<string, object> Dump(string requestId, string op, Dictionary<string, object> args)
|
||||||
|
{
|
||||||
|
string hwndText = HelperProtocol.GetString(args, "hwnd", "");
|
||||||
|
IntPtr hwnd;
|
||||||
|
if (!WindowScanner.TryParseHwnd(hwndText, out hwnd))
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "invalid or empty hwnd");
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxDepth = HelperProtocol.GetInt(args, "max_depth", 8);
|
||||||
|
if (maxDepth < 0) maxDepth = 0;
|
||||||
|
if (maxDepth > 20) maxDepth = 20;
|
||||||
|
bool includeText = HelperProtocol.GetBool(args, "include_text", true);
|
||||||
|
int maxChildren = HelperProtocol.GetInt(args, "max_children", 200);
|
||||||
|
if (maxChildren < 1) maxChildren = 1;
|
||||||
|
if (maxChildren > 1000) maxChildren = 1000;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AutomationElement rootElement = AutomationElement.FromHandle(hwnd);
|
||||||
|
if (rootElement == null)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "WINDOW_NOT_FOUND", "no UI Automation element for hwnd");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "hwnd", WindowScanner.FormatHwnd(hwnd) },
|
||||||
|
{ "root", DumpElement(rootElement, 0, maxDepth, includeText, maxChildren) }
|
||||||
|
};
|
||||||
|
return HelperProtocol.Success(requestId, op, data);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HelperProtocol.Failure(requestId, op, "UIA_DUMP_FAILED", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> DumpElement(AutomationElement element, int depth, int maxDepth, bool includeText, int maxChildren)
|
||||||
|
{
|
||||||
|
var node = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "name", SafeString(delegate { return element.Current.Name; }) },
|
||||||
|
{ "control_type", SafeControlType(element) },
|
||||||
|
{ "automation_id", SafeString(delegate { return element.Current.AutomationId; }) },
|
||||||
|
{ "class_name", SafeString(delegate { return element.Current.ClassName; }) },
|
||||||
|
{ "localized_control_type", SafeString(delegate { return element.Current.LocalizedControlType; }) },
|
||||||
|
{ "framework_id", SafeString(delegate { return element.Current.FrameworkId; }) },
|
||||||
|
{ "is_enabled", SafeBool(delegate { return element.Current.IsEnabled; }) },
|
||||||
|
{ "is_offscreen", SafeBool(delegate { return element.Current.IsOffscreen; }) },
|
||||||
|
{ "bounds", SafeBounds(element) }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeText)
|
||||||
|
{
|
||||||
|
node["value"] = SafeValue(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
var children = new List<Dictionary<string, object>>();
|
||||||
|
if (depth < maxDepth)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AutomationElementCollection found = element.FindAll(TreeScope.Children, Condition.TrueCondition);
|
||||||
|
int limit = Math.Min(found.Count, maxChildren);
|
||||||
|
for (int i = 0; i < limit; i++)
|
||||||
|
{
|
||||||
|
children.Add(DumpElement(found[i], depth + 1, maxDepth, includeText, maxChildren));
|
||||||
|
}
|
||||||
|
if (found.Count > limit)
|
||||||
|
{
|
||||||
|
node["truncated_children"] = found.Count - limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
node["children_error"] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node["children"] = children;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private delegate string StringGetter();
|
||||||
|
private delegate bool BoolGetter();
|
||||||
|
|
||||||
|
private static string SafeString(StringGetter getter)
|
||||||
|
{
|
||||||
|
try { return getter() ?? ""; }
|
||||||
|
catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SafeBool(BoolGetter getter)
|
||||||
|
{
|
||||||
|
try { return getter(); }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeControlType(AutomationElement element)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string name = element.Current.ControlType.ProgrammaticName ?? "";
|
||||||
|
return name.StartsWith("ControlType.", StringComparison.Ordinal) ? name.Substring("ControlType.".Length) : name;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object> SafeBounds(AutomationElement element)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Windows.Rect rect = element.Current.BoundingRectangle;
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "x", rect.X },
|
||||||
|
{ "y", rect.Y },
|
||||||
|
{ "width", rect.Width },
|
||||||
|
{ "height", rect.Height }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "x", 0 },
|
||||||
|
{ "y", 0 },
|
||||||
|
{ "width", 0 },
|
||||||
|
{ "height", 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeValue(AutomationElement element)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object pattern;
|
||||||
|
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out pattern))
|
||||||
|
{
|
||||||
|
return ((ValuePattern)pattern).Current.Value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
native/ISphereWinHelper/WindowScanner.cs
Normal file
172
native/ISphereWinHelper/WindowScanner.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ISphereWinHelper
|
||||||
|
{
|
||||||
|
internal static class WindowScanner
|
||||||
|
{
|
||||||
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool IsWindowVisible(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct RECT
|
||||||
|
{
|
||||||
|
public int Left;
|
||||||
|
public int Top;
|
||||||
|
public int Right;
|
||||||
|
public int Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Dictionary<string, object> Scan(Dictionary<string, object> args)
|
||||||
|
{
|
||||||
|
bool includeAllVisible = HelperProtocol.GetBool(args, "include_all_visible", false);
|
||||||
|
var windows = new List<Dictionary<string, object>>();
|
||||||
|
|
||||||
|
EnumWindows(delegate(IntPtr hwnd, IntPtr lParam)
|
||||||
|
{
|
||||||
|
if (!IsWindowVisible(hwnd))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string title = GetWindowTextValue(hwnd);
|
||||||
|
string className = GetClassNameValue(hwnd);
|
||||||
|
uint pidRaw;
|
||||||
|
GetWindowThreadProcessId(hwnd, out pidRaw);
|
||||||
|
int pid = unchecked((int)pidRaw);
|
||||||
|
string processName = GetProcessName(pid);
|
||||||
|
int score = ScoreWindow(processName, title, className);
|
||||||
|
|
||||||
|
if (!includeAllVisible && score <= 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
RECT rect;
|
||||||
|
GetWindowRect(hwnd, out rect);
|
||||||
|
windows.Add(new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "hwnd", FormatHwnd(hwnd) },
|
||||||
|
{ "pid", pid },
|
||||||
|
{ "process_name", processName },
|
||||||
|
{ "title", title },
|
||||||
|
{ "class_name", className },
|
||||||
|
{ "visible", true },
|
||||||
|
{ "bounds", new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "x", rect.Left },
|
||||||
|
{ "y", rect.Top },
|
||||||
|
{ "width", Math.Max(0, rect.Right - rect.Left) },
|
||||||
|
{ "height", Math.Max(0, rect.Bottom - rect.Top) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "score", score }
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
|
||||||
|
windows.Sort(delegate(Dictionary<string, object> left, Dictionary<string, object> right)
|
||||||
|
{
|
||||||
|
return Convert.ToInt32(right["score"]).CompareTo(Convert.ToInt32(left["score"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Dictionary<string, object> { { "windows", windows } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseHwnd(string text, out IntPtr hwnd)
|
||||||
|
{
|
||||||
|
hwnd = IntPtr.Zero;
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalized = text.Trim();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long value;
|
||||||
|
if (normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = Convert.ToInt64(normalized.Substring(2), 16);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
value = Convert.ToInt64(normalized, 10);
|
||||||
|
}
|
||||||
|
hwnd = new IntPtr(value);
|
||||||
|
return hwnd != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatHwnd(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
return "0x" + hwnd.ToInt64().ToString("X");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetWindowTextValue(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(512);
|
||||||
|
GetWindowText(hwnd, buffer, buffer.Capacity);
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetClassNameValue(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(256);
|
||||||
|
GetClassName(hwnd, buffer, buffer.Capacity);
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetProcessName(int pid)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (Process process = Process.GetProcessById(pid))
|
||||||
|
{
|
||||||
|
return process.ProcessName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ScoreWindow(string processName, string title, string className)
|
||||||
|
{
|
||||||
|
int score = 0;
|
||||||
|
string combined = ((processName ?? "") + " " + (title ?? "") + " " + (className ?? "")).ToLowerInvariant();
|
||||||
|
if (combined.Contains("implatformclient")) score += 60;
|
||||||
|
if (combined.Contains("isphere")) score += 50;
|
||||||
|
if (combined.Contains("impp")) score += 30;
|
||||||
|
if (combined.Contains("importal")) score += 20;
|
||||||
|
if ((className ?? "").IndexOf("WindowsForms", StringComparison.OrdinalIgnoreCase) >= 0) score += 5;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
scripts/build-win-helper.ps1
Normal file
83
scripts/build-win-helper.ps1
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
param(
|
||||||
|
[string]$OutputDir = "runs/win-helper",
|
||||||
|
[string]$Configuration = "Release",
|
||||||
|
[ValidateSet("anycpu", "x86", "x64")]
|
||||||
|
[string]$Platform = "anycpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repo = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
|
||||||
|
$sourceDir = Join-Path $repo "native/ISphereWinHelper"
|
||||||
|
$outDirPath = Join-Path $repo $OutputDir
|
||||||
|
$outExe = Join-Path $outDirPath "ISphereWinHelper.exe"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $sourceDir)) {
|
||||||
|
throw "source directory not found: $sourceDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = @(
|
||||||
|
"$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe",
|
||||||
|
"$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe"
|
||||||
|
)
|
||||||
|
$csc = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
|
||||||
|
if (-not $csc) {
|
||||||
|
throw ".NET Framework csc.exe v4.0.30319 not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $outDirPath | Out-Null
|
||||||
|
|
||||||
|
$sources = Get-ChildItem -LiteralPath $sourceDir -Filter '*.cs' | Sort-Object Name | ForEach-Object { $_.FullName }
|
||||||
|
if (-not $sources) {
|
||||||
|
throw "no C# sources found in $sourceDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
$referenceRoots = @(
|
||||||
|
"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8",
|
||||||
|
"C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.7.2",
|
||||||
|
"$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\WPF",
|
||||||
|
"$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\WPF"
|
||||||
|
)
|
||||||
|
function Resolve-Reference([string]$name) {
|
||||||
|
foreach ($root in $referenceRoots) {
|
||||||
|
$candidate = Join-Path $root $name
|
||||||
|
if (Test-Path -LiteralPath $candidate) {
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $name
|
||||||
|
}
|
||||||
|
|
||||||
|
$uiaClient = Resolve-Reference "UIAutomationClient.dll"
|
||||||
|
$uiaTypes = Resolve-Reference "UIAutomationTypes.dll"
|
||||||
|
$windowsBase = Resolve-Reference "WindowsBase.dll"
|
||||||
|
|
||||||
|
& $csc `
|
||||||
|
/nologo `
|
||||||
|
/target:exe `
|
||||||
|
/platform:$Platform `
|
||||||
|
/optimize+ `
|
||||||
|
/warn:0 `
|
||||||
|
/out:$outExe `
|
||||||
|
/reference:System.dll `
|
||||||
|
/reference:System.Core.dll `
|
||||||
|
/reference:System.Web.Extensions.dll `
|
||||||
|
/reference:System.Windows.Forms.dll `
|
||||||
|
/reference:$uiaClient `
|
||||||
|
/reference:$uiaTypes `
|
||||||
|
/reference:$windowsBase `
|
||||||
|
$sources
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "win helper compilation failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [ordered]@{
|
||||||
|
ok = $true
|
||||||
|
configuration = $Configuration
|
||||||
|
platform = $Platform
|
||||||
|
csc = $csc
|
||||||
|
source_dir = $sourceDir
|
||||||
|
output = $outExe
|
||||||
|
}
|
||||||
|
$result | ConvertTo-Json -Depth 4 -Compress
|
||||||
130
tests/test_win_helper.py
Normal file
130
tests/test_win_helper.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
BUILD_SCRIPT = REPO / "scripts" / "build-win-helper.ps1"
|
||||||
|
HELPER_EXE = REPO / "runs" / "win-helper" / "ISphereWinHelper.exe"
|
||||||
|
|
||||||
|
|
||||||
|
def build_helper():
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-File",
|
||||||
|
str(BUILD_SCRIPT),
|
||||||
|
],
|
||||||
|
cwd=REPO,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(result.stdout + result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def invoke_helper(payload):
|
||||||
|
if isinstance(payload, str):
|
||||||
|
stdin = payload
|
||||||
|
else:
|
||||||
|
stdin = json.dumps(payload, ensure_ascii=False)
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(HELPER_EXE), "--json"],
|
||||||
|
cwd=REPO,
|
||||||
|
input=stdin,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
decoded = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise AssertionError(f"stdout was not JSON: {result.stdout!r}; stderr={result.stderr!r}") from exc
|
||||||
|
return result, decoded
|
||||||
|
|
||||||
|
|
||||||
|
class WinHelperBuildTests(unittest.TestCase):
|
||||||
|
def test_build_script_creates_helper_exe(self):
|
||||||
|
if HELPER_EXE.exists():
|
||||||
|
HELPER_EXE.unlink()
|
||||||
|
|
||||||
|
build_helper()
|
||||||
|
|
||||||
|
self.assertTrue(HELPER_EXE.exists(), f"helper exe missing: {HELPER_EXE}")
|
||||||
|
|
||||||
|
|
||||||
|
class WinHelperProtocolTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
build_helper()
|
||||||
|
|
||||||
|
def request(self, op, args=None):
|
||||||
|
result, decoded = invoke_helper(
|
||||||
|
{
|
||||||
|
"protocol": "isphere.helper.v1",
|
||||||
|
"request_id": f"test-{op}",
|
||||||
|
"op": op,
|
||||||
|
"timeout_ms": 5000,
|
||||||
|
"args": args or {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
def test_version_returns_contract_metadata(self):
|
||||||
|
decoded = self.request("version")
|
||||||
|
|
||||||
|
self.assertTrue(decoded["ok"])
|
||||||
|
self.assertEqual(decoded["protocol"], "isphere.helper.v1")
|
||||||
|
self.assertEqual(decoded["request_id"], "test-version")
|
||||||
|
self.assertEqual(decoded["op"], "version")
|
||||||
|
self.assertEqual(decoded["data"]["helper_name"], "ISphereWinHelper")
|
||||||
|
self.assertEqual(decoded["data"]["protocol"], "isphere.helper.v1")
|
||||||
|
|
||||||
|
def test_self_check_reports_desktop_and_uia_status(self):
|
||||||
|
decoded = self.request("self_check")
|
||||||
|
|
||||||
|
self.assertTrue(decoded["ok"])
|
||||||
|
self.assertIn("desktop_access", decoded["data"])
|
||||||
|
self.assertIn("uia_available", decoded["data"])
|
||||||
|
|
||||||
|
def test_scan_windows_returns_json_window_list(self):
|
||||||
|
decoded = self.request("scan_windows", {"include_all_visible": True})
|
||||||
|
|
||||||
|
self.assertTrue(decoded["ok"])
|
||||||
|
self.assertIsInstance(decoded["data"]["windows"], list)
|
||||||
|
|
||||||
|
def test_unsupported_op_returns_structured_error(self):
|
||||||
|
decoded = self.request("does_not_exist")
|
||||||
|
|
||||||
|
self.assertFalse(decoded["ok"])
|
||||||
|
self.assertEqual(decoded["error"]["code"], "UNSUPPORTED_OP")
|
||||||
|
|
||||||
|
def test_invalid_json_returns_structured_error(self):
|
||||||
|
result, decoded = invoke_helper("{not json")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertFalse(decoded["ok"])
|
||||||
|
self.assertEqual(decoded["protocol"], "isphere.helper.v1")
|
||||||
|
self.assertEqual(decoded["error"]["code"], "INVALID_REQUEST")
|
||||||
|
|
||||||
|
def test_dump_uia_with_missing_window_returns_structured_error(self):
|
||||||
|
decoded = self.request("dump_uia", {"hwnd": "0x0"})
|
||||||
|
|
||||||
|
self.assertFalse(decoded["ok"])
|
||||||
|
self.assertIn(decoded["error"]["code"], {"WINDOW_NOT_FOUND", "UIA_DUMP_FAILED"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user