fix(windows-node): show adapter and Origin versions

This commit is contained in:
caoqianming 2026-08-14 16:00:59 +08:00
parent 4a53bc7827
commit 92caa2a2c4
8 changed files with 208 additions and 11 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口与 zcbot 管理页会同时显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。
- Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。 - Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。
- Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。 - Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。

View File

@ -5,6 +5,7 @@ import json
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
WORKER_PATH = ( WORKER_PATH = (
Path(__file__).resolve().parents[1] / "windows-node" / "origin-worker" / "worker.py" Path(__file__).resolve().parents[1] / "windows-node" / "origin-worker" / "worker.py"
@ -35,6 +36,42 @@ class OriginWorkerUnitTests(unittest.TestCase):
manifest = json.loads(ADAPTER_MANIFEST_PATH.read_text(encoding="utf-8")) manifest = json.loads(ADAPTER_MANIFEST_PATH.read_text(encoding="utf-8"))
self.assertEqual(worker.ADAPTER_VERSION, manifest["adapter_version"]) self.assertEqual(worker.ADAPTER_VERSION, manifest["adapter_version"])
def test_registered_origin_version_comes_from_com_server_executable(self) -> None:
class FakeKey:
def __init__(self, path: str):
self.path = path
def __enter__(self):
return self
def __exit__(self, *_):
return None
class FakeRegistry:
HKEY_CLASSES_ROOT = object()
values = {
r"Origin.ApplicationSI\CLSID": "{origin-clsid}",
r"CLSID\{origin-clsid}\LocalServer32": (
r'"C:\Program Files\OriginLab\Origin2025\Origin64.exe" /Automation'
),
}
@classmethod
def OpenKey(cls, _root, path):
return FakeKey(path)
@classmethod
def QueryValueEx(cls, key, _name):
return cls.values[key.path], 1
with patch.object(worker, "_windows_file_version", return_value="2025") as read:
self.assertEqual(worker._registered_origin_version(FakeRegistry), "2025")
read.assert_called_once_with(
Path(r"C:\Program Files\OriginLab\Origin2025\Origin64.exe")
)
FakeRegistry.values[r"CLSID\{origin-clsid}\LocalServer32"] = "invalid"
self.assertIsNone(worker._registered_origin_version(FakeRegistry))
def test_csv_and_json_inputs_are_read_without_origin(self) -> None: def test_csv_and_json_inputs_are_read_without_origin(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)

View File

@ -63,7 +63,10 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("生成 Windows Node 注册码", html) self.assertIn("生成 Windows Node 注册码", html)
self.assertIn('"/v1/admin/software-node-enrollments"', admin_js) self.assertIn('"/v1/admin/software-node-enrollments"', admin_js)
self.assertIn('capabilities: ["origin.plot@v2"]', admin_js) self.assertIn('capabilities: ["origin.plot@v2"]', admin_js)
self.assertIn('origin.health === "ready"', admin_js) self.assertIn("runtime.capability_runtime", admin_js)
self.assertIn("item.adapter_version", admin_js)
self.assertIn("item.software_version", admin_js)
self.assertIn('runtime.origin ? [["origin.plot@v2", runtime.origin]]', 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/software-nodes")', admin_js) self.assertIn('apiGet("/v1/admin/software-nodes")', admin_js)

View File

@ -172,6 +172,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker) self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker)
self.assertIn('["--probe"]', runner) self.assertIn('["--probe"]', runner)
self.assertIn('root.GetProperty("adapter_version")', runner) self.assertIn('root.GetProperty("adapter_version")', runner)
self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner)
self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner)
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn('Adapter {adapter.AdapterVersion}', form)
self.assertIn('Origin 版本未知', form)
self.assertIn( self.assertIn(
"&& !jobInbox.HasPendingJobs", "&& !jobInbox.HasPendingJobs",
connection, connection,

View File

@ -166,17 +166,40 @@ function nodeStatusHTML(status) {
return `<span class="node-status ${value}">${labels[value]}</span>`; return `<span class="node-status ${value}">${labels[value]}</span>`;
} }
function nodeCapabilityRuntimes(runtime) {
const entries = Object.entries(runtime.capability_runtime || {})
.filter(([, value]) => value && typeof value === "object");
if (entries.length) return entries;
return runtime.origin ? [["origin.plot@v2", runtime.origin]] : [];
}
function nodeSoftwareName(capability, item) {
if (item.software === "OriginPro" || capability.startsWith("origin.")) return "Origin";
return item.software || capability;
}
function renderWindowsNodes() { function renderWindowsNodes() {
const rows = softwareNodes.map(node => { const rows = softwareNodes.map(node => {
const runtime = node.runtime || {}; const runtime = node.runtime || {};
const origin = runtime.origin || {}; const capabilityRuntimes = nodeCapabilityRuntimes(runtime);
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}` : "", ...capabilityRuntimes.map(([capability, item]) => {
const name = nodeSoftwareName(capability, item);
return `${name}${item.health === "ready" ? "可用" : "不可用"}`;
}),
].filter(Boolean);
const versionParts = [
node.node_version ? `Node ${node.node_version}` : "",
...capabilityRuntimes.flatMap(([capability, item]) => {
const name = nodeSoftwareName(capability, item);
return [
item.adapter_version ? `${name} Adapter ${item.adapter_version}` : "",
item.software_version ? `${name} ${item.software_version}` : "",
];
}),
].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>`
@ -187,7 +210,7 @@ function renderWindowsNodes() {
+ `<div class="node-id">${escapeHtml(node.node_id)}</div></td>` + `<div class="node-id">${escapeHtml(node.node_id)}</div></td>`
+ `<td>${nodeStatusHTML(node.status)}</td>` + `<td>${nodeStatusHTML(node.status)}</td>`
+ `<td class="node-runtime">${escapeHtml(runtimeParts.join(" · ") || "—")}</td>` + `<td class="node-runtime">${escapeHtml(runtimeParts.join(" · ") || "—")}</td>`
+ `<td>${escapeHtml(node.node_version || "—")}</td>` + `<td class="node-runtime">${escapeHtml(versionParts.join(" · ") || "—")}</td>`
+ `<td>${(node.capabilities || []).map(x => `<span class="chip">${escapeHtml(x)}</span>`).join(" ") || "—"}</td>` + `<td>${(node.capabilities || []).map(x => `<span class="chip">${escapeHtml(x)}</span>`).join(" ") || "—"}</td>`
+ `<td>${lastSeen}</td>` + `<td>${lastSeen}</td>`
+ `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" ` + `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" `

View File

@ -196,7 +196,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
return new ProcessCommand(interpreter, [descriptor.EntrypointPath]); return new ProcessCommand(interpreter, [descriptor.EntrypointPath]);
} }
private static Process Start( private Process Start(
ProcessCommand command, string workingDirectory, IReadOnlyList<string> arguments) ProcessCommand command, string workingDirectory, IReadOnlyList<string> arguments)
{ {
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
@ -214,6 +214,11 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
{ {
startInfo.ArgumentList.Add(argument); startInfo.ArgumentList.Add(argument);
} }
if (descriptor.Manifest.Runtime == "python")
{
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
}
return Process.Start(startInfo) return Process.Start(startInfo)
?? throw new InvalidOperationException("Adapter worker did not start."); ?? throw new InvalidOperationException("Adapter worker did not start.");
} }

View File

@ -206,11 +206,12 @@ internal sealed class ConfigurationForm : Form
private static string FormatAdapterStatus(INodeAdapter adapter) private static string FormatAdapterStatus(INodeAdapter adapter)
{ {
var runtime = adapter.DetectRuntime(); var runtime = adapter.DetectRuntime();
var version = string.IsNullOrWhiteSpace(runtime.SoftwareVersion) var softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
? "版本未知" ? "Origin 版本未知"
: $"版本 {runtime.SoftwareVersion}"; : $"Origin {runtime.SoftwareVersion}";
var state = runtime.Health == "ready" ? "可用" : "不可用"; var state = runtime.Health == "ready" ? "可用" : "不可用";
return $"{adapter.DisplayName}{state} · {version}\n{runtime.Detail}"; return $"{adapter.DisplayName}{state} · Adapter {adapter.AdapterVersion} · {softwareVersion}\n"
+ runtime.Detail;
} }
private void RefreshJobs() private void RefreshJobs()

View File

@ -11,6 +11,7 @@ import hashlib
import json import json
import math import math
import os import os
import re
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
@ -55,6 +56,125 @@ LEGEND_POSITIONS = {
ADAPTER_VERSION = "0.5.0" ADAPTER_VERSION = "0.5.0"
def _server_executable(command: str) -> Path:
value = os.path.expandvars(command.strip())
match = re.match(r'^\s*"([^"]+\.exe)"|^\s*(.+?\.exe)(?:\s|$)', value, re.IGNORECASE)
if match is None:
raise ValueError("Origin COM server executable is invalid")
return Path(match.group(1) or match.group(2))
def _windows_file_version(path: Path) -> str:
import ctypes
from ctypes import wintypes
class FixedFileInfo(ctypes.Structure):
_fields_ = [
("signature", wintypes.DWORD),
("structure_version", wintypes.DWORD),
("file_version_ms", wintypes.DWORD),
("file_version_ls", wintypes.DWORD),
("product_version_ms", wintypes.DWORD),
("product_version_ls", wintypes.DWORD),
("file_flags_mask", wintypes.DWORD),
("file_flags", wintypes.DWORD),
("file_os", wintypes.DWORD),
("file_type", wintypes.DWORD),
("file_subtype", wintypes.DWORD),
("file_date_ms", wintypes.DWORD),
("file_date_ls", wintypes.DWORD),
]
class LanguageAndCodePage(ctypes.Structure):
_fields_ = [("language", wintypes.WORD), ("code_page", wintypes.WORD)]
version_api = ctypes.WinDLL("version", use_last_error=True)
version_api.GetFileVersionInfoSizeW.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)
]
version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD
version_api.GetFileVersionInfoW.argtypes = [
wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p
]
version_api.GetFileVersionInfoW.restype = wintypes.BOOL
version_api.VerQueryValueW.argtypes = [
ctypes.c_void_p,
wintypes.LPCWSTR,
ctypes.POINTER(ctypes.c_void_p),
ctypes.POINTER(wintypes.UINT),
]
version_api.VerQueryValueW.restype = wintypes.BOOL
ignored_handle = wintypes.DWORD()
size = version_api.GetFileVersionInfoSizeW(str(path), ctypes.byref(ignored_handle))
if size == 0:
raise ctypes.WinError(ctypes.get_last_error())
buffer = ctypes.create_string_buffer(size)
if not version_api.GetFileVersionInfoW(str(path), 0, size, buffer):
raise ctypes.WinError(ctypes.get_last_error())
value = ctypes.c_void_p()
value_size = wintypes.UINT()
if not version_api.VerQueryValueW(
buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)
):
raise ctypes.WinError(ctypes.get_last_error())
info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents
translation = ctypes.c_void_p()
translation_size = wintypes.UINT()
if version_api.VerQueryValueW(
buffer,
r"\VarFileInfo\Translation",
ctypes.byref(translation),
ctypes.byref(translation_size),
):
count = translation_size.value // ctypes.sizeof(LanguageAndCodePage)
translations = ctypes.cast(
translation, ctypes.POINTER(LanguageAndCodePage * count)
).contents
for item in translations:
prefix = rf"\StringFileInfo\{item.language:04x}{item.code_page:04x}"
strings = {}
for name in ("FileDescription", "ProductVersion"):
string_value = ctypes.c_void_p()
string_size = wintypes.UINT()
if version_api.VerQueryValueW(
buffer,
f"{prefix}\\{name}",
ctypes.byref(string_value),
ctypes.byref(string_size),
):
strings[name] = ctypes.wstring_at(string_value).strip()
description = strings.get("FileDescription", "")
match = re.fullmatch(r"Origin(?:Pro)?\s+(.+)", description)
if match:
return match.group(1)
if strings.get("ProductVersion"):
return strings["ProductVersion"]
parts = (
info.file_version_ms >> 16,
info.file_version_ms & 0xFFFF,
info.file_version_ls >> 16,
info.file_version_ls & 0xFFFF,
)
if not any(parts):
raise ValueError("Origin executable has no file version")
return ".".join(str(part) for part in parts)
def _registered_origin_version(winreg: Any) -> str | None:
try:
with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"
) as clsid_key:
clsid = winreg.QueryValueEx(clsid_key, None)[0]
with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32"
) as server_key:
command = winreg.QueryValueEx(server_key, None)[0]
return _windows_file_version(_server_executable(str(command)))
except (OSError, ValueError):
return None
def _probe() -> int: def _probe() -> int:
health = "ready" health = "ready"
detail = "Origin COM 与托管 Python 运行时可用" detail = "Origin COM 与托管 Python 运行时可用"
@ -67,6 +187,7 @@ def _probe() -> int:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"): with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"):
pass pass
software_version = _registered_origin_version(winreg)
originpro_version = version("originpro") originpro_version = version("originpro")
detail = f"Origin COM 与托管 Python 运行时可用originpro {originpro_version}" detail = f"Origin COM 与托管 Python 运行时可用originpro {originpro_version}"
del originpro del originpro