feat(compute): report Origin runtime health

This commit is contained in:
caoqianming 2026-08-13 12:34:27 +08:00
parent 054aaf8e8c
commit b016f8ee8c
11 changed files with 234 additions and 61 deletions

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib import importlib
import unittest import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from uuid import uuid4 from uuid import uuid4
@ -38,6 +39,18 @@ class ComputeNodeSecurityTests(unittest.TestCase):
self.assertEqual(len(digest), 64) self.assertEqual(len(digest), 64)
self.assertNotIn("ZCN-ABC", digest) self.assertNotIn("ZCN-ABC", digest)
def test_websocket_auth_rejection_uses_explicit_application_close_code(self) -> None:
source = (
Path(__file__).resolve().parents[1] / "web" / "routers" / "compute_nodes.py"
).read_text(encoding="utf-8")
rejection = source.split("except (ValueError, ComputeNodeError):", 1)[1].split(
"await node_connections.activate", 1
)[0]
self.assertLess(
rejection.index("await websocket.accept()"), rejection.index("await websocket.close")
)
self.assertIn('code=4003, reason="invalid node credentials"', rejection)
class ComputeNodeConnectionTests(unittest.IsolatedAsyncioTestCase): class ComputeNodeConnectionTests(unittest.IsolatedAsyncioTestCase):
async def test_new_connection_replaces_old_without_removing_new(self) -> None: async def test_new_connection_replaces_old_without_removing_new(self) -> None:

View File

@ -34,6 +34,7 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("生成 Windows Node 注册码", html) self.assertIn("生成 Windows Node 注册码", html)
self.assertIn('"/v1/admin/compute-node-enrollments"', admin_js) self.assertIn('"/v1/admin/compute-node-enrollments"', admin_js)
self.assertIn('capabilities: ["origin.plot@v1"]', admin_js) self.assertIn('capabilities: ["origin.plot@v1"]', admin_js)
self.assertIn('origin.health === "ready"', admin_js)
self.assertIn("ttl_seconds: 600", admin_js) self.assertIn("ttl_seconds: 600", admin_js)
self.assertIn("navigator.clipboard.writeText(value)", admin_js) self.assertIn("navigator.clipboard.writeText(value)", admin_js)
self.assertIn('apiGet("/v1/admin/compute-nodes")', admin_js) self.assertIn('apiGet("/v1/admin/compute-nodes")', admin_js)

View File

@ -56,7 +56,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None: def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn("ClientSize = new Size(880, 720)", form) self.assertIn("ClientSize = new Size(840, 680)", form)
self.assertIn("FormBorderStyle.Sizable", form) self.assertIn("FormBorderStyle.Sizable", form)
self.assertIn("AutoScaleMode.Dpi", form) self.assertIn("AutoScaleMode.Dpi", form)
self.assertIn("AutoScroll = true", form) self.assertIn("AutoScroll = true", form)
@ -65,12 +65,17 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("成功注册一次后立即失效", form) self.assertIn("成功注册一次后立即失效", form)
self.assertIn("CreateCard", form) self.assertIn("CreateCard", form)
self.assertIn("注册并连接", form) self.assertIn("注册并连接", form)
self.assertIn("ContentWidth = 800", form) self.assertIn("ContentWidth = 760", form)
self.assertIn("节点能力", form)
self.assertIn("Origin 绘图", form) self.assertIn("Origin 绘图", form)
self.assertIn("当前 MVP 内置声明该协议,不需要手工配置", form) self.assertIn('CreateCapabilityRow("Origin 绘图", "origin.plot@v1")', form)
self.assertIn('CreateButton("立即重连", 112, primary: true)', form)
self.assertIn("ReconnectRequested?.Invoke()", form)
self.assertIn("registrationCard.Visible = !registered", form) self.assertIn("registrationCard.Visible = !registered", form)
self.assertIn("registeredActionsCard.Visible = registered", form) self.assertIn("reconnect.Visible = registered", form)
self.assertIn("resetIdentity.Visible = registered", form)
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
self.assertIn("form.ReconnectRequested += RestartConnection", tray)
def test_startup_task_is_login_scoped_and_runs_the_fixed_node_executable(self) -> None: def test_startup_task_is_login_scoped_and_runs_the_fixed_node_executable(self) -> None:
script = (ROOT / "install-startup.ps1").read_text(encoding="utf-8") script = (ROOT / "install-startup.ps1").read_text(encoding="utf-8")
@ -80,6 +85,35 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("-RunLevel Limited", script) self.assertIn("-RunLevel Limited", script)
self.assertNotIn("-RunLevel Highest", script) self.assertNotIn("-RunLevel Highest", script)
def test_auth_rejection_is_distinct_from_http_websocket_handshake_failure(self) -> None:
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', connection)
self.assertIn('or HttpStatusCode.Forbidden', connection)
self.assertIn("WebSocket 握手被拒绝,请检查服务端或反向代理", connection)
self.assertIn("catch (NodeEndpointException exception)", connection)
self.assertIn("throw new NodeEndpointException", connection)
self.assertIn("(int?)result.CloseStatus == 4003", connection)
self.assertIn("节点身份已被服务端拒绝", connection)
self.assertNotIn("Node credentials were rejected", connection)
def test_origin_runtime_probe_is_read_only_and_reported(self) -> None:
probe = (PROJECT / "OriginRuntimeProbe.cs").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
self.assertIn('AutomationProgId = @"Origin.ApplicationSI\\CLSID"', probe)
self.assertIn("RegistryHive.LocalMachine", probe)
self.assertIn("RegistryHive.CurrentUser", probe)
self.assertIn('new("OriginPro", version, "0.1.0", health, detail)', probe)
self.assertIn('available_slots = origin.Health == "ready" ? 1 : 0', connection)
self.assertNotIn("CreateInstance", probe)
self.assertNotIn("Process.Start", probe)
for marker in (
"software_version = origin.SoftwareVersion",
"adapter_version = origin.AdapterVersion",
"health = origin.Health",
"detail = origin.Detail",
):
self.assertIn(marker, connection)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -80,7 +80,11 @@ def register_compute_node_routes(app, *, require_admin) -> None:
token = _bearer(websocket.headers.get("authorization")) token = _bearer(websocket.headers.get("authorization"))
identity = await asyncio.to_thread(authenticate_node, node_id, token) identity = await asyncio.to_thread(authenticate_node, node_id, token)
except (ValueError, ComputeNodeError): except (ValueError, ComputeNodeError):
await websocket.close(code=1008, reason="invalid node credentials") # 握手前 close 会被 ASGI 统一表现为 HTTP 403客户端无法区分
# “凭据无效”和“代理/路由没有正确转发 WebSocket”。先升级再用
# 应用关闭码给已持有 Node ID/Token 的节点返回明确诊断。
await websocket.accept()
await websocket.close(code=4003, reason="invalid node credentials")
return return
await websocket.accept() await websocket.accept()
await node_connections.activate(node_id, websocket) await node_connections.activate(node_id, websocket)

View File

@ -169,10 +169,14 @@ function nodeStatusHTML(status) {
function renderWindowsNodes() { function renderWindowsNodes() {
const rows = computeNodes.map(node => { const rows = computeNodes.map(node => {
const runtime = node.runtime || {}; const runtime = node.runtime || {};
const origin = runtime.origin || {};
const originState = origin.health === "ready" ? "Origin 可用" : "Origin 不可用";
const originVersion = origin.software_version ? ` ${origin.software_version}` : "";
const runtimeParts = [ const runtimeParts = [
node.os_version || "", node.os_version || "",
runtime.desktop_session === true ? "桌面会话" : "", runtime.desktop_session === true ? "桌面会话" : "",
runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "", runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "",
runtime.origin ? `${originState}${originVersion}` : "",
].filter(Boolean); ].filter(Boolean);
const lastSeen = node.last_seen_at const lastSeen = node.last_seen_at
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>` ? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`

View File

@ -2,7 +2,7 @@
内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch客户端只使用 .NET Windows Desktop Framework不依赖第三方 NuGet 包。 内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch客户端只使用 .NET Windows Desktop Framework不依赖第三方 NuGet 包。
当前实现托盘状态角标、小型配置窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。尚未实现 `compute_jobs`、Origin Worker、任务目录和产物上传。 当前实现托盘状态角标、小型配置窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连,并只读探测 Origin/OriginPro 安装版本、COM 自动化组件与桌面会话状态。尚未实现 `compute_jobs`、Origin Worker、任务目录和产物上传。
注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。 注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。

View File

@ -2,12 +2,13 @@ namespace Zcbot.WindowsNode;
internal sealed class ConfigurationForm : Form internal sealed class ConfigurationForm : Form
{ {
private const int ContentWidth = 800; private const int ContentWidth = 760;
private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765"); private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765");
private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant()); private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant());
private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true); private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true);
private readonly Button register = CreateButton("注册并连接", 128, primary: true); private readonly Button register = CreateButton("注册并连接", 128, primary: true);
private readonly Button reconnect = CreateButton("立即重连", 112, primary: true);
private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220); private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220);
private readonly CheckBox startAtLogin = new() private readonly CheckBox startAtLogin = new()
{ {
@ -25,18 +26,18 @@ internal sealed class ConfigurationForm : Form
private readonly Label identity = CreateBodyLabel(); private readonly Label identity = CreateBodyLabel();
private readonly Label capabilitySummary = CreateBodyLabel(); private readonly Label capabilitySummary = CreateBodyLabel();
private readonly TableLayoutPanel registrationCard; private readonly TableLayoutPanel registrationCard;
private readonly TableLayoutPanel registeredActionsCard;
private bool changingStartup; private bool changingStartup;
internal event Func<EnrollOptions, Task>? RegisterRequested; internal event Func<EnrollOptions, Task>? RegisterRequested;
internal event Action? ReconnectRequested;
internal event Action? ResetIdentityRequested; internal event Action? ResetIdentityRequested;
internal ConfigurationForm() internal ConfigurationForm()
{ {
Text = "zcbot Windows Node"; Text = "zcbot Windows Node";
AutoScaleMode = AutoScaleMode.Dpi; AutoScaleMode = AutoScaleMode.Dpi;
ClientSize = new Size(880, 720); ClientSize = new Size(840, 680);
MinimumSize = new Size(850, 680); MinimumSize = new Size(820, 640);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9); Font = new Font("Microsoft YaHei UI", 9);
FormBorderStyle = FormBorderStyle.Sizable; FormBorderStyle = FormBorderStyle.Sizable;
@ -62,7 +63,7 @@ internal sealed class ConfigurationForm : Form
AutoSizeMode = AutoSizeMode.GrowAndShrink, AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top, Dock = DockStyle.Top,
ColumnCount = 1, ColumnCount = 1,
RowCount = 6, RowCount = 5,
BackColor = BackColor, BackColor = BackColor,
}; };
page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
@ -85,33 +86,19 @@ internal sealed class ConfigurationForm : Form
heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点")); heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点"));
page.Controls.Add(heading); page.Controls.Add(heading);
var overview = new TableLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Top,
ColumnCount = 2,
Margin = new Padding(0, 0, 0, 14),
};
overview.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
overview.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
var statusCard = CreateCard(); var statusCard = CreateCard();
statusCard.Margin = new Padding(0, 0, 7, 0); statusCard.Controls.Add(CreateSectionTitle("节点"));
statusCard.Controls.Add(CreateSectionTitle("节点状态"));
statusCard.Controls.Add(state); statusCard.Controls.Add(state);
statusCard.Controls.Add(detail); statusCard.Controls.Add(detail);
statusCard.Controls.Add(identity); statusCard.Controls.Add(identity);
overview.Controls.Add(statusCard, 0, 0); statusCard.Controls.Add(CreateDivider());
statusCard.Controls.Add(CreateCapabilityRow("Origin 绘图", "origin.plot@v1"));
var capabilityCard = CreateCard(); statusCard.Controls.Add(capabilitySummary);
capabilityCard.Margin = new Padding(7, 0, 0, 0); var resetActions = CreateActions();
capabilityCard.Controls.Add(CreateSectionTitle("节点能力")); resetActions.Controls.Add(reconnect);
capabilityCard.Controls.Add(CreateCapabilityBadge("Origin 绘图", "origin.plot@v1")); resetActions.Controls.Add(resetIdentity);
capabilityCard.Controls.Add(capabilitySummary); statusCard.Controls.Add(resetActions);
capabilityCard.Controls.Add(CreateHint( page.Controls.Add(statusCard);
"当前 MVP 内置声明该协议,不需要手工配置;它不代表已完成 Origin 安装检测。"));
overview.Controls.Add(capabilityCard, 1, 0);
page.Controls.Add(overview);
registrationCard = CreateCard(); registrationCard = CreateCard();
registrationCard.Controls.Add(CreateSectionTitle("首次注册")); registrationCard.Controls.Add(CreateSectionTitle("首次注册"));
@ -125,15 +112,6 @@ internal sealed class ConfigurationForm : Form
registrationCard.Controls.Add(registerActions); registrationCard.Controls.Add(registerActions);
page.Controls.Add(registrationCard); page.Controls.Add(registrationCard);
registeredActionsCard = CreateCard();
registeredActionsCard.Controls.Add(CreateSectionTitle("节点身份"));
registeredActionsCard.Controls.Add(CreateHint(
"服务地址、节点名称和能力在首次注册时确定。需要修改时,请先在管理后台删除旧节点,再清除本机身份并重新注册。"));
var identityActions = CreateActions();
identityActions.Controls.Add(resetIdentity);
registeredActionsCard.Controls.Add(identityActions);
page.Controls.Add(registeredActionsCard);
var runtimeCard = CreateCard(); var runtimeCard = CreateCard();
runtimeCard.Controls.Add(CreateSectionTitle("运行设置")); runtimeCard.Controls.Add(CreateSectionTitle("运行设置"));
runtimeCard.Controls.Add(startAtLogin); runtimeCard.Controls.Add(startAtLogin);
@ -147,6 +125,7 @@ internal sealed class ConfigurationForm : Form
page.Controls.Add(securityNote); page.Controls.Add(securityNote);
register.Click += async (_, _) => await RegisterAsync(); register.Click += async (_, _) => await RegisterAsync();
reconnect.Click += (_, _) => ReconnectRequested?.Invoke();
resetIdentity.Click += (_, _) => ResetIdentity(); resetIdentity.Click += (_, _) => ResetIdentity();
startAtLogin.CheckedChanged += (_, _) => ToggleStartup(); startAtLogin.CheckedChanged += (_, _) => ToggleStartup();
FormClosing += (_, eventArgs) => FormClosing += (_, eventArgs) =>
@ -185,11 +164,12 @@ internal sealed class ConfigurationForm : Form
: $"节点:{config.NodeName}\nNode ID{config.NodeId}\n服务{config.ServerUrl}"; : $"节点:{config.NodeName}\nNode ID{config.NodeId}\n服务{config.ServerUrl}";
capabilitySummary.Text = config is null capabilitySummary.Text = config is null
? "注册后启用" ? "注册后启用"
: $"已声明 {config.Capabilities.Count} 项能力"; : FormatOriginStatus(OriginRuntimeProbe.Detect());
var registered = config is not null; var registered = config is not null;
registrationCard.Visible = !registered; registrationCard.Visible = !registered;
registeredActionsCard.Visible = registered; reconnect.Visible = registered;
resetIdentity.Visible = registered;
register.Enabled = !registered && status.State != NodeState.Connecting; register.Enabled = !registered && status.State != NodeState.Connecting;
if (registered) if (registered)
{ {
@ -199,6 +179,15 @@ internal sealed class ConfigurationForm : Form
} }
} }
private static string FormatOriginStatus(OriginRuntimeStatus origin)
{
var version = string.IsNullOrWhiteSpace(origin.SoftwareVersion)
? "版本未知"
: $"版本 {origin.SoftwareVersion}";
var state = origin.Health == "ready" ? "可用" : "不可用";
return $"{state} · {version}\n{origin.Detail}";
}
private void ResetIdentity() private void ResetIdentity()
{ {
var answer = MessageBox.Show( var answer = MessageBox.Show(
@ -287,7 +276,7 @@ internal sealed class ConfigurationForm : Form
AutoSize = true, AutoSize = true,
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(71, 85, 105), ForeColor = Color.FromArgb(71, 85, 105),
MaximumSize = new Size(340, 0), MaximumSize = new Size(ContentWidth - 40, 0),
Margin = new Padding(0, 2, 0, 5), Margin = new Padding(0, 2, 0, 5),
}; };
@ -300,25 +289,33 @@ internal sealed class ConfigurationForm : Form
Margin = new Padding(0, 2, 0, 4), Margin = new Padding(0, 2, 0, 4),
}; };
private static Panel CreateCapabilityBadge(string title, string protocol) private static Panel CreateDivider() => new()
{ {
var badge = new Panel Height = 1,
Dock = DockStyle.Top,
BackColor = Color.FromArgb(226, 232, 240),
Margin = new Padding(0, 10, 0, 12),
};
private static Panel CreateCapabilityRow(string title, string protocol)
{
var row = new Panel
{ {
AutoSize = false, AutoSize = false,
Height = 54, Height = 36,
Dock = DockStyle.Top, Dock = DockStyle.Top,
BackColor = Color.FromArgb(239, 246, 255), BackColor = Color.FromArgb(239, 246, 255),
Margin = new Padding(0, 2, 0, 8), Margin = new Padding(0, 0, 0, 5),
Padding = new Padding(12, 7, 12, 7), Padding = new Padding(11, 7, 11, 7),
}; };
badge.Controls.Add(new Label row.Controls.Add(new Label
{ {
Text = $"{title}\n协议{protocol}", Text = $"{title} · {protocol}",
AutoSize = true, AutoSize = true,
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
ForeColor = Color.FromArgb(29, 78, 216), ForeColor = Color.FromArgb(29, 78, 216),
}); });
return badge; return row;
} }
private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new() private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new()

View File

@ -49,6 +49,11 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
Report(NodeState.Offline, "服务端消息无效,等待重连"); Report(NodeState.Offline, "服务端消息无效,等待重连");
Console.Error.WriteLine($"[WARN] Invalid server message: {exception.Message}"); Console.Error.WriteLine($"[WARN] Invalid server message: {exception.Message}");
} }
catch (NodeEndpointException exception)
{
Report(NodeState.Offline, "WebSocket 握手被拒绝,请检查服务端或反向代理");
Console.Error.WriteLine($"[WARN] WebSocket handshake rejected: {exception.Message}");
}
var baseDelay = Backoff[Math.Min(attempt, Backoff.Length - 1)]; var baseDelay = Backoff[Math.Min(attempt, Backoff.Length - 1)];
attempt++; attempt++;
@ -73,12 +78,14 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
{ {
await socket.ConnectAsync(endpoint, cancellationToken); await socket.ConnectAsync(endpoint, cancellationToken);
} }
catch (WebSocketException) when ( catch (WebSocketException exception) when (
socket.HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) socket.HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{ {
Report(NodeState.AuthenticationRequired, "身份失效,需要重新注册"); throw new NodeEndpointException(
throw new NodeConfigurationException( $"WebSocket handshake returned HTTP {(int?)socket.HttpStatusCode}. "
"Node credentials were rejected. Ask an administrator to re-register this node."); + "Verify that the server includes the current Windows Node routes and that "
+ $"the reverse proxy forwards WebSocket Upgrade for {endpoint.AbsolutePath}. "
+ exception.Message);
} }
Console.WriteLine("[OK] Node connected."); Console.WriteLine("[OK] Node connected.");
Report(NodeState.Online, "已连接"); Report(NodeState.Online, "已连接");
@ -122,10 +129,17 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
var result = await socket.ReceiveAsync(buffer, cancellationToken); var result = await socket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == WebSocketMessageType.Close) if (result.MessageType == WebSocketMessageType.Close)
{ {
if ((int?)result.CloseStatus == 4003)
{
throw new NodeConfigurationException(
"节点身份已被服务端拒绝。请在管理后台确认该 Node ID 未被禁用或删除;"
+ "若记录不存在或身份已撤销,请清除本机身份并使用新注册码重新注册。"
+ $" 服务端信息:{result.CloseStatusDescription}");
}
if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation) if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation)
{ {
throw new NodeConfigurationException( throw new NodeConfigurationException(
$"Node credentials or install identity were rejected: {result.CloseStatusDescription}"); $"节点上报被服务端拒绝:{result.CloseStatusDescription}");
} }
return; return;
} }
@ -172,6 +186,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
private object RuntimePayload() private object RuntimePayload()
{ {
var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\"; var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\";
var origin = OriginRuntimeProbe.Detect();
return new return new
{ {
install_id = config.InstallId, install_id = config.InstallId,
@ -179,9 +194,17 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
node_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0", node_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0",
os_version = RuntimeInformation.OSDescription, os_version = RuntimeInformation.OSDescription,
capabilities = config.Capabilities, capabilities = config.Capabilities,
available_slots = 1, available_slots = origin.Health == "ready" ? 1 : 0,
disk_free_bytes = new DriveInfo(root).AvailableFreeSpace, disk_free_bytes = new DriveInfo(root).AvailableFreeSpace,
desktop_session = Environment.UserInteractive, desktop_session = Environment.UserInteractive,
origin = new
{
software = origin.Software,
software_version = origin.SoftwareVersion,
adapter_version = origin.AdapterVersion,
health = origin.Health,
detail = origin.Detail,
},
}; };
} }
} }

View File

@ -46,3 +46,5 @@ internal sealed record NodePaths(string RootDirectory, string ConfigPath)
} }
internal sealed class NodeConfigurationException(string message) : Exception(message); internal sealed class NodeConfigurationException(string message) : Exception(message);
internal sealed class NodeEndpointException(string message) : Exception(message);

View File

@ -0,0 +1,94 @@
using Microsoft.Win32;
using System.Security;
namespace Zcbot.WindowsNode;
internal sealed record OriginRuntimeStatus(
string Software,
string? SoftwareVersion,
string AdapterVersion,
string Health,
string Detail);
internal static class OriginRuntimeProbe
{
private static readonly Lazy<OriginRuntimeStatus> Current = new(DetectCore);
private const string AutomationProgId = @"Origin.ApplicationSI\CLSID";
internal static OriginRuntimeStatus Detect() => Current.Value;
private static OriginRuntimeStatus DetectCore()
{
try
{
var version = FindInstalledVersion();
using var automationKey = Registry.ClassesRoot.OpenSubKey(AutomationProgId);
var automationRegistered = automationKey is not null;
if (version is null && !automationRegistered)
{
return Status(null, "unavailable", "未检测到 Origin/OriginPro 安装");
}
if (!automationRegistered)
{
return Status(version, "unavailable", "已检测到 Origin但 COM 自动化组件未注册");
}
if (!Environment.UserInteractive)
{
return Status(version, "unavailable", "Origin 需要交互式 Windows 桌面会话");
}
return Status(version, "ready", "Origin COM 自动化组件可用");
}
catch (Exception exception) when (
exception is SecurityException or UnauthorizedAccessException or IOException)
{
return Status(null, "unavailable", $"Origin 运行时探测失败:{exception.Message}");
}
}
private static OriginRuntimeStatus Status(string? version, string health, string detail) =>
new("OriginPro", version, "0.1.0", health, detail);
private static string? FindInstalledVersion()
{
var candidates = new List<string>();
foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser })
{
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
using var uninstall = baseKey.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall");
if (uninstall is null)
{
continue;
}
foreach (var keyName in uninstall.GetSubKeyNames())
{
using var product = uninstall.OpenSubKey(keyName);
var name = product?.GetValue("DisplayName") as string;
var publisher = product?.GetValue("Publisher") as string;
if (!IsOriginProduct(name, publisher))
{
continue;
}
var version = product?.GetValue("DisplayVersion") as string;
if (!string.IsNullOrWhiteSpace(version))
{
candidates.Add(version.Trim());
}
}
}
}
return candidates.OrderByDescending(ParseVersion).ThenByDescending(x => x).FirstOrDefault();
}
private static bool IsOriginProduct(string? name, string? publisher) =>
!string.IsNullOrWhiteSpace(name)
&& (name.Equals("Origin", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Origin ", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("OriginPro", StringComparison.OrdinalIgnoreCase))
&& (publisher?.Contains("OriginLab", StringComparison.OrdinalIgnoreCase) ?? false);
private static Version ParseVersion(string value) =>
Version.TryParse(value, out var version) ? version : new Version(0, 0);
}

View File

@ -16,6 +16,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
this.store = store; this.store = store;
form = new ConfigurationForm(); form = new ConfigurationForm();
form.RegisterRequested += RegisterAsync; form.RegisterRequested += RegisterAsync;
form.ReconnectRequested += RestartConnection;
form.ResetIdentityRequested += ResetIdentity; form.ResetIdentityRequested += ResetIdentity;
statusItem = new ToolStripMenuItem("尚未注册") { Enabled = false }; statusItem = new ToolStripMenuItem("尚未注册") { Enabled = false };