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 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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>`
|
||||||
|
|
|
||||||
|
|
@ -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 运行。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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 };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue