fix(windows-node): show adapter and Origin versions
This commit is contained in:
parent
4a53bc7827
commit
92caa2a2c4
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口与 zcbot 管理页会同时显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。
|
||||
|
||||
- Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node,无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。
|
||||
|
||||
- Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
WORKER_PATH = (
|
||||
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"))
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,10 @@ class StaticVendorTests(unittest.TestCase):
|
|||
self.assertIn("生成 Windows Node 注册码", html)
|
||||
self.assertIn('"/v1/admin/software-node-enrollments"', 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("navigator.clipboard.writeText(value)", admin_js)
|
||||
self.assertIn('apiGet("/v1/admin/software-nodes")', admin_js)
|
||||
|
|
|
|||
|
|
@ -172,6 +172,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker)
|
||||
self.assertIn('["--probe"]', 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(
|
||||
"&& !jobInbox.HasPendingJobs",
|
||||
connection,
|
||||
|
|
|
|||
|
|
@ -166,17 +166,40 @@ function nodeStatusHTML(status) {
|
|||
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() {
|
||||
const rows = softwareNodes.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 capabilityRuntimes = nodeCapabilityRuntimes(runtime);
|
||||
const runtimeParts = [
|
||||
node.os_version || "",
|
||||
runtime.desktop_session === true ? "桌面会话" : "",
|
||||
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);
|
||||
const lastSeen = node.last_seen_at
|
||||
? `<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>`
|
||||
+ `<td>${nodeStatusHTML(node.status)}</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>${lastSeen}</td>`
|
||||
+ `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" `
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
|
|||
return new ProcessCommand(interpreter, [descriptor.EntrypointPath]);
|
||||
}
|
||||
|
||||
private static Process Start(
|
||||
private Process Start(
|
||||
ProcessCommand command, string workingDirectory, IReadOnlyList<string> arguments)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
|
|
@ -214,6 +214,11 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
|
|||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
if (descriptor.Manifest.Runtime == "python")
|
||||
{
|
||||
startInfo.Environment["PYTHONUTF8"] = "1";
|
||||
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
|
||||
}
|
||||
return Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Adapter worker did not start.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,11 +206,12 @@ internal sealed class ConfigurationForm : Form
|
|||
private static string FormatAdapterStatus(INodeAdapter adapter)
|
||||
{
|
||||
var runtime = adapter.DetectRuntime();
|
||||
var version = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
|
||||
? "版本未知"
|
||||
: $"版本 {runtime.SoftwareVersion}";
|
||||
var softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
|
||||
? "Origin 版本未知"
|
||||
: $"Origin {runtime.SoftwareVersion}";
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import hashlib
|
|||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
|
@ -55,6 +56,125 @@ LEGEND_POSITIONS = {
|
|||
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:
|
||||
health = "ready"
|
||||
detail = "Origin COM 与托管 Python 运行时可用"
|
||||
|
|
@ -67,6 +187,7 @@ def _probe() -> int:
|
|||
|
||||
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"):
|
||||
pass
|
||||
software_version = _registered_origin_version(winreg)
|
||||
originpro_version = version("originpro")
|
||||
detail = f"Origin COM 与托管 Python 运行时可用(originpro {originpro_version})"
|
||||
del originpro
|
||||
|
|
|
|||
Loading…
Reference in New Issue