fix(windows-node): show adapter and Origin versions
This commit is contained in:
parent
4a53bc7827
commit
92caa2a2c4
|
|
@ -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 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"}" `
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue