From b016f8ee8c39ec1313c9480a125f12c3a26c533a Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 13 Aug 2026 12:34:27 +0800 Subject: [PATCH] feat(compute): report Origin runtime health --- tests/test_compute_nodes.py | 13 +++ tests/test_static_vendor.py | 1 + tests/test_windows_node_source.py | 44 ++++++++- web/routers/compute_nodes.py | 6 +- web/static/js/admin.js | 4 + windows-node/README.md | 2 +- .../Zcbot.WindowsNode/ConfigurationForm.cs | 93 +++++++++--------- .../Zcbot.WindowsNode/NodeConnectionLoop.cs | 35 +++++-- windows-node/Zcbot.WindowsNode/NodeModels.cs | 2 + .../Zcbot.WindowsNode/OriginRuntimeProbe.cs | 94 +++++++++++++++++++ .../TrayApplicationContext.cs | 1 + 11 files changed, 234 insertions(+), 61 deletions(-) create mode 100644 windows-node/Zcbot.WindowsNode/OriginRuntimeProbe.cs 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 };