diff --git a/tests/test_compute_nodes.py b/tests/test_compute_nodes.py
index 4c71d17..e4cc181 100644
--- a/tests/test_compute_nodes.py
+++ b/tests/test_compute_nodes.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib
import unittest
+from pathlib import Path
from unittest.mock import AsyncMock, patch
from uuid import uuid4
@@ -38,6 +39,18 @@ class ComputeNodeSecurityTests(unittest.TestCase):
self.assertEqual(len(digest), 64)
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):
async def test_new_connection_replaces_old_without_removing_new(self) -> None:
diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py
index 0cd6716..77dc763 100644
--- a/tests/test_static_vendor.py
+++ b/tests/test_static_vendor.py
@@ -34,6 +34,7 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("生成 Windows Node 注册码", html)
self.assertIn('"/v1/admin/compute-node-enrollments"', 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("navigator.clipboard.writeText(value)", admin_js)
self.assertIn('apiGet("/v1/admin/compute-nodes")', admin_js)
diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py
index d939c13..5c0f638 100644
--- a/tests/test_windows_node_source.py
+++ b/tests/test_windows_node_source.py
@@ -56,7 +56,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
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("AutoScaleMode.Dpi", form)
self.assertIn("AutoScroll = true", form)
@@ -65,12 +65,17 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("成功注册一次后立即失效", form)
self.assertIn("CreateCard", form)
self.assertIn("注册并连接", form)
- self.assertIn("ContentWidth = 800", form)
- self.assertIn("节点能力", form)
+ self.assertIn("ContentWidth = 760", 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("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:
script = (ROOT / "install-startup.ps1").read_text(encoding="utf-8")
@@ -80,6 +85,35 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("-RunLevel Limited", 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__":
unittest.main()
diff --git a/web/routers/compute_nodes.py b/web/routers/compute_nodes.py
index 6bb0c5c..b26c17b 100644
--- a/web/routers/compute_nodes.py
+++ b/web/routers/compute_nodes.py
@@ -80,7 +80,11 @@ def register_compute_node_routes(app, *, require_admin) -> None:
token = _bearer(websocket.headers.get("authorization"))
identity = await asyncio.to_thread(authenticate_node, node_id, token)
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
await websocket.accept()
await node_connections.activate(node_id, websocket)
diff --git a/web/static/js/admin.js b/web/static/js/admin.js
index 45999e5..d80b3ef 100644
--- a/web/static/js/admin.js
+++ b/web/static/js/admin.js
@@ -169,10 +169,14 @@ function nodeStatusHTML(status) {
function renderWindowsNodes() {
const rows = computeNodes.map(node => {
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 = [
node.os_version || "",
runtime.desktop_session === true ? "桌面会话" : "",
runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "",
+ runtime.origin ? `${originState}${originVersion}` : "",
].filter(Boolean);
const lastSeen = node.last_seen_at
? `${escapeHtml(fmtTimeAgo(node.last_seen_at))}`
diff --git a/windows-node/README.md b/windows-node/README.md
index e4ca5b8..8bf6195 100644
--- a/windows-node/README.md
+++ b/windows-node/README.md
@@ -2,7 +2,7 @@
内网 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 运行。
diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs
index 365d241..7df4525 100644
--- a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs
+++ b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs
@@ -2,12 +2,13 @@ namespace Zcbot.WindowsNode;
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 nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant());
private readonly TextBox enrollmentCode = CreateTextBox(usePassword: 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 CheckBox startAtLogin = new()
{
@@ -25,18 +26,18 @@ internal sealed class ConfigurationForm : Form
private readonly Label identity = CreateBodyLabel();
private readonly Label capabilitySummary = CreateBodyLabel();
private readonly TableLayoutPanel registrationCard;
- private readonly TableLayoutPanel registeredActionsCard;
private bool changingStartup;
internal event Func? RegisterRequested;
+ internal event Action? ReconnectRequested;
internal event Action? ResetIdentityRequested;
internal ConfigurationForm()
{
Text = "zcbot Windows Node";
AutoScaleMode = AutoScaleMode.Dpi;
- ClientSize = new Size(880, 720);
- MinimumSize = new Size(850, 680);
+ ClientSize = new Size(840, 680);
+ MinimumSize = new Size(820, 640);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9);
FormBorderStyle = FormBorderStyle.Sizable;
@@ -62,7 +63,7 @@ internal sealed class ConfigurationForm : Form
AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top,
ColumnCount = 1,
- RowCount = 6,
+ RowCount = 5,
BackColor = BackColor,
};
page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
@@ -85,33 +86,19 @@ internal sealed class ConfigurationForm : Form
heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点"));
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();
- statusCard.Margin = new Padding(0, 0, 7, 0);
- statusCard.Controls.Add(CreateSectionTitle("节点状态"));
+ statusCard.Controls.Add(CreateSectionTitle("节点"));
statusCard.Controls.Add(state);
statusCard.Controls.Add(detail);
statusCard.Controls.Add(identity);
- overview.Controls.Add(statusCard, 0, 0);
-
- var capabilityCard = CreateCard();
- capabilityCard.Margin = new Padding(7, 0, 0, 0);
- capabilityCard.Controls.Add(CreateSectionTitle("节点能力"));
- capabilityCard.Controls.Add(CreateCapabilityBadge("Origin 绘图", "origin.plot@v1"));
- capabilityCard.Controls.Add(capabilitySummary);
- capabilityCard.Controls.Add(CreateHint(
- "当前 MVP 内置声明该协议,不需要手工配置;它不代表已完成 Origin 安装检测。"));
- overview.Controls.Add(capabilityCard, 1, 0);
- page.Controls.Add(overview);
+ statusCard.Controls.Add(CreateDivider());
+ statusCard.Controls.Add(CreateCapabilityRow("Origin 绘图", "origin.plot@v1"));
+ statusCard.Controls.Add(capabilitySummary);
+ var resetActions = CreateActions();
+ resetActions.Controls.Add(reconnect);
+ resetActions.Controls.Add(resetIdentity);
+ statusCard.Controls.Add(resetActions);
+ page.Controls.Add(statusCard);
registrationCard = CreateCard();
registrationCard.Controls.Add(CreateSectionTitle("首次注册"));
@@ -125,15 +112,6 @@ internal sealed class ConfigurationForm : Form
registrationCard.Controls.Add(registerActions);
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();
runtimeCard.Controls.Add(CreateSectionTitle("运行设置"));
runtimeCard.Controls.Add(startAtLogin);
@@ -147,6 +125,7 @@ internal sealed class ConfigurationForm : Form
page.Controls.Add(securityNote);
register.Click += async (_, _) => await RegisterAsync();
+ reconnect.Click += (_, _) => ReconnectRequested?.Invoke();
resetIdentity.Click += (_, _) => ResetIdentity();
startAtLogin.CheckedChanged += (_, _) => ToggleStartup();
FormClosing += (_, eventArgs) =>
@@ -185,11 +164,12 @@ internal sealed class ConfigurationForm : Form
: $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}";
capabilitySummary.Text = config is null
? "注册后启用"
- : $"已声明 {config.Capabilities.Count} 项能力";
+ : FormatOriginStatus(OriginRuntimeProbe.Detect());
var registered = config is not null;
registrationCard.Visible = !registered;
- registeredActionsCard.Visible = registered;
+ reconnect.Visible = registered;
+ resetIdentity.Visible = registered;
register.Enabled = !registered && status.State != NodeState.Connecting;
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()
{
var answer = MessageBox.Show(
@@ -287,7 +276,7 @@ internal sealed class ConfigurationForm : Form
AutoSize = true,
Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(71, 85, 105),
- MaximumSize = new Size(340, 0),
+ MaximumSize = new Size(ContentWidth - 40, 0),
Margin = new Padding(0, 2, 0, 5),
};
@@ -300,25 +289,33 @@ internal sealed class ConfigurationForm : Form
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,
- Height = 54,
+ Height = 36,
Dock = DockStyle.Top,
BackColor = Color.FromArgb(239, 246, 255),
- Margin = new Padding(0, 2, 0, 8),
- Padding = new Padding(12, 7, 12, 7),
+ Margin = new Padding(0, 0, 0, 5),
+ 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,
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
ForeColor = Color.FromArgb(29, 78, 216),
});
- return badge;
+ return row;
}
private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new()
diff --git a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs
index 23cc7c0..0f9885c 100644
--- a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs
+++ b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs
@@ -49,6 +49,11 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action?
Report(NodeState.Offline, "服务端消息无效,等待重连");
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)];
attempt++;
@@ -73,12 +78,14 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action?
{
await socket.ConnectAsync(endpoint, cancellationToken);
}
- catch (WebSocketException) when (
+ catch (WebSocketException exception) when (
socket.HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
- Report(NodeState.AuthenticationRequired, "身份失效,需要重新注册");
- throw new NodeConfigurationException(
- "Node credentials were rejected. Ask an administrator to re-register this node.");
+ throw new NodeEndpointException(
+ $"WebSocket handshake returned HTTP {(int?)socket.HttpStatusCode}. "
+ + "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.");
Report(NodeState.Online, "已连接");
@@ -122,10 +129,17 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action?
var result = await socket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
+ if ((int?)result.CloseStatus == 4003)
+ {
+ throw new NodeConfigurationException(
+ "节点身份已被服务端拒绝。请在管理后台确认该 Node ID 未被禁用或删除;"
+ + "若记录不存在或身份已撤销,请清除本机身份并使用新注册码重新注册。"
+ + $" 服务端信息:{result.CloseStatusDescription}");
+ }
if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation)
{
throw new NodeConfigurationException(
- $"Node credentials or install identity were rejected: {result.CloseStatusDescription}");
+ $"节点上报被服务端拒绝:{result.CloseStatusDescription}");
}
return;
}
@@ -172,6 +186,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action?
private object RuntimePayload()
{
var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\";
+ var origin = OriginRuntimeProbe.Detect();
return new
{
install_id = config.InstallId,
@@ -179,9 +194,17 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action?
node_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0",
os_version = RuntimeInformation.OSDescription,
capabilities = config.Capabilities,
- available_slots = 1,
+ available_slots = origin.Health == "ready" ? 1 : 0,
disk_free_bytes = new DriveInfo(root).AvailableFreeSpace,
desktop_session = Environment.UserInteractive,
+ origin = new
+ {
+ software = origin.Software,
+ software_version = origin.SoftwareVersion,
+ adapter_version = origin.AdapterVersion,
+ health = origin.Health,
+ detail = origin.Detail,
+ },
};
}
}
diff --git a/windows-node/Zcbot.WindowsNode/NodeModels.cs b/windows-node/Zcbot.WindowsNode/NodeModels.cs
index c737e8e..98361fd 100644
--- a/windows-node/Zcbot.WindowsNode/NodeModels.cs
+++ b/windows-node/Zcbot.WindowsNode/NodeModels.cs
@@ -46,3 +46,5 @@ internal sealed record NodePaths(string RootDirectory, string ConfigPath)
}
internal sealed class NodeConfigurationException(string message) : Exception(message);
+
+internal sealed class NodeEndpointException(string message) : Exception(message);
diff --git a/windows-node/Zcbot.WindowsNode/OriginRuntimeProbe.cs b/windows-node/Zcbot.WindowsNode/OriginRuntimeProbe.cs
new file mode 100644
index 0000000..5ca6e82
--- /dev/null
+++ b/windows-node/Zcbot.WindowsNode/OriginRuntimeProbe.cs
@@ -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 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();
+ 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);
+}
diff --git a/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs
index e2f9139..2289a1e 100644
--- a/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs
+++ b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs
@@ -16,6 +16,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
this.store = store;
form = new ConfigurationForm();
form.RegisterRequested += RegisterAsync;
+ form.ReconnectRequested += RestartConnection;
form.ResetIdentityRequested += ResetIdentity;
statusItem = new ToolStripMenuItem("尚未注册") { Enabled = false };