diff --git a/CHANGELOG.md b/CHANGELOG.md index a2a3ec0..f690aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。 + - 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。 - Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。 diff --git a/DESIGN.md b/DESIGN.md index ff85b89..121040a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 物理目录只使用 `/workspaces//`,不再按 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 物理目录只使用 `/workspaces//`,不再按 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` 明确保持原发布流程。 diff --git a/PROGRESS.md b/PROGRESS.md index cdff146..91e8170 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 检查通过,未迁移真实节点数据、未连接生产数据库。 diff --git a/RUN.md b/RUN.md index 9feccb2..66c9d76 100644 --- a/RUN.md +++ b/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`。解释器位于 `\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`,解释器位于 `\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 目录固定为 `\workspaces\\`,不使用 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/`;有对应活动任务时拒绝替换。安装要求本机存在 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。 -注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号执行统一安装器、注册并运行 Node。当前 MVP 以该账号的登录后计划任务启动,不安装 Windows Service。 +注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。 直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。 diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index ac2bc21..3d4467e 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -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) diff --git a/windows-node/README.md b/windows-node/README.md index bdbede0..3516643 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -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 位于 `\runtimes\\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 位于 `\runtimes\\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 diff --git a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs b/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs index 1453bab..f97ab73 100644 --- a/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs +++ b/windows-node/Zcbot.WindowsNode/AdapterProcessRunner.cs @@ -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."); } diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs index ef101e7..5bbb8e6 100644 --- a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs +++ b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs @@ -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 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? 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(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); } diff --git a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs index 7bf18ce..0beebf6 100644 --- a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs +++ b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs @@ -13,6 +13,7 @@ internal interface INodeAdapter IReadOnlyList 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 PreviewOutputIds { get; } AdapterRuntimeStatus DetectRuntime(); + void InvalidateRuntime(); Task RunLocalAcceptanceAsync( string workRoot, IProgress progress, @@ -249,6 +251,7 @@ internal sealed class ProcessNodeAdapter : INodeAdapter public IReadOnlyList 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 DiscoverDescriptors() { if (!Directory.Exists(AdapterRoot)) return []; diff --git a/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs b/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs index 247d9bc..23aa202 100644 --- a/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs +++ b/windows-node/Zcbot.WindowsNode/NodeDataRoot.cs @@ -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"); } diff --git a/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs b/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs new file mode 100644 index 0000000..2f8c862 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs @@ -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 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 InstallRuntimeAsync( + SoftwareDefinition definition, + IProgress 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 UninstallLocations(string displayNameFragment) + { + var result = new List(); + 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 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 arguments, + string workingDirectory, + IProgress? 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? 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 PrefixArguments); +} diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj index fbba6e7..22d729a 100644 --- a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -40,10 +40,5 @@ PreserveNewest PreserveNewest - - install-windows-node.bat - PreserveNewest - PreserveNewest - diff --git a/windows-node/install-windows-node.bat b/windows-node/install-windows-node.bat deleted file mode 100644 index 0fa2975..0000000 --- a/windows-node/install-windows-node.bat +++ /dev/null @@ -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 diff --git a/windows-node/package-windows-node.bat b/windows-node/package-windows-node.bat index 4c2c078..dd7e4de 100644 --- a/windows-node/package-windows-node.bat +++ b/windows-node/package-windows-node.bat @@ -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