feat(node): manage software setup in UI
This commit is contained in:
parent
28347b940f
commit
b830dbfeec
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。
|
||||
|
||||
- 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。
|
||||
|
||||
- Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
|||
|
||||
云端控制面使用独立的 `software_node_enrollments` 与 `software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token,数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。
|
||||
|
||||
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本;Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter;发布过程把对应源码合同复制进各 adapter 目录,Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元,manifest、合同、Worker 和依赖必须整体更新,避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job;具体软件探测和二次语义校验属于 Worker。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并以 `--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。
|
||||
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本;Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换,写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter;发布过程把对应源码合同复制进各 adapter 目录,Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元,manifest、合同、Worker 和依赖必须整体更新,避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 1–1440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,允许管理员自动检测或选择预定义应用位置,并只按内置软件清单及固定 requirements 单独构建受管 runtime;UI 不接受任意安装命令,任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 Worker,Worker 的 `--probe` 继续承担最终软件版本、SDK/COM 和执行门健康校验。Worker 可以是受管 Python 脚本或独立 EXE,进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接;Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。
|
||||
|
||||
扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE;增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schema;Node 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 adapter。服务端用本次上报中自己具有共享契约的 capability 原子替换节点能力列表,未知能力不进入调度;管理端只读展示该结果,不保存人工能力选择。现有节点因此不需要清身份或重新注册,升级 Node 后重启一次即可在下一次心跳同步。当前选择明确的运维边界:zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node,整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE,也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 Job 协议。
|
||||
|
||||
|
|
@ -478,7 +478,7 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠
|
|||
|
||||
Blender 首个入口为 `blender.scene.author@v1`,以设备目标状态而非 Python、逐步按键或任意节点脚本表达三维创作。adapter 0.1.0 的首个 `rotary_kiln` feature 接受长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、窑头尾罩、平台、物料等受控参数,由固定 `bpy` Worker 创建模型、相机、灯光和材质;输出 `.blend`、PNG 预览、场景清单、溯源及可选 GLB。受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec` 拉起已安装的 Blender,实际 `bpy` 在 Blender 自带 Python 内执行;请求不能提供脚本、命令行、本机路径、URL 或插件。`.blend` 作为 Workspace state,续作先验证并打开上一版工程,再以完整目标状态生成新版本;后续修改器、节点、动画和高质量渲染继续作为同一业务方向的版本化 feature 增量开放,不把首个设备构造器固化成 Blender 的能力上限。
|
||||
|
||||
专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `<data_root>/workspaces/<workspace_id>/`,不再按 user 建上层目录。`data_root` 默认是 `%ProgramData%/Zcbot/WindowsNode`,专用运行账号可在 UI 中选择其他本机固定磁盘,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 拥有最高优先级并使 UI 只读;账号级选择保存在 HKCU,避免把根目录定位信息放入其自身。迁移先停止调度并等待本机任务及导出收尾,在目标同级临时目录复制并逐文件校验后才原子切换,旧目录不自动删除。这样用户统计集中在数据库完成,节点只管理实际落到本机的 workspace,也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node;后续 Job 必须以当前 head 为 source、串行回到同一节点,节点离线时等待而不复制工程或静默改派。
|
||||
专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `<data_root>/workspaces/<workspace_id>/`,不再按 user 建上层目录。全新节点的 `data_root` 默认是当前专用账号的 `%LocalAppData%/Zcbot/WindowsNode`,以便无需提权即可从 UI 完成首次配置;升级节点若已有 `%ProgramData%/Zcbot/WindowsNode` 则继续原位使用,不做隐式迁移。专用运行账号可在 UI 中选择其他本机固定磁盘,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 拥有最高优先级并使 UI 只读;账号级选择保存在 HKCU,避免把根目录定位信息放入其自身。迁移先停止调度并等待本机任务及导出收尾,在目标同级临时目录复制并逐文件校验后才原子切换,旧目录不自动删除。这样用户统计集中在数据库完成,节点只管理实际落到本机的 workspace,也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node;后续 Job 必须以当前 head 为 source、串行回到同一节点,节点离线时等待而不复制工程或静默改派。
|
||||
|
||||
Node 对每个 Workspace 只保留 `current` 和 `rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`,Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程,物理空间上限约为当前工程加一份回滚工程;Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU 为 workspace state;ANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-08-20(对话草稿暂存与回答中排队发送,未发版)
|
||||
最后更新:2026-08-20(Windows Node 专业软件独立配置与 runtime 安装,未发版)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,6 +20,8 @@
|
|||
---
|
||||
## 已完成关键能力
|
||||
|
||||
- **08-20 / Unreleased / Windows Node 专业软件独立配置与 runtime 安装**:主窗口新增 Origin、ANSYS Mechanical 2024 R2、Blender 软件卡,分别显示应用位置来源和受管 runtime 状态;Blender/ANSYS 可由 UI 选择自定义位置,清除后按环境变量、卸载注册表、PATH/标准目录自动检测,Origin 保持 COM 注册检测。每个软件可单独创建或更新隔离的 Python 3.12 runtime,安装只使用内置 runtime 定义与固定 requirements,拒绝活动任务期间替换,并先在临时目录完成后再切换;配置后的路径由 Host 作为固定子进程环境传给 Worker,任务请求仍不能提供路径或命令。移除重复承担软件安装和开机启动的统一 BAT,发布包直接运行 EXE,当前账号的开机启动也完全由 UI 管理;全新节点默认使用可写的 `%LocalAppData%` 数据根目录,已有 `%ProgramData%` 节点原位兼容。
|
||||
|
||||
- **08-20 / Unreleased / 对话草稿自动暂存与回答中排队发送**:未发送文字按用户和对话隔离保存于浏览器本地,切换对话或刷新页面后自动恢复,发送成功后清除;助手回答、停止收尾或后台进程执行期间,消息及附件作为可移除的待处理卡按序保存,当前工作结束后严格串行启动下一轮。composer 对齐 Codex 单按钮形态:忙碌且输入为空时显示“停止”,键入文字或加入附件后同一按钮切回“发送”并走排队语义。新对话在 task 懒创建与首条消息两个请求之间会把草稿迁到真实 task,发送失败仍可找回;输入采用 200ms 合并写入并覆盖润色、语音转写和引用修改入口。JavaScript 语法、队列/草稿专项及既有前端测试通过,无 DB、schema、migration、HTTP API、依赖或运行方式变化。
|
||||
|
||||
- **08-20 / Unreleased / Windows Node 数据根目录自定义与安全迁移**:新增 UI 数据目录选择、打开和整体迁移,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先且使 UI 只读,未配置节点继续使用 `%ProgramData%`;迁移拒绝活动任务、非固定盘、嵌套或非空目标及空间不足,停止连接并等待任务/导出收尾后在临时目录复制和逐文件 SHA-256 校验,成功才保存账号级路径并重启,旧目录保留。安装器、runtime、Workspace、诊断和磁盘余量上报统一跟随实际数据根目录;65 项 Windows Node/软件节点专项 unittest、.NET build 与 diff 检查通过,未迁移真实节点数据、未连接生产数据库。
|
||||
|
|
|
|||
23
RUN.md
23
RUN.md
|
|
@ -1091,26 +1091,23 @@ Zcbot.WindowsNode.exe enroll `
|
|||
Zcbot.WindowsNode.exe
|
||||
```
|
||||
|
||||
Windows Node 发布目录包含纯 BAT 安装入口,不依赖 PowerShell。使用实际运行 Node 的专用 Windows 账号双击 `install-windows-node.bat`;安装器会分别检测 Origin/OriginPro 与 `C:\Program Files\ANSYS Inc\v242`(或 `AWP_ROOT242`)中的 Mechanical 2024 R2,检测到后自动通过 `py -3.12` 或 PATH 定位 Python 3.12,并为两者安装相互隔离的固定 runtime;未检测到的软件会跳过。Python 依赖默认显式使用清华 PyPI 镜像;如需切换官方源或院内镜像,在启动安装器前设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 注册该账号登录后的启动任务,结束后保留窗口。也可在命令提示符执行:
|
||||
Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service,也不要求管理员创建计划任务。
|
||||
|
||||
```bat
|
||||
cd /d D:\ZcbotNode
|
||||
install-windows-node.bat
|
||||
```
|
||||
专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。依赖默认显式使用清华 PyPI 镜像;如需切换官方源或院内镜像,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。解释器位于 `<data_root>\runtimes\origin|ansys|blender\Scripts\python.exe`;如需由集中运维提供既有受管解释器,机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON` 或 `ZCBOT_ADAPTER_BLENDER_PYTHON` 继续兼容,旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。任务请求无权选择解释器、脚本、安装依赖或应用路径。
|
||||
|
||||
若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认数据根目录是 `%ProgramData%\Zcbot\WindowsNode`,解释器位于 `<data_root>\runtimes\origin|ansys|blender\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON` 或 `ZCBOT_ADAPTER_BLENDER_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。
|
||||
|
||||
数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node,并由管理员自行保证完整数据已经迁入。统一安装器会读取同一环境变量或当前专用账号的 UI 配置。
|
||||
数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node,并由管理员自行保证完整数据已经迁入。软件卡安装的 runtime 始终跟随当前数据根目录。
|
||||
|
||||
Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
|
||||
|
||||
Blender 能力为 `blender.scene.author@v1`。首版 `rotary_kiln` 接受完整的回转窑目标状态,生成 `scene.blend`、`preview.png`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`;`.blend` 留在 Workspace 作为下一次修改的事实源。安装器会从 `ZCBOT_BLENDER_EXE`、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender;非标准安装位置应先设置机器级 `ZCBOT_BLENDER_EXE`,再重新启动 Node。启动器固定使用 Blender 后台、factory startup 和禁止自动脚本模式,不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL 或本机路径。
|
||||
主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements,并先在临时目录完成后再替换该软件的 `runtimes/<runtime_id>`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。Blender 和 ANSYS 可用“选择位置”保存当前专用账号的配置,点“自动检测”会清除手工值并重新按环境变量、Windows 卸载注册表、PATH/标准目录探测;Origin 使用 COM 注册自动检测。环境变量 `ZCBOT_BLENDER_EXE` 与 `AWP_ROOT242` 继续兼容。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。
|
||||
|
||||
Blender 能力为 `blender.scene.author@v1`。首版 `rotary_kiln` 接受完整的回转窑目标状态,生成 `scene.blend`、`preview.png`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`;`.blend` 留在 Workspace 作为下一次修改的事实源。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup 和禁止自动脚本模式,不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL 或本机路径。
|
||||
|
||||
ANSYS 能力为 `ansys.geometry.inspect@v1` 和 `ansys.mechanical.static_structural@v2`,只面向 Mechanical 2024 R2(revision 242)。adapter 0.3.0 使用 PyMechanical 0.11.3:前者导入几何并输出面清单、单位、包围盒、Named Selection 和预览,后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区,再执行真实静力求解。v2 直接替换旧 v1,旧请求不会在新版节点调度。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。
|
||||
|
||||
普通几何先提交 `ansys.geometry.inspect@v1`,`outputs` 传空数组;成功后以 `geometry-manifest.json` 的单位、包围盒、面质心/法向/面积和 `geometry-preview.png` 判断边界区域。规则板件可在静力 v2 中使用 `{"type":"extreme_face","axis":"x","side":"min","expected_count":1}`;已知平面可使用 `{"type":"planar_faces","normal":[1,0,0],"offset":200,"tolerance":0.01,"expected_count":1}`,其中偏置和容差沿用 `analysis.unit_system` 的长度单位;已有 CAD Named Selection 则使用 `{"type":"named_selection","name":"fixed_mount"}`。每个 boundary/load 还必须提供唯一的 ASCII `name`,用于 Mechanical 树、反力结果和溯源。`expected_count` 可省略,但自动化生产请求应尽量提供;数量不符、没有平面、查询为空或名称冲突都会在网格和求解前失败。复杂装配或歧义区域不得只凭预览猜测,应回到 CAD 建立 Named Selection 后再提交。
|
||||
|
||||
验收使用安装包自带的 NIST FTC-11 AP203 几何,adapter 通过与生产请求相同的 `planar_faces` 法向、偏置、容差及 `expected_count=1` 建立 `fixed_face`、`load_face`;不再走验收专用选面旁路。先退出托盘 Node、整体替换新版发布目录、重新运行统一安装器并启动 Node,确认托盘仍显示 ANSYS 待验收。打开 Node 主窗口,在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”,只需选择报告父目录;UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project,可不受限制地使用,文件头保留来源说明。
|
||||
验收使用安装包自带的 NIST FTC-11 AP203 几何,adapter 通过与生产请求相同的 `planar_faces` 法向、偏置、容差及 `expected_count=1` 建立 `fixed_face`、`load_face`;不再走验收专用选面旁路。先退出托盘 Node、整体替换新版发布目录并启动,在 ANSYS 软件卡中安装/更新运行环境,确认能力仍显示待验收。随后在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”,只需选择报告父目录;UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project,可不受限制地使用,文件头保留来源说明。
|
||||
|
||||
节点卡片的“复制诊断信息”可把 Node 版本、系统、数据目录、执行门状态和每个 adapter 的软件版本、health、detail、受管 runtime 路径复制到对话,不包含 Node Token。若 UI 无法运行验收,可先退出托盘 Node,再用同一专用 Windows 账号在 PowerShell 执行以下等价命令(`--work-root` 必须是尚不存在的新目录):
|
||||
|
||||
|
|
@ -1147,11 +1144,11 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
|
|||
|
||||
两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动(实际版本、runtime、入口和契约文件名),`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。
|
||||
|
||||
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE:运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node,备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再从开始菜单或登录任务启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,需重新运行统一安装器更新受管 Python runtime;仅 `adapter.json`、契约或 `worker.py` 变化时无需。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
|
||||
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE:运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node,备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约或 `worker.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
|
||||
|
||||
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v1-adapter.zip`,替换规则与 Origin 相同。Blender 的 `requirements.txt` 当前不安装第三方包;首次增加 Blender 能力时仍需运行统一安装器创建轻量启动 runtime,`bpy` 不安装到该 runtime。
|
||||
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v1-adapter.zip`,替换规则与 Origin 相同。Blender 的 `requirements.txt` 当前不安装第三方包;首次增加 Blender 能力时在 Blender 软件卡中创建轻量启动 runtime,`bpy` 不安装到该 runtime。
|
||||
|
||||
注册配置写入 `<data_root>\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号执行统一安装器、注册并运行 Node。当前 MVP 以该账号的登录后计划任务启动,不安装 Windows Service。
|
||||
注册配置写入 `<data_root>\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。
|
||||
|
||||
直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。
|
||||
|
||||
|
|
|
|||
|
|
@ -63,7 +63,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
source = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in PROJECT.glob("*.cs")
|
||||
if path.name not in {"AdapterProcessRunner.cs", "ConfigurationForm.cs"}
|
||||
if path.name not in {
|
||||
"AdapterProcessRunner.cs",
|
||||
"ConfigurationForm.cs",
|
||||
"SoftwareRuntimeManager.cs",
|
||||
}
|
||||
)
|
||||
for forbidden in ("Process.Start", "cmd.exe", "powershell.exe", "LabTalk"):
|
||||
self.assertNotIn(forbidden, source)
|
||||
|
|
@ -81,6 +85,9 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('"ZCBOT_WINDOWS_NODE_DATA_DIR"', settings)
|
||||
self.assertIn("Environment.SpecialFolder.LocalApplicationData", settings)
|
||||
self.assertIn("Environment.SpecialFolder.CommonApplicationData", settings)
|
||||
self.assertIn('Directory.Exists(legacy)', settings)
|
||||
self.assertLess(
|
||||
settings.index("Environment.GetEnvironmentVariable"),
|
||||
settings.index("Registry.CurrentUser.OpenSubKey"),
|
||||
|
|
@ -128,7 +135,6 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
|
||||
self.assertIn("ANSYS Mechanical 真机验收", form)
|
||||
self.assertIn("运行内置基准验收", form)
|
||||
self.assertNotIn("new OpenFileDialog", form)
|
||||
self.assertIn("AcceptanceReportPassed", form)
|
||||
self.assertIn('"ZCBOT_ANSYS_242_VALIDATED", "1"', form)
|
||||
self.assertIn("EnvironmentVariableTarget.Machine", form)
|
||||
|
|
@ -142,8 +148,33 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
acceptance = form.split("private async Task RunAnsysAcceptanceAsync", 1)[1].split(
|
||||
"private void EnableAnsysGate", 1
|
||||
)[0]
|
||||
self.assertNotIn("new OpenFileDialog", acceptance)
|
||||
self.assertNotIn("Process.Start", acceptance)
|
||||
|
||||
def test_professional_software_ui_uses_fixed_per_runtime_installers(self) -> None:
|
||||
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
|
||||
manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")
|
||||
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('CreateSectionTitle("专业软件")', form)
|
||||
self.assertIn('CreateButton("自动检测", 100)', form)
|
||||
self.assertIn('CreateButton("选择位置", 100)', form)
|
||||
self.assertIn('CreateButton("安装/更新运行环境", 172', form)
|
||||
self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form)
|
||||
self.assertIn("item.HasActiveJobs", form)
|
||||
for runtime_id in ('"origin"', '"ansys"', '"blender"'):
|
||||
self.assertIn(runtime_id, manager)
|
||||
self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager)
|
||||
self.assertIn("RegistryHive.LocalMachine", manager)
|
||||
self.assertIn("RegistryHive.CurrentUser", manager)
|
||||
self.assertIn('"ZCBOT_BLENDER_EXE"', manager)
|
||||
self.assertIn('"AWP_ROOT242"', manager)
|
||||
self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', manager)
|
||||
self.assertIn("ArgumentList.Add(argument)", manager)
|
||||
self.assertNotIn('"cmd.exe"', manager.lower())
|
||||
self.assertNotIn('"powershell.exe"', manager.lower())
|
||||
self.assertIn("SoftwareRuntimeManager.ApplyProcessEnvironment", runner)
|
||||
|
||||
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(840, 680)", form)
|
||||
|
|
@ -198,73 +229,27 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn("JobDisplaySnapshot", models)
|
||||
self.assertNotIn("origin_running", inbox + connection + models)
|
||||
|
||||
def test_unified_installer_registers_login_startup(self) -> None:
|
||||
launcher = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
|
||||
self.assertNotIn("powershell", launcher.lower())
|
||||
self.assertIn('set "NODE_EXE=%~dp0Zcbot.WindowsNode.exe"', launcher)
|
||||
self.assertIn('schtasks.exe /Create /TN "Zcbot Windows Node"', launcher)
|
||||
self.assertIn("/SC ONLOGON", launcher)
|
||||
self.assertIn("/RL LIMITED", launcher)
|
||||
self.assertIn("/IT", launcher)
|
||||
self.assertIn("pause >nul", launcher)
|
||||
def test_startup_and_runtime_installation_are_owned_by_the_ui(self) -> None:
|
||||
startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8")
|
||||
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
|
||||
manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")
|
||||
|
||||
def test_unified_installer_detects_origin_and_python_312(self) -> None:
|
||||
script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
|
||||
self.assertIn('set "NODE_DATA_ROOT=%ZCBOT_WINDOWS_NODE_DATA_DIR%"', script)
|
||||
self.assertIn('reg.exe query "HKCU\\Software\\Zcbot\\WindowsNode" /v DataRoot', script)
|
||||
self.assertFalse((ROOT / "install-windows-node.bat").exists())
|
||||
self.assertIn('Text = "登录 Windows 后自动启动节点"', form)
|
||||
self.assertIn("StartupRegistration.SetEnabled", form)
|
||||
self.assertIn('@"Software\\Microsoft\\Windows\\CurrentVersion\\Run"', startup)
|
||||
self.assertIn("Environment.ProcessPath", startup)
|
||||
self.assertIn("FindPython312Async", manager)
|
||||
self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', manager)
|
||||
self.assertIn('new BootstrapPython("python.exe", [])', manager)
|
||||
self.assertIn('"ZCBOT_PIP_INDEX_URL"', manager)
|
||||
self.assertIn(
|
||||
'if not defined NODE_DATA_ROOT set "NODE_DATA_ROOT=%ProgramData%\\Zcbot\\WindowsNode"',
|
||||
script,
|
||||
)
|
||||
self.assertIn(":detect_origin", script)
|
||||
self.assertIn('reg.exe query "HKCR\\Origin.ApplicationSI\\CLSID"', script)
|
||||
self.assertIn(":create_python_runtime", script)
|
||||
self.assertIn("py.exe -3.12", script)
|
||||
self.assertIn("sys.version_info[:2] == (3, 12)", script)
|
||||
self.assertIn(
|
||||
'set "ORIGIN_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\origin"',
|
||||
script,
|
||||
)
|
||||
self.assertIn(
|
||||
'set "ANSYS_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\ansys"',
|
||||
script,
|
||||
)
|
||||
self.assertIn(":detect_ansys_242", script)
|
||||
self.assertIn("v242\\aisol\\bin\\winx64\\AnsysWBU.exe", script)
|
||||
self.assertIn(
|
||||
'call :create_python_runtime "%ORIGIN_RUNTIME_DIR%" "%~1"', script
|
||||
)
|
||||
self.assertIn(
|
||||
'call :create_python_runtime "%ANSYS_RUNTIME_DIR%" "%~1"', script
|
||||
)
|
||||
self.assertIn('py.exe -3.12 -m venv "%~1"', script)
|
||||
self.assertIn('python.exe -m venv "%~1"', script)
|
||||
self.assertNotIn('"%PYTHON_EXE%"', script)
|
||||
|
||||
def test_unified_installer_detects_blender_and_creates_launcher_runtime(self) -> None:
|
||||
script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
|
||||
self.assertIn(":detect_blender", script)
|
||||
self.assertIn("ZCBOT_BLENDER_EXE", script)
|
||||
self.assertIn("Blender Foundation\\Blender *", script)
|
||||
self.assertIn(
|
||||
'set "BLENDER_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\blender"',
|
||||
script,
|
||||
)
|
||||
self.assertIn(
|
||||
'call :create_python_runtime "%BLENDER_RUNTIME_DIR%" "%~1"', script
|
||||
'"https://pypi.tuna.tsinghua.edu.cn/simple/"', manager
|
||||
)
|
||||
|
||||
def test_unified_installer_uses_configurable_domestic_pypi(self) -> None:
|
||||
script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
'if not defined ZCBOT_PIP_INDEX_URL set "ZCBOT_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/"',
|
||||
script,
|
||||
)
|
||||
self.assertEqual(script.count('--index-url "%ZCBOT_PIP_INDEX_URL%"'), 2)
|
||||
|
||||
def test_publish_output_contains_the_complete_installer_payload(self) -> None:
|
||||
def test_publish_output_contains_the_complete_node_payload(self) -> None:
|
||||
project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8")
|
||||
self.assertIn("..\\install-windows-node.bat", project)
|
||||
self.assertNotIn("install-windows-node.bat", project)
|
||||
self.assertIn("..\\adapters\\**\\*", project)
|
||||
self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project)
|
||||
self.assertIn("..\\..\\software-contracts\\origin.plot.v2.json", project)
|
||||
|
|
@ -305,7 +290,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
|||
self.assertIn('set "SELF_CONTAINED=false"', script)
|
||||
self.assertIn('if /I "%~1"=="--self-contained"', script)
|
||||
self.assertIn("dotnet publish", script)
|
||||
self.assertIn('"install-windows-node.bat"', script)
|
||||
self.assertNotIn('"install-windows-node.bat"', script)
|
||||
self.assertIn("start Zcbot.WindowsNode.exe", script)
|
||||
self.assertIn('for /r "%PUBLISH_DIR%\\adapters" %%F in (adapter.json)', script)
|
||||
self.assertIn('do if exist "%%~fF"', script)
|
||||
self.assertIn("ADAPTER_CONTRACT_COUNT", script)
|
||||
|
|
|
|||
|
|
@ -14,21 +14,19 @@ windows-node\package-windows-node.bat
|
|||
|
||||
固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式。adapter 0.6.0 提供 `multi_panel`:可组成 1–4 个二维 panel,在每个 panel 混合基础二维系列、绑定左右 Y 轴并添加对称 X/Y 误差列;adapter 0.7.0 增加 `box`、`histogram`、`stacked_column`、`bubble` 和 `band`,adapter 0.8.0 再增加 `area`、`stacked_area`、`polar`、`pie` 和 `stacked_bar`。Worker 生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格;所有堆叠系列必须逐行共享相同 X 值。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口;使用实际运行 Node 的专用 Windows 账号直接双击:
|
||||
|
||||
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要重新运行统一安装器。
|
||||
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要在对应软件卡中更新 runtime。
|
||||
|
||||
```text
|
||||
install-windows-node.bat
|
||||
```
|
||||
Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zcbot.WindowsNode.exe`,在主窗口完成节点注册、登录后自动启动、应用位置配置和各软件 runtime 的独立安装。runtime 安装依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,要求 Python 3.12;依赖默认通过清华 PyPI 镜像安装,需要改用官方源或院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。runtime 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。
|
||||
|
||||
安装器是纯 BAT,不依赖 PowerShell:它分别检测 Origin/OriginPro、ANSYS Mechanical 2024 R2 和 Blender,依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys`/`blender` runtime;未检测到的软件会明确跳过。Blender runtime 只是标准库启动器,`bpy` 始终在 Blender 自带 Python 内运行。Python 依赖默认通过清华 PyPI 镜像安装;需要改用官方源或院内镜像时,先设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务,成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`;数据根目录默认是 `%ProgramData%\Zcbot\WindowsNode`。
|
||||
推荐在 Node 主窗口“专业软件”区域完成后续配置:Origin、ANSYS、Blender 各有独立软件卡,可分别自动检测应用、选择受支持的自定义位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements,不执行用户输入的命令;新环境先在同一数据根目录的临时目录构建,成功后再替换对应 runtime。Blender 与 ANSYS 的手工路径保存在当前专用 Windows 账号下,Node 启动 Worker 时注入固定环境变量;“自动检测”可清除手工路径并回到环境变量、卸载注册表、PATH/标准目录发现。Origin 继续以 COM 注册为应用事实源。
|
||||
|
||||
主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务,已有未完成任务时拒绝开始;随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。统一安装器同时读取环境变量或该专用账号的 UI 配置,后续更新不会把 runtime 写回默认目录。
|
||||
主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务,已有未完成任务时拒绝开始;随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。软件卡始终把 runtime 安装到当前数据根目录,后续更新不会写回默认目录。
|
||||
|
||||
Blender adapter 0.1.0 以 `rotary_kiln` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、2–8 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;输出可续作 `scene.blend`、自动预览、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup 和禁止自动脚本标志,请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后整体替换 `adapters\blender.scene.author@v1\` 并重启;若节点没有 `runtimes\blender`,再运行统一安装器。
|
||||
Blender adapter 0.1.0 以 `rotary_kiln` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、2–8 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;输出可续作 `scene.blend`、自动预览、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup 和禁止自动脚本标志,请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后整体替换 `adapters\blender.scene.author@v1\` 并重启;若节点没有 `runtimes\blender`,在 Blender 软件卡中单独安装即可。
|
||||
|
||||
ANSYS adapter 0.2.0 已实现 Mechanical 2024 R2 / PyMechanical 0.11.3 的真实线性静力流程,可从 STEP/Parasolid/IGES 导入 Named Selection,应用固定材料/网格/支撑/力或压力,并导出 MECHDAT、汇总、结果表、结果图、solver log 和溯源。契约不接收 Python、APDL、Journal、工程文件或本机路径。Node 主窗口提供“复制诊断信息”和“ANSYS Mechanical 真机验收”卡片;验收使用内置 NIST FTC-11 STEP 并自动建立两个端面 Named Selection,用户只需选择报告目录。该入口不受执行门影响,但只有固定的取消与连续 20 次求解全部通过后,界面才允许写入机器级执行门。完成专用节点的许可证、取消、残留进程和许可证释放验收前,能力仍保持不可调度,具体步骤见根目录 `RUN.md`。
|
||||
|
||||
注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。
|
||||
注册和运行必须使用同一专用 Windows 账号。MVP 由主窗口按需写入该账号的登录启动项,不以 Windows Service 在 Session 0 运行。
|
||||
|
||||
```powershell
|
||||
dotnet build windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj
|
||||
|
|
|
|||
|
|
@ -325,6 +325,9 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
|
|||
startInfo.Environment["PYTHONUTF8"] = "1";
|
||||
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
|
||||
}
|
||||
SoftwareRuntimeManager.ApplyProcessEnvironment(
|
||||
startInfo,
|
||||
descriptor.Manifest.RuntimeId);
|
||||
return Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Adapter worker did not start.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ internal sealed class ConfigurationForm : Form
|
|||
private readonly DataGridView jobGrid = CreateJobGrid();
|
||||
private readonly JobInboxStore jobInbox = new(NodePaths.ForCurrentMachine().JobsDirectory);
|
||||
private readonly NodeAdapterRegistry adapters;
|
||||
private readonly Dictionary<string, SoftwareControls> softwareControls = new(StringComparer.Ordinal);
|
||||
private readonly System.Windows.Forms.Timer jobRefreshTimer = new() { Interval = 1000 };
|
||||
private readonly TableLayoutPanel registrationCard;
|
||||
private readonly TableLayoutPanel ansysAcceptanceCard;
|
||||
|
|
@ -60,6 +61,7 @@ internal sealed class ConfigurationForm : Form
|
|||
private NodeConfig? currentConfig;
|
||||
private string? passedAcceptanceReport;
|
||||
private CancellationTokenSource? acceptanceStop;
|
||||
private CancellationTokenSource? runtimeInstallStop;
|
||||
private bool dataRootMigrationInProgress;
|
||||
|
||||
internal event Func<EnrollOptions, Task>? RegisterRequested;
|
||||
|
|
@ -140,6 +142,16 @@ internal sealed class ConfigurationForm : Form
|
|||
statusCard.Controls.Add(resetActions);
|
||||
page.Controls.Add(statusCard);
|
||||
|
||||
var softwareCard = CreateCard();
|
||||
softwareCard.Controls.Add(CreateSectionTitle("专业软件"));
|
||||
softwareCard.Controls.Add(CreateHint(
|
||||
"每台 Node 只需配置并安装本机实际拥有的软件。应用位置可自动检测或手工指定;运行环境按软件独立安装。"));
|
||||
foreach (var definition in SoftwareRuntimeManager.Definitions)
|
||||
{
|
||||
softwareCard.Controls.Add(CreateSoftwarePanel(definition));
|
||||
}
|
||||
page.Controls.Add(softwareCard);
|
||||
|
||||
ansysAcceptanceCard = CreateCard();
|
||||
ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收"));
|
||||
ansysAcceptanceCard.Controls.Add(CreateHint(
|
||||
|
|
@ -222,12 +234,15 @@ internal sealed class ConfigurationForm : Form
|
|||
{
|
||||
acceptanceStop?.Cancel();
|
||||
acceptanceStop?.Dispose();
|
||||
runtimeInstallStop?.Cancel();
|
||||
runtimeInstallStop?.Dispose();
|
||||
jobRefreshTimer.Dispose();
|
||||
};
|
||||
var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v2");
|
||||
ansysAcceptanceCard.Visible = ansysAdapter?.SupportsLocalAcceptance == true;
|
||||
enableAnsysGate.Enabled = false;
|
||||
UpdateAnsysAcceptanceStatus();
|
||||
RefreshSoftwareCards();
|
||||
RefreshJobs();
|
||||
ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null);
|
||||
}
|
||||
|
|
@ -286,6 +301,214 @@ internal sealed class ConfigurationForm : Form
|
|||
+ runtime.Detail;
|
||||
}
|
||||
|
||||
private Control CreateSoftwarePanel(SoftwareDefinition definition)
|
||||
{
|
||||
var panel = new TableLayoutPanel
|
||||
{
|
||||
AutoSize = true,
|
||||
AutoSizeMode = AutoSizeMode.GrowAndShrink,
|
||||
Dock = DockStyle.Top,
|
||||
ColumnCount = 1,
|
||||
Padding = new Padding(0, 10, 0, 8),
|
||||
Margin = new Padding(0, 6, 0, 0),
|
||||
};
|
||||
panel.Controls.Add(new Label
|
||||
{
|
||||
Text = definition.DisplayName,
|
||||
AutoSize = true,
|
||||
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(30, 41, 59),
|
||||
Margin = new Padding(0, 0, 0, 5),
|
||||
});
|
||||
var path = CreateTextBox();
|
||||
path.ReadOnly = true;
|
||||
panel.Controls.Add(path);
|
||||
var status = CreateBodyLabel();
|
||||
panel.Controls.Add(status);
|
||||
var actions = CreateActions();
|
||||
var automatic = CreateButton("自动检测", 100);
|
||||
Button? choose = null;
|
||||
if (definition.PathKind != SoftwarePathKind.None)
|
||||
{
|
||||
choose = CreateButton("选择位置", 100);
|
||||
actions.Controls.Add(choose);
|
||||
}
|
||||
var install = CreateButton("安装/更新运行环境", 172, primary: true);
|
||||
actions.Controls.Add(automatic);
|
||||
actions.Controls.Add(install);
|
||||
panel.Controls.Add(actions);
|
||||
var controls = new SoftwareControls(path, status, automatic, choose, install);
|
||||
softwareControls.Add(definition.Id, controls);
|
||||
automatic.Click += (_, _) => ResetSoftwareLocation(definition);
|
||||
if (choose is not null)
|
||||
{
|
||||
choose.Click += (_, _) => ChooseSoftwareLocation(definition);
|
||||
}
|
||||
install.Click += async (_, _) => await InstallSoftwareRuntimeAsync(definition);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void RefreshSoftwareCards()
|
||||
{
|
||||
foreach (var definition in SoftwareRuntimeManager.Definitions)
|
||||
{
|
||||
RefreshSoftwareCard(definition);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshSoftwareCard(SoftwareDefinition definition)
|
||||
{
|
||||
var controls = softwareControls[definition.Id];
|
||||
var location = SoftwareRuntimeManager.ResolveLocation(definition);
|
||||
controls.Path.Text = location.Path ?? "未检测到应用位置";
|
||||
controls.Path.ForeColor = location.Available ? Color.FromArgb(51, 65, 85) : Color.Firebrick;
|
||||
var runtime = Path.Combine(
|
||||
NodePaths.ForCurrentMachine().RootDirectory,
|
||||
"runtimes",
|
||||
definition.RuntimeId,
|
||||
"Scripts",
|
||||
"python.exe");
|
||||
var runtimeState = File.Exists(runtime) ? "运行环境已安装" : "运行环境尚未安装";
|
||||
controls.Status.Text = location.Available
|
||||
? $"{location.Source} · {runtimeState}"
|
||||
: $"{location.Detail} · {runtimeState}";
|
||||
controls.Status.ForeColor = location.Available ? Color.FromArgb(71, 85, 105) : Color.Firebrick;
|
||||
controls.Install.Enabled = location.Available && runtimeInstallStop is null;
|
||||
controls.Automatic.Enabled = runtimeInstallStop is null;
|
||||
if (controls.Choose is not null) controls.Choose.Enabled = runtimeInstallStop is null;
|
||||
}
|
||||
|
||||
private void ResetSoftwareLocation(SoftwareDefinition definition)
|
||||
{
|
||||
try
|
||||
{
|
||||
SoftwareRuntimeManager.ClearConfiguredPath(definition);
|
||||
adapters.InvalidateRuntime(definition.RuntimeId);
|
||||
RefreshSoftwareCard(definition);
|
||||
RefreshCapabilitySummary();
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or SecurityException or UnauthorizedAccessException)
|
||||
{
|
||||
MessageBox.Show(exception.Message, "自动检测失败",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ChooseSoftwareLocation(SoftwareDefinition definition)
|
||||
{
|
||||
string? selected = null;
|
||||
if (definition.PathKind == SoftwarePathKind.Executable)
|
||||
{
|
||||
using var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = $"选择 {definition.DisplayName} 可执行文件",
|
||||
Filter = $"{definition.RequiredRelativePath}|{definition.RequiredRelativePath}|可执行文件 (*.exe)|*.exe",
|
||||
CheckFileExists = true,
|
||||
Multiselect = false,
|
||||
};
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.FileName;
|
||||
}
|
||||
else if (definition.PathKind == SoftwarePathKind.Directory)
|
||||
{
|
||||
using var dialog = new FolderBrowserDialog
|
||||
{
|
||||
Description = $"选择 {definition.DisplayName} 安装根目录",
|
||||
UseDescriptionForTitle = true,
|
||||
ShowNewFolderButton = false,
|
||||
};
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.SelectedPath;
|
||||
}
|
||||
if (selected is null) return;
|
||||
try
|
||||
{
|
||||
SoftwareRuntimeManager.SaveConfiguredPath(definition, selected);
|
||||
adapters.InvalidateRuntime(definition.RuntimeId);
|
||||
RefreshSoftwareCard(definition);
|
||||
RefreshCapabilitySummary();
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException
|
||||
or IOException
|
||||
or InvalidDataException
|
||||
or InvalidOperationException
|
||||
or SecurityException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
MessageBox.Show(exception.Message, "应用位置无效",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InstallSoftwareRuntimeAsync(SoftwareDefinition definition)
|
||||
{
|
||||
if (adapters.All.Any(item =>
|
||||
definition.RuntimeId.Equals(item.RuntimeId, StringComparison.Ordinal)
|
||||
&& item.HasActiveJobs))
|
||||
{
|
||||
MessageBox.Show(
|
||||
$"{definition.DisplayName} 仍有任务在执行,暂不能更换运行环境。",
|
||||
"暂不能安装", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
var answer = MessageBox.Show(
|
||||
$"将为 {definition.DisplayName} 创建独立 Python 3.12 运行环境并安装固定依赖。是否继续?",
|
||||
"安装专业软件运行环境", MessageBoxButtons.YesNo, MessageBoxIcon.Question,
|
||||
MessageBoxDefaultButton.Button2);
|
||||
if (answer != DialogResult.Yes) return;
|
||||
|
||||
runtimeInstallStop = new CancellationTokenSource();
|
||||
RefreshSoftwareCards();
|
||||
var controls = softwareControls[definition.Id];
|
||||
controls.Status.ForeColor = Color.DarkOrange;
|
||||
var progress = new Progress<string>(line => controls.Status.Text = line);
|
||||
try
|
||||
{
|
||||
var result = await SoftwareRuntimeManager.InstallRuntimeAsync(
|
||||
definition,
|
||||
progress,
|
||||
runtimeInstallStop.Token);
|
||||
adapters.InvalidateRuntime(definition.RuntimeId);
|
||||
controls.Status.ForeColor = Color.ForestGreen;
|
||||
controls.Status.Text = result.Detail;
|
||||
MessageBox.Show(
|
||||
$"{result.Detail}\n{result.RuntimePath}",
|
||||
"安装完成", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
controls.Status.ForeColor = Color.DimGray;
|
||||
controls.Status.Text = "安装已取消,原运行环境保持不变。";
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException
|
||||
or InvalidDataException
|
||||
or InvalidOperationException
|
||||
or SecurityException
|
||||
or UnauthorizedAccessException
|
||||
or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
controls.Status.ForeColor = Color.Firebrick;
|
||||
controls.Status.Text = $"安装失败:{exception.Message}";
|
||||
MessageBox.Show(exception.Message, "安装失败",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtimeInstallStop.Dispose();
|
||||
runtimeInstallStop = null;
|
||||
RefreshSoftwareCards();
|
||||
RefreshCapabilitySummary();
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshCapabilitySummary()
|
||||
{
|
||||
capabilitySummary.Text = currentConfig is null
|
||||
? "注册后启用"
|
||||
: string.Join("\n", adapters.All.Select(FormatAdapterStatus));
|
||||
}
|
||||
|
||||
private void CopyDiagnostics()
|
||||
{
|
||||
if (currentConfig is null)
|
||||
|
|
@ -986,4 +1209,11 @@ internal sealed class ConfigurationForm : Form
|
|||
layout.Controls.Add(control);
|
||||
layout.Controls.Add(CreateHint(hint));
|
||||
}
|
||||
|
||||
private sealed record SoftwareControls(
|
||||
TextBox Path,
|
||||
Label Status,
|
||||
Button Automatic,
|
||||
Button? Choose,
|
||||
Button Install);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ internal interface INodeAdapter
|
|||
IReadOnlyList<string> Features { get; }
|
||||
bool HasActiveJobs { get; }
|
||||
string RunningDetail { get; }
|
||||
string? RuntimeId { get; }
|
||||
bool SupportsLocalAcceptance { get; }
|
||||
string RuntimePath { get; }
|
||||
string ContractPath { get; }
|
||||
|
|
@ -20,6 +21,7 @@ internal interface INodeAdapter
|
|||
string? WorkspaceStateFilename { get; }
|
||||
IReadOnlyList<string> PreviewOutputIds { get; }
|
||||
AdapterRuntimeStatus DetectRuntime();
|
||||
void InvalidateRuntime();
|
||||
Task<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
|
||||
string workRoot,
|
||||
IProgress<string> progress,
|
||||
|
|
@ -249,6 +251,7 @@ internal sealed class ProcessNodeAdapter : INodeAdapter
|
|||
public IReadOnlyList<string> Features => Descriptor.Contract.Features;
|
||||
public bool HasActiveJobs => runner.HasActiveJobs;
|
||||
public string RunningDetail => Descriptor.Manifest.RunningDetail;
|
||||
public string? RuntimeId => Descriptor.Manifest.RuntimeId;
|
||||
public bool SupportsLocalAcceptance =>
|
||||
Capability.Equals(
|
||||
"ansys.mechanical.static_structural@v2", StringComparison.Ordinal)
|
||||
|
|
@ -270,6 +273,12 @@ internal sealed class ProcessNodeAdapter : INodeAdapter
|
|||
return cachedRuntime;
|
||||
}
|
||||
|
||||
public void InvalidateRuntime()
|
||||
{
|
||||
cachedRuntime = null;
|
||||
runtimeCheckedAt = default;
|
||||
}
|
||||
|
||||
public bool ValidateRequest(JsonElement request) =>
|
||||
Descriptor.Contract.RequestSchema.Evaluate(request, SchemaOptions).IsValid;
|
||||
|
||||
|
|
@ -311,6 +320,15 @@ internal sealed class NodeAdapterRegistry
|
|||
internal INodeAdapter? Find(string capability) =>
|
||||
adapters.TryGetValue(capability, out var adapter) ? adapter : null;
|
||||
|
||||
internal void InvalidateRuntime(string runtimeId)
|
||||
{
|
||||
foreach (var adapter in adapters.Values.Where(
|
||||
item => runtimeId.Equals(item.RuntimeId, StringComparison.Ordinal)))
|
||||
{
|
||||
adapter.InvalidateRuntime();
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdapterDescriptor> DiscoverDescriptors()
|
||||
{
|
||||
if (!Directory.Exists(AdapterRoot)) return [];
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ internal static class NodeDataRootSettings
|
|||
Normalize(configured), IsEnvironmentManaged: false, "user");
|
||||
}
|
||||
|
||||
var legacy = LegacyDefaultRoot();
|
||||
if (Directory.Exists(legacy))
|
||||
{
|
||||
return new NodeDataRootSelection(
|
||||
legacy, IsEnvironmentManaged: false, "legacy-default");
|
||||
}
|
||||
return new NodeDataRootSelection(DefaultRoot(), IsEnvironmentManaged: false, "default");
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +73,10 @@ internal static class NodeDataRootSettings
|
|||
}
|
||||
|
||||
private static string DefaultRoot() => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Zcbot", "WindowsNode");
|
||||
|
||||
private static string LegacyDefaultRoot() => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"Zcbot", "WindowsNode");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
using Microsoft.Win32;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
|
||||
namespace Zcbot.WindowsNode;
|
||||
|
||||
internal enum SoftwarePathKind
|
||||
{
|
||||
None,
|
||||
Executable,
|
||||
Directory,
|
||||
}
|
||||
|
||||
internal sealed record SoftwareDefinition(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string RuntimeId,
|
||||
SoftwarePathKind PathKind,
|
||||
string? EnvironmentVariable,
|
||||
string? RequiredRelativePath,
|
||||
string RequirementsRelativePath);
|
||||
|
||||
internal sealed record SoftwareLocation(
|
||||
bool Available,
|
||||
string? Path,
|
||||
string Source,
|
||||
string Detail);
|
||||
|
||||
internal sealed record RuntimeInstallResult(string RuntimePath, string Detail);
|
||||
|
||||
internal static class SoftwareRuntimeManager
|
||||
{
|
||||
private const string SettingsKey = @"Software\Zcbot\WindowsNode\Software";
|
||||
private const string DefaultIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple/";
|
||||
|
||||
internal static IReadOnlyList<SoftwareDefinition> Definitions { get; } =
|
||||
[
|
||||
new(
|
||||
"origin",
|
||||
"Origin",
|
||||
"origin",
|
||||
SoftwarePathKind.None,
|
||||
null,
|
||||
null,
|
||||
@"adapters\origin.plot@v2\requirements.txt"),
|
||||
new(
|
||||
"ansys",
|
||||
"ANSYS Mechanical 2024 R2",
|
||||
"ansys",
|
||||
SoftwarePathKind.Directory,
|
||||
"AWP_ROOT242",
|
||||
@"aisol\bin\winx64\AnsysWBU.exe",
|
||||
@"adapters\ansys.mechanical.static_structural@v2\requirements.txt"),
|
||||
new(
|
||||
"blender",
|
||||
"Blender",
|
||||
"blender",
|
||||
SoftwarePathKind.Executable,
|
||||
"ZCBOT_BLENDER_EXE",
|
||||
"blender.exe",
|
||||
@"adapters\blender.scene.author@v1\requirements.txt"),
|
||||
];
|
||||
|
||||
internal static SoftwareDefinition ByRuntimeId(string runtimeId) =>
|
||||
Definitions.Single(item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
|
||||
|
||||
internal static SoftwareLocation ResolveLocation(SoftwareDefinition definition)
|
||||
{
|
||||
var configured = ReadConfiguredPath(definition.Id);
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return Location(definition, configured, "界面配置", strict: true);
|
||||
}
|
||||
|
||||
if (definition.EnvironmentVariable is not null)
|
||||
{
|
||||
var environment = Environment.GetEnvironmentVariable(definition.EnvironmentVariable);
|
||||
if (!string.IsNullOrWhiteSpace(environment))
|
||||
{
|
||||
return Location(
|
||||
definition,
|
||||
environment,
|
||||
$"环境变量 {definition.EnvironmentVariable}",
|
||||
strict: true);
|
||||
}
|
||||
}
|
||||
|
||||
return definition.Id switch
|
||||
{
|
||||
"origin" => DetectOrigin(),
|
||||
"ansys" => DetectAnsys(definition),
|
||||
"blender" => DetectBlender(definition),
|
||||
_ => new SoftwareLocation(false, null, "自动检测", "没有可用的自动检测规则。"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static void SaveConfiguredPath(SoftwareDefinition definition, string path)
|
||||
{
|
||||
var location = Location(definition, path, "界面配置", strict: true);
|
||||
if (!location.Available || location.Path is null)
|
||||
{
|
||||
throw new InvalidDataException(location.Detail);
|
||||
}
|
||||
using var key = Registry.CurrentUser.CreateSubKey(SettingsKey, writable: true)
|
||||
?? throw new InvalidOperationException("无法保存专业软件设置。");
|
||||
key.SetValue(definition.Id, location.Path, RegistryValueKind.String);
|
||||
}
|
||||
|
||||
internal static void ClearConfiguredPath(SoftwareDefinition definition)
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(SettingsKey, writable: true);
|
||||
key?.DeleteValue(definition.Id, throwOnMissingValue: false);
|
||||
}
|
||||
|
||||
internal static void ApplyProcessEnvironment(ProcessStartInfo startInfo, string? runtimeId)
|
||||
{
|
||||
if (runtimeId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var definition = Definitions.SingleOrDefault(
|
||||
item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
|
||||
if (definition?.EnvironmentVariable is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var location = ResolveLocation(definition);
|
||||
if (location.Available && location.Path is not null)
|
||||
{
|
||||
startInfo.Environment[definition.EnvironmentVariable] = location.Path;
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<RuntimeInstallResult> InstallRuntimeAsync(
|
||||
SoftwareDefinition definition,
|
||||
IProgress<string> progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var location = ResolveLocation(definition);
|
||||
if (!location.Available)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"尚未检测到 {definition.DisplayName}:{location.Detail}");
|
||||
}
|
||||
|
||||
var requirements = ResolveBundledFile(definition.RequirementsRelativePath);
|
||||
var root = NodePaths.ForCurrentMachine().RootDirectory;
|
||||
var runtimes = Path.Combine(root, "runtimes");
|
||||
Directory.CreateDirectory(runtimes);
|
||||
NodeConfigStore.RestrictDirectory(root);
|
||||
|
||||
var runtime = Path.Combine(runtimes, definition.RuntimeId);
|
||||
var staging = Path.Combine(
|
||||
runtimes,
|
||||
$".{definition.RuntimeId}.installing-{Guid.NewGuid():N}");
|
||||
var backup = Path.Combine(
|
||||
runtimes,
|
||||
$".{definition.RuntimeId}.rollback-{Guid.NewGuid():N}");
|
||||
var stagingPython = Path.Combine(staging, "Scripts", "python.exe");
|
||||
var movedExisting = false;
|
||||
try
|
||||
{
|
||||
progress.Report("正在查找 Python 3.12…");
|
||||
var bootstrap = await FindPython312Async(cancellationToken);
|
||||
progress.Report("正在创建隔离运行环境…");
|
||||
await RunAsync(
|
||||
bootstrap.FileName,
|
||||
bootstrap.PrefixArguments.Concat(["-m", "venv", staging]),
|
||||
AppContext.BaseDirectory,
|
||||
progress,
|
||||
cancellationToken);
|
||||
if (!File.Exists(stagingPython))
|
||||
{
|
||||
throw new InvalidDataException("Python venv 未生成 Scripts\\python.exe。");
|
||||
}
|
||||
|
||||
await VerifyPython312Async(stagingPython, cancellationToken);
|
||||
progress.Report("正在安装 adapter 依赖…");
|
||||
var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL");
|
||||
if (string.IsNullOrWhiteSpace(indexUrl))
|
||||
{
|
||||
indexUrl = DefaultIndexUrl;
|
||||
}
|
||||
await RunAsync(
|
||||
stagingPython,
|
||||
["-m", "pip", "install", "--index-url", indexUrl, "--requirement", requirements],
|
||||
AppContext.BaseDirectory,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
if (Directory.Exists(runtime))
|
||||
{
|
||||
Directory.Move(runtime, backup);
|
||||
movedExisting = true;
|
||||
}
|
||||
Directory.Move(staging, runtime);
|
||||
if (movedExisting)
|
||||
{
|
||||
Directory.Delete(backup, recursive: true);
|
||||
}
|
||||
NodeConfigStore.RestrictDirectory(runtime);
|
||||
return new RuntimeInstallResult(
|
||||
Path.Combine(runtime, "Scripts", "python.exe"),
|
||||
$"{definition.DisplayName} 运行环境安装完成。");
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (movedExisting && !Directory.Exists(runtime) && Directory.Exists(backup))
|
||||
{
|
||||
Directory.Move(backup, runtime);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteDirectoryIfPresent(staging);
|
||||
DeleteDirectoryIfPresent(backup);
|
||||
}
|
||||
}
|
||||
|
||||
private static SoftwareLocation DetectOrigin()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var clsid = Registry.ClassesRoot.OpenSubKey(@"Origin.ApplicationSI\CLSID");
|
||||
var clsidValue = clsid?.GetValue(null) as string;
|
||||
if (string.IsNullOrWhiteSpace(clsidValue))
|
||||
{
|
||||
return new SoftwareLocation(false, null, "COM 注册", "未找到 Origin COM 注册信息。");
|
||||
}
|
||||
using var server = Registry.ClassesRoot.OpenSubKey($@"CLSID\{clsidValue}\LocalServer32");
|
||||
var command = server?.GetValue(null) as string;
|
||||
var executable = ExecutableFromCommand(command);
|
||||
return executable is null
|
||||
? new SoftwareLocation(false, null, "COM 注册", "Origin COM 服务路径无效。")
|
||||
: new SoftwareLocation(true, executable, "COM 注册", $"已检测到 {executable}");
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or SecurityException or UnauthorizedAccessException)
|
||||
{
|
||||
return new SoftwareLocation(false, null, "COM 注册", exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static SoftwareLocation DetectAnsys(SoftwareDefinition definition)
|
||||
{
|
||||
var candidates = new List<(string Path, string Source)>();
|
||||
foreach (var location in UninstallLocations("ANSYS"))
|
||||
{
|
||||
candidates.Add((location, "注册表"));
|
||||
candidates.Add((Path.Combine(location, "v242"), "注册表"));
|
||||
}
|
||||
candidates.Add((@"C:\Program Files\ANSYS Inc\v242", "标准目录"));
|
||||
return FirstLocation(definition, candidates);
|
||||
}
|
||||
|
||||
private static SoftwareLocation DetectBlender(SoftwareDefinition definition)
|
||||
{
|
||||
var candidates = new List<(string Path, string Source)>();
|
||||
var path = FindOnPath("blender.exe");
|
||||
if (path is not null)
|
||||
{
|
||||
candidates.Add((path, "PATH"));
|
||||
}
|
||||
candidates.AddRange(UninstallLocations("Blender")
|
||||
.Select(location => (Path.Combine(location, "blender.exe"), "注册表")));
|
||||
foreach (var variable in new[] { "ProgramFiles", "ProgramW6432" })
|
||||
{
|
||||
var root = Environment.GetEnvironmentVariable(variable);
|
||||
if (string.IsNullOrWhiteSpace(root)) continue;
|
||||
var blenderRoot = Path.Combine(root, "Blender Foundation");
|
||||
if (!Directory.Exists(blenderRoot)) continue;
|
||||
candidates.AddRange(Directory.EnumerateDirectories(blenderRoot, "Blender *")
|
||||
.OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(item => (Path.Combine(item, "blender.exe"), "标准目录")));
|
||||
}
|
||||
return FirstLocation(definition, candidates);
|
||||
}
|
||||
|
||||
private static SoftwareLocation FirstLocation(
|
||||
SoftwareDefinition definition,
|
||||
IEnumerable<(string Path, string Source)> candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var location = Location(definition, candidate.Path, candidate.Source, strict: false);
|
||||
if (location.Available)
|
||||
{
|
||||
return location;
|
||||
}
|
||||
}
|
||||
return new SoftwareLocation(false, null, "自动检测", "没有找到有效的安装位置。");
|
||||
}
|
||||
|
||||
private static SoftwareLocation Location(
|
||||
SoftwareDefinition definition,
|
||||
string value,
|
||||
string source,
|
||||
bool strict)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.GetFullPath(Environment.ExpandEnvironmentVariables(value.Trim().Trim('"')));
|
||||
var required = definition.PathKind switch
|
||||
{
|
||||
SoftwarePathKind.Executable => path,
|
||||
SoftwarePathKind.Directory => Path.Combine(path, definition.RequiredRelativePath!),
|
||||
_ => path,
|
||||
};
|
||||
var valid = File.Exists(required)
|
||||
&& (definition.PathKind != SoftwarePathKind.Executable
|
||||
|| Path.GetFileName(required).Equals(
|
||||
definition.RequiredRelativePath,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
return valid
|
||||
? new SoftwareLocation(true, path, source, $"已检测到 {required}")
|
||||
: new SoftwareLocation(
|
||||
false,
|
||||
strict ? path : null,
|
||||
source,
|
||||
$"指定位置缺少 {definition.RequiredRelativePath}。");
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or IOException or NotSupportedException or SecurityException)
|
||||
{
|
||||
return new SoftwareLocation(false, null, source, exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadConfiguredPath(string id)
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(SettingsKey);
|
||||
return key?.GetValue(id) as string;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> UninstallLocations(string displayNameFragment)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser })
|
||||
{
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
{
|
||||
RegistryKey? baseKey = null;
|
||||
RegistryKey? uninstall = null;
|
||||
try
|
||||
{
|
||||
baseKey = RegistryKey.OpenBaseKey(hive, view);
|
||||
uninstall = baseKey.OpenSubKey(
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall");
|
||||
if (uninstall is null) continue;
|
||||
foreach (var name in uninstall.GetSubKeyNames())
|
||||
{
|
||||
using var entry = uninstall.OpenSubKey(name);
|
||||
var displayName = entry?.GetValue("DisplayName") as string;
|
||||
var location = entry?.GetValue("InstallLocation") as string;
|
||||
if (!string.IsNullOrWhiteSpace(displayName)
|
||||
&& displayName.Contains(displayNameFragment, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(location))
|
||||
{
|
||||
result.Add(location);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or SecurityException or UnauthorizedAccessException)
|
||||
{
|
||||
// A registry view can be unavailable to the current node account.
|
||||
}
|
||||
finally
|
||||
{
|
||||
uninstall?.Dispose();
|
||||
baseKey?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private static string? FindOnPath(string filename)
|
||||
{
|
||||
var path = Environment.GetEnvironmentVariable("PATH");
|
||||
if (string.IsNullOrWhiteSpace(path)) return null;
|
||||
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidate = Path.Combine(directory.Trim().Trim('"'), filename);
|
||||
if (File.Exists(candidate)) return Path.GetFullPath(candidate);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or IOException or NotSupportedException)
|
||||
{
|
||||
// Ignore malformed PATH entries and continue with the next one.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExecutableFromCommand(string? command)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command)) return null;
|
||||
var value = Environment.ExpandEnvironmentVariables(command.Trim());
|
||||
var candidate = value.StartsWith('"')
|
||||
? value[1..].Split('"', 2)[0]
|
||||
: value.Split(".exe", 2, StringSplitOptions.None)[0] + ".exe";
|
||||
return File.Exists(candidate) ? Path.GetFullPath(candidate) : null;
|
||||
}
|
||||
|
||||
private static string ResolveBundledFile(string relativePath)
|
||||
{
|
||||
var root = Path.GetFullPath(AppContext.BaseDirectory);
|
||||
var path = Path.GetFullPath(Path.Combine(root, relativePath));
|
||||
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
|
||||
{
|
||||
throw new InvalidDataException("Adapter requirements 文件缺失或路径非法。");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static async Task<BootstrapPython> FindPython312Async(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
new BootstrapPython("py.exe", ["-3.12"]),
|
||||
new BootstrapPython("python.exe", []),
|
||||
};
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(
|
||||
candidate.FileName,
|
||||
candidate.PrefixArguments.Concat(
|
||||
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"]),
|
||||
AppContext.BaseDirectory,
|
||||
progress: null,
|
||||
cancellationToken);
|
||||
return candidate;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is InvalidOperationException or Win32Exception)
|
||||
{
|
||||
// Try the next fixed Python 3.12 discovery method.
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"未找到 Python 3.12。请先安装 Python 3.12 并启用 py launcher 或加入 PATH。");
|
||||
}
|
||||
|
||||
private static Task VerifyPython312Async(string python, CancellationToken cancellationToken) =>
|
||||
RunAsync(
|
||||
python,
|
||||
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"],
|
||||
AppContext.BaseDirectory,
|
||||
progress: null,
|
||||
cancellationToken);
|
||||
|
||||
private static async Task RunAsync(
|
||||
string filename,
|
||||
IEnumerable<string> arguments,
|
||||
string workingDirectory,
|
||||
IProgress<string>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = filename,
|
||||
WorkingDirectory = workingDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
startInfo.Environment["PYTHONUTF8"] = "1";
|
||||
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
|
||||
using var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("运行环境安装进程未启动。");
|
||||
var stdout = RelayAsync(process.StandardOutput, progress);
|
||||
var stderr = RelayAsync(process.StandardError, progress);
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (!process.HasExited) process.Kill(entireProcessTree: true);
|
||||
throw;
|
||||
}
|
||||
await Task.WhenAll(stdout, stderr);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"运行环境安装进程失败,退出码 {process.ExitCode}。");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RelayAsync(StreamReader reader, IProgress<string>? progress)
|
||||
{
|
||||
while (await reader.ReadLineAsync() is { } line)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(line)) progress?.Report(line);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteDirectoryIfPresent(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// A successful runtime swap must not be reported as failed only because
|
||||
// antivirus software still holds a handle to the disposable directory.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BootstrapPython(
|
||||
string FileName,
|
||||
IReadOnlyList<string> PrefixArguments);
|
||||
}
|
||||
|
|
@ -40,10 +40,5 @@
|
|||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="..\install-windows-node.bat">
|
||||
<Link>install-windows-node.bat</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
@echo off
|
||||
setlocal EnableExtensions
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "NODE_EXE=%~dp0Zcbot.WindowsNode.exe"
|
||||
set "NODE_DATA_ROOT=%ZCBOT_WINDOWS_NODE_DATA_DIR%"
|
||||
if not defined NODE_DATA_ROOT (
|
||||
for /f "tokens=2,*" %%A in ('reg.exe query "HKCU\Software\Zcbot\WindowsNode" /v DataRoot 2^>nul ^| findstr.exe /I /C:"DataRoot"') do set "NODE_DATA_ROOT=%%B"
|
||||
)
|
||||
if not defined NODE_DATA_ROOT set "NODE_DATA_ROOT=%ProgramData%\Zcbot\WindowsNode"
|
||||
set "ORIGIN_REQUIREMENTS=%~dp0adapters\origin.plot@v2\requirements.txt"
|
||||
set "ORIGIN_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\origin"
|
||||
set "ORIGIN_RUNTIME_PYTHON=%ORIGIN_RUNTIME_DIR%\Scripts\python.exe"
|
||||
set "ANSYS_REQUIREMENTS=%~dp0adapters\ansys.mechanical.static_structural@v2\requirements.txt"
|
||||
set "ANSYS_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\ansys"
|
||||
set "ANSYS_RUNTIME_PYTHON=%ANSYS_RUNTIME_DIR%\Scripts\python.exe"
|
||||
set "BLENDER_REQUIREMENTS=%~dp0adapters\blender.scene.author@v1\requirements.txt"
|
||||
set "BLENDER_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\blender"
|
||||
set "BLENDER_RUNTIME_PYTHON=%BLENDER_RUNTIME_DIR%\Scripts\python.exe"
|
||||
if not defined ZCBOT_PIP_INDEX_URL set "ZCBOT_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/"
|
||||
|
||||
echo [INFO] Installing zcbot Windows Node...
|
||||
echo [INFO] Data root: "%NODE_DATA_ROOT%"
|
||||
|
||||
if not exist "%NODE_EXE%" (
|
||||
echo [ERR] Zcbot.WindowsNode.exe was not found beside this installer.
|
||||
goto :failed
|
||||
)
|
||||
|
||||
call :detect_origin
|
||||
if errorlevel 1 (
|
||||
echo [WARN] Origin/OriginPro was not detected; skipped the Origin runtime.
|
||||
goto :install_ansys
|
||||
)
|
||||
|
||||
echo [INFO] Origin/OriginPro detected.
|
||||
if not exist "%ORIGIN_REQUIREMENTS%" (
|
||||
echo [ERR] Origin worker requirements are missing: %ORIGIN_REQUIREMENTS%
|
||||
goto :failed
|
||||
)
|
||||
|
||||
if not exist "%ORIGIN_RUNTIME_PYTHON%" (
|
||||
echo [INFO] Creating Origin Python runtime...
|
||||
call :create_python_runtime "%ORIGIN_RUNTIME_DIR%" "%~1"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Failed to create the Origin Python runtime.
|
||||
goto :failed
|
||||
)
|
||||
)
|
||||
if not exist "%ORIGIN_RUNTIME_PYTHON%" (
|
||||
echo [ERR] Python venv did not create its interpreter: %ORIGIN_RUNTIME_PYTHON%
|
||||
goto :failed
|
||||
)
|
||||
|
||||
"%ORIGIN_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Existing Origin runtime is not Python 3.12: %ORIGIN_RUNTIME_PYTHON%
|
||||
goto :failed
|
||||
)
|
||||
|
||||
echo [INFO] Installing Origin worker dependencies...
|
||||
"%ORIGIN_RUNTIME_PYTHON%" -m pip install --index-url "%ZCBOT_PIP_INDEX_URL%" --requirement "%ORIGIN_REQUIREMENTS%"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Failed to install the Origin worker dependencies.
|
||||
goto :failed
|
||||
)
|
||||
"%ORIGIN_RUNTIME_PYTHON%" -c "import originpro, openpyxl, numpy, pymupdf; print('[OK] Origin worker Python packages are available.')"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Origin runtime import verification failed.
|
||||
goto :failed
|
||||
)
|
||||
echo [OK] Origin runtime installed: %ORIGIN_RUNTIME_PYTHON%
|
||||
|
||||
:install_ansys
|
||||
call :detect_ansys_242
|
||||
if errorlevel 1 (
|
||||
echo [WARN] ANSYS Mechanical 2024 R2 was not detected; skipped the ANSYS runtime.
|
||||
goto :install_blender
|
||||
)
|
||||
|
||||
echo [INFO] ANSYS Mechanical 2024 R2 detected.
|
||||
if not exist "%ANSYS_REQUIREMENTS%" (
|
||||
echo [ERR] ANSYS worker requirements are missing: %ANSYS_REQUIREMENTS%
|
||||
goto :failed
|
||||
)
|
||||
if not exist "%ANSYS_RUNTIME_PYTHON%" (
|
||||
echo [INFO] Creating ANSYS Python runtime...
|
||||
call :create_python_runtime "%ANSYS_RUNTIME_DIR%" "%~1"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Failed to create the ANSYS Python runtime.
|
||||
goto :failed
|
||||
)
|
||||
)
|
||||
if not exist "%ANSYS_RUNTIME_PYTHON%" (
|
||||
echo [ERR] Python venv did not create its interpreter: %ANSYS_RUNTIME_PYTHON%
|
||||
goto :failed
|
||||
)
|
||||
"%ANSYS_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Existing ANSYS runtime is not Python 3.12: %ANSYS_RUNTIME_PYTHON%
|
||||
goto :failed
|
||||
)
|
||||
echo [INFO] Installing ANSYS worker dependencies...
|
||||
"%ANSYS_RUNTIME_PYTHON%" -m pip install --index-url "%ZCBOT_PIP_INDEX_URL%" --requirement "%ANSYS_REQUIREMENTS%"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Failed to install the ANSYS worker dependencies.
|
||||
goto :failed
|
||||
)
|
||||
"%ANSYS_RUNTIME_PYTHON%" -c "from ansys.mechanical.core import App; print('[OK] ANSYS worker Python packages are available.')"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] ANSYS runtime import verification failed.
|
||||
goto :failed
|
||||
)
|
||||
echo [OK] ANSYS runtime installed: %ANSYS_RUNTIME_PYTHON%
|
||||
|
||||
:install_blender
|
||||
call :detect_blender
|
||||
if errorlevel 1 (
|
||||
echo [WARN] Blender was not detected; skipped the Blender launcher runtime.
|
||||
goto :install_startup
|
||||
)
|
||||
|
||||
echo [INFO] Blender detected.
|
||||
if not exist "%BLENDER_REQUIREMENTS%" (
|
||||
echo [ERR] Blender adapter requirements marker is missing: %BLENDER_REQUIREMENTS%
|
||||
goto :failed
|
||||
)
|
||||
if not exist "%BLENDER_RUNTIME_PYTHON%" (
|
||||
echo [INFO] Creating Blender launcher runtime...
|
||||
call :create_python_runtime "%BLENDER_RUNTIME_DIR%" "%~1"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Failed to create the Blender launcher runtime.
|
||||
goto :failed
|
||||
)
|
||||
)
|
||||
if not exist "%BLENDER_RUNTIME_PYTHON%" (
|
||||
echo [ERR] Python venv did not create its interpreter: %BLENDER_RUNTIME_PYTHON%
|
||||
goto :failed
|
||||
)
|
||||
"%BLENDER_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Existing Blender launcher runtime is not Python 3.12: %BLENDER_RUNTIME_PYTHON%
|
||||
goto :failed
|
||||
)
|
||||
echo [OK] Blender launcher runtime installed: %BLENDER_RUNTIME_PYTHON%
|
||||
|
||||
:install_startup
|
||||
echo [INFO] Registering startup task for %USERDOMAIN%\%USERNAME%...
|
||||
schtasks.exe /Create /TN "Zcbot Windows Node" /SC ONLOGON /RU "%USERDOMAIN%\%USERNAME%" /RL LIMITED /IT /TR "\"%NODE_EXE%\"" /F
|
||||
if errorlevel 1 (
|
||||
echo [ERR] Failed to register the Windows Node startup task.
|
||||
goto :failed
|
||||
)
|
||||
|
||||
echo [OK] Windows Node installation completed.
|
||||
echo [INFO] Start Zcbot.WindowsNode.exe to register or connect the node.
|
||||
goto :finish_ok
|
||||
|
||||
:detect_origin
|
||||
reg.exe query "HKCR\Origin.ApplicationSI\CLSID" >nul 2>&1
|
||||
if not errorlevel 1 exit /b 0
|
||||
reg.exe query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" /s /f "OriginLab" /d >nul 2>&1
|
||||
if not errorlevel 1 exit /b 0
|
||||
reg.exe query "HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" /s /f "OriginLab" /d >nul 2>&1
|
||||
if not errorlevel 1 exit /b 0
|
||||
reg.exe query "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" /s /f "OriginLab" /d >nul 2>&1
|
||||
if not errorlevel 1 exit /b 0
|
||||
exit /b 1
|
||||
|
||||
:detect_ansys_242
|
||||
if defined AWP_ROOT242 if exist "%AWP_ROOT242%\aisol\bin\winx64\AnsysWBU.exe" exit /b 0
|
||||
if exist "C:\Program Files\ANSYS Inc\v242\aisol\bin\winx64\AnsysWBU.exe" exit /b 0
|
||||
exit /b 1
|
||||
|
||||
:detect_blender
|
||||
if defined ZCBOT_BLENDER_EXE if exist "%ZCBOT_BLENDER_EXE%" exit /b 0
|
||||
where.exe blender.exe >nul 2>&1
|
||||
if not errorlevel 1 exit /b 0
|
||||
for /d %%D in ("%ProgramFiles%\Blender Foundation\Blender *") do (
|
||||
if exist "%%~fD\blender.exe" exit /b 0
|
||||
)
|
||||
exit /b 1
|
||||
|
||||
:create_python_runtime
|
||||
if not "%~2"=="" (
|
||||
if not exist "%~2" (
|
||||
echo [ERR] Python executable was not found: %~2
|
||||
exit /b 1
|
||||
)
|
||||
"%~2" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
|
||||
if errorlevel 1 (
|
||||
echo [ERR] The specified Python must be Python 3.12: %~2
|
||||
exit /b 1
|
||||
)
|
||||
echo [INFO] Using Python 3.12: %~2
|
||||
"%~2" -m venv "%~1"
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
|
||||
where.exe py.exe >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
py.exe -3.12 -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [INFO] Using Python 3.12 through py.exe.
|
||||
py.exe -3.12 -m venv "%~1"
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
)
|
||||
|
||||
where.exe python.exe >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
python.exe -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo [INFO] Using Python 3.12 from PATH.
|
||||
python.exe -m venv "%~1"
|
||||
exit /b %ERRORLEVEL%
|
||||
)
|
||||
)
|
||||
|
||||
echo [ERR] Python 3.12 was not found.
|
||||
echo [INFO] Add Python 3.12 to PATH or run: install-windows-node.bat "C:\path\python.exe"
|
||||
exit /b 1
|
||||
|
||||
:failed
|
||||
echo.
|
||||
echo [ERR] Installation failed.
|
||||
echo [INFO] Press any key to close this window.
|
||||
pause >nul
|
||||
exit /b 1
|
||||
|
||||
:finish_ok
|
||||
echo.
|
||||
echo [INFO] Press any key to close this window.
|
||||
pause >nul
|
||||
exit /b 0
|
||||
|
|
@ -62,7 +62,6 @@ if errorlevel 1 (
|
|||
|
||||
for %%F in (
|
||||
"Zcbot.WindowsNode.exe"
|
||||
"install-windows-node.bat"
|
||||
) do (
|
||||
if not exist "!PUBLISH_DIR!\%%~F" (
|
||||
echo [ERR] Published package is incomplete: %%~F
|
||||
|
|
@ -108,7 +107,7 @@ if not defined SHA256 (
|
|||
|
||||
echo [OK] Package: !ARCHIVE_PATH!
|
||||
echo [OK] SHA256: !SHA256!
|
||||
echo [INFO] Extract the ZIP and double-click install-windows-node.bat.
|
||||
echo [INFO] Extract the ZIP and start Zcbot.WindowsNode.exe.
|
||||
goto :finish_ok
|
||||
|
||||
:failed
|
||||
|
|
|
|||
Loading…
Reference in New Issue