feat(compute): report Origin runtime health
This commit is contained in:
parent
054aaf8e8c
commit
b016f8ee8c
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`
|
||||
|
|
|
|||
|
|
@ -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 运行。
|
||||
|
||||
|
|
|
|||
|
|
@ -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<EnrollOptions, Task>? 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()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
|
|||
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<NodeStatus>?
|
|||
{
|
||||
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<NodeStatus>?
|
|||
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<NodeStatus>?
|
|||
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<NodeStatus>?
|
|||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue