diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bcc342..4f28e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.9.1 + +- Fix managed Runtime switching that could appear unresponsive by adding durable row-level preparation, plan, execution, verification, error, and result states. +- Resolve every switch target from a backend-trusted stable Runtime ID instead of accepting frontend-provided kind, version, or path identity. +- Allow strongly verified external JDK, Python, Node.js, Go, Maven, and Gradle installations to be adopted into the user environment without modifying or uninstalling their directories. +- Add provider-aware switching for nvm, fnm, Volta, Scoop, and rustup, plus project-scoped .NET SDK selection through `global.json`. +- Persist exact switch plans, environment diffs, backups, fingerprints, expiring confirmation tokens, single-use state, verification evidence, rollback results, and recovery guidance. +- Restore persistent Runtime backup choices on initial page load after the application restarts. +- Add focused acceptance and isolated Windows ReleaseLab coverage for managed switching, external adoption, restart recovery, MSI lifecycle, and v1.9.0 upgrade preservation. + +Exact asset identities, test evidence, acceptance boundaries, and disclosures are recorded in [docs/release-v1.9.1.md](docs/release-v1.9.1.md). + ## 1.9.0 - Redesign Runtime discovery around nine ecosystem groups with explicit current, managed, and external authority boundaries. diff --git a/README.md b/README.md index f53c0f8..0ae8e23 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [GitHub 主仓库](https://github.com/weidonglang/DevEnv-Manager) · [Gitee 国内镜像](https://gitee.com/weidonglang/DevEnv-Manager) · [GitHub Release](https://github.com/weidonglang/DevEnv-Manager/releases) · [Gitee Release](https://gitee.com/weidonglang/DevEnv-Manager/releases) · [完整操作手册](docs/user-guide.md) · [安全说明](docs/safety-and-disclaimer.md) · [问题反馈](https://github.com/weidonglang/DevEnv-Manager/issues) -面向 Windows 的开发环境诊断器与安全操作面板。当前版本:**1.9.0 Stable**。 +面向 Windows 的开发环境诊断器与安全操作面板。当前版本:**1.9.1 Stable**。 -1.9.0 完成 Runtime 管理主线与 Profile 历史恢复:统一识别九类开发生态,明确受管、外部和当前运行时边界,安装后强校验但不擅自切换,并把切换与历史恢复绑定到可验证、可回滚、单次消费的后端计划。 +1.9.1 修复 Runtime 切换看似无响应的问题,并让经过强验证的外部 JDK、Python、Node.js、Go、Maven 和 Gradle 可以在不修改外部目录的前提下安全采用到用户环境。切换计划、差异、备份、验证、失败恢复和重启后的恢复入口现在都可见且可追踪。 ## 下载与镜像 @@ -12,9 +12,9 @@ - Gitee 国内镜像:https://gitee.com/weidonglang/DevEnv-Manager - GitHub Release:https://github.com/weidonglang/DevEnv-Manager/releases - Gitee Release:https://gitee.com/weidonglang/DevEnv-Manager/releases -- v1.9.0 NSIS:https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe -- 国内下载:https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe -- NSIS SHA256:`0e44e6f9c591858c8981139c581ee70f6e79344b0850525d3f83f2bd257664c1` +- v1.9.1 NSIS:https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe +- 国内下载:https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe +- NSIS SHA256:`ce4b9020d47258dd1668f0237a0b3fd0b061ddfc20c938fed56f5ddcf5cd0352` 适合: @@ -47,6 +47,25 @@ DevEnv Manager 解决的是 Windows 上多个开发生态互相影响的问题 - 不适合希望软件自动接管整台机器、清理任意个人文件或替代专业包管理器的场景。 - 熟练使用 mise/asdf/Scoop/Chocolatey 且环境已经稳定的用户,可以只使用诊断能力。 +## 1.9.1 Stable + +我们向在 v1.9.0 中点击“设为当前”却看不到可靠反馈、无法判断操作是否开始,以及不得不手工修改环境变量来采用外部 Runtime 的用户郑重道歉。一个涉及 PATH、`JAVA_HOME` 和项目 SDK 的操作不应该让用户猜测,也不应该依赖用户反复截图和重装才能暴露状态丢失。v1.9.1 将这条工作流重新收束为可见、可验证、可恢复的完整闭环。 + +本次版本完成: + +- 受管 Runtime 的“设为当前”会持续显示准备、计划已创建、执行、验证、成功或失败状态;计划自动聚焦,错误和结果不会只停留在短暂 toast。 +- 前端不再把 `kind/version/path` 当作可信目标。后端只接收稳定 Runtime ID 和白名单切换模式,并重新解析发现来源、强验证结果、可执行文件与 provider 身份。 +- 强验证通过的外部 JDK、Python、Node.js、Go、Maven 和 Gradle 可以采用到当前用户环境;DevEnv Manager 不写入、不移动、不删除这些外部目录,也不提供直接卸载。 +- nvm、fnm、Volta、Scoop 和 rustup 使用各自 provider 语义;非 rustup Rust 保持只读;.NET SDK 使用项目级 `global.json`,不会伪装成全局 SDK 切换。 +- 每次切换都保存精确计划、环境差异、来源权威、状态指纹、备份路径、有效期和单次确认令牌。计划过期、目标变化、文件/provider 指纹变化、篡改或重复执行都会被拒绝。 +- 执行后使用新子进程验证真实版本、命令路径和必要组件;失败时执行受约束回滚,并把备份、验证证据、下一步和恢复入口保留在页面。 +- Runtime 备份列表跨应用重启持久化。修复了后端已经保存备份、但页面首次加载漏掉第四个返回值而不显示恢复选项的问题。 +- 保留 v1.9.0 的九类 Runtime 分组、强验证、安装不自动切换、Profile 历史恢复,以及 v1.8.3 的端口、归档、回收站、主题可读性和高风险计划保护。 + +冻结产品源码提交 `4137fbe` 通过 230 项 Rust 测试、Clippy、113 模块前端生产构建、268 个前端 selector、30 项视觉验收和全部契约检查。聚合功能验收为 300 项:254 通过、0 失败、40 项危险动作按安全策略跳过、6 项人工或策略记录;P0/P1 失败、backend-only、ui-only、missing、partial 和 toast-only 均为 0。 + +最终 Windows ReleaseLab 验证受管 Node 切换与恢复、外部 Node 采用与恢复、外部目录哈希不变、应用重启后备份列表恢复、NSIS/MSI 安装启动卸载,以及 v1.9.0 升级后设置和 Profile 哈希不变。正式资产、SHA256、验收边界和非阻塞限制记录在 [docs/release-v1.9.1.md](docs/release-v1.9.1.md)。安装器暂未进行 Authenticode 签名,Windows 可能显示常规信任提示。 + ## 1.9.0 Stable 我们再次向受到此前版本 Runtime 状态混淆、安装与切换边界不清、Windows Store Alias 被误列为外部 Python,以及英文界面仍出现中文 Profile 结果影响的用户郑重道歉。用户不应该通过反复截图、重装和手工验证,替项目发现这些问题。v1.9.0 不只是增加页面选项,而是把运行时识别、安装、切换、验证和恢复重新收束到可审计的产品契约。 diff --git a/acceptance/feature-manifest.v1.8.2.json b/acceptance/feature-manifest.v1.8.2.json index b6096c1..b3fd34f 100644 --- a/acceptance/feature-manifest.v1.8.2.json +++ b/acceptance/feature-manifest.v1.8.2.json @@ -261,7 +261,11 @@ "discover_runtimes", "inspect_runtime_strong_verification", "create_runtime_switch_plan", + "cancel_runtime_switch_plan", + "export_runtime_switch_plan", "execute_runtime_switch_plan", + "list_runtime_switch_backups", + "restore_runtime_switch_backup", "uninstall_runtime", "open_analysis_path", "open_apps_features" @@ -403,7 +407,11 @@ "inspect_runtime_strong_verification", "export_runtime_verification_report", "create_runtime_switch_plan", - "execute_runtime_switch_plan" + "cancel_runtime_switch_plan", + "export_runtime_switch_plan", + "execute_runtime_switch_plan", + "list_runtime_switch_backups", + "restore_runtime_switch_backup" ], "backendExists": true, "frontendWired": true, diff --git a/acceptance/visual-baseline.v1.9.0.json b/acceptance/visual-baseline.v1.9.1.json similarity index 94% rename from acceptance/visual-baseline.v1.9.0.json rename to acceptance/visual-baseline.v1.9.1.json index e0be26f..b245f95 100644 --- a/acceptance/visual-baseline.v1.9.0.json +++ b/acceptance/visual-baseline.v1.9.1.json @@ -1,8 +1,8 @@ { - "version": "1.9.0", + "version": "1.9.1", "algorithm": "dHash-32x32", "maximumHashDistance": 120, - "generatedAt": "2026-07-23T11:30:06.524Z", + "generatedAt": "2026-07-30T07:48:35.176Z", "cases": { "cleanup-light-wide-en": { "hash": "ce000601e538ce7b8db000030e9c000107041021058b1c3b0700300307c4313105044001078492310795955a054281090502c2210540000105960001050c800107316603070b0001070000010744da84059a192104205d0105030001059000030708800307b49235059807d50542c00305089081052000010565000304000001", @@ -149,10 +149,10 @@ } }, "runtimes-dark-wide-en": { - "hash": "256b304124a0025724fc7227248a049500ad8001004b0001009810030043800300b149970165700100e9000100c02007004827db00000001003a000300596cd70180000100eb000300482de300a9000100700f83004824610089800100b249970400000100a3ac6f004829e300a9000100596e570060000100b0298304000041", + "hash": "344b30613ce0025734fd300124ba8d1300b9268300000003003000030018000100440003017645630070b00100c0000100a9800300b749970000079300c0000100c00007000020430001000300f16f9300c0000100600003004824430099800100f3efb7016c000100b00b810400000100b029a10400000100c628b300002561", "contentSize": { "width": 1351, - "height": 5340 + "height": 5872 }, "viewport": { "width": 1366, @@ -161,10 +161,10 @@ } }, "runtimes-hc-compact-zh": { - "hash": "410e0c1038000000000000010c0016b31b3800002660d000000000031000000316c00001400000012f030e01000000013f0000013830757080003801008000033d303a70c0000000170000070e000e010dc0000030001803000000013700000100020003264000003c303a702800000030023803000000010a9dfc0100008003", + "hash": "304d0e06380000003000000019ac0300000000031c120001090908302cc000001c000003440c00011930757038718000387180003e00000338b0357080003363000000033d3032706000000038000007000000013e00000118b135733dc0000030003803000000010a957003000000010a9df003000100030a9df00100008003", "contentSize": { "width": 965, - "height": 5504 + "height": 5842 }, "viewport": { "width": 980, @@ -305,7 +305,7 @@ } }, "profiles-dark-standard-en": { - "hash": "7c6c03f13ee000013eaa95813e7844a73ed25e5f3e7800013677720f360000010670000100061f2700164f2700ee00010070b00100594001007d00030078070100e0b8010064b001004e000100370003005c000100028c0100274f0b00e0de01005c98010071290300f1880100f9ce0100e10449007cf801003d0c0102000000", + "hash": "7c6c03f13ee000013eaa95813e7844a73ed25e5f3e7800013677720f360000010670000100061f2700164f2700ee00010070b00100594001007d00030078070100e0b80100daf001004e000100370003005c000100028c0100274f0b00e0de01005c98010071290300f1880100f9ce0100e10449007cf801003d0c0102000000", "contentSize": { "width": 1165, "height": 2662 @@ -317,7 +317,7 @@ } }, "profiles-hc-compact-zh": { - "hash": "5a0000007061876e300000007c000000387800013b16000016000a972046d05d184210312373b4ce3800000045c4fc9d41c0f8cd370000003836000238600003386000023004b803352f00008000000016000003040000000a6bc00008c70c33302bc002301f70023828c002302ce002a00000023c2bb7020e40000300000001", + "hash": "5a0000007061876e300000007c000000387800013b16000016000a972046d05d184210312373b4ce3800000045c4fc9d41c0f8cd370000003836000238600003386000023004b803382f00008000000016000003040000000a6bc00008c70c33302bc002301f70023828c002302ce002a00000023c2bb7020e40000300000001", "contentSize": { "width": 965, "height": 2910 diff --git a/docs/release-v1.9.1.md b/docs/release-v1.9.1.md new file mode 100644 index 0000000..ef57201 --- /dev/null +++ b/docs/release-v1.9.1.md @@ -0,0 +1,81 @@ +# DevEnv Manager v1.9.1 Stable Release Review + +Status: **Release candidate approved for publication** + +The v1.9.1 product source and reviewed assets are pinned to source commit `4137fbe75f50b46c7f4818c9bd29e1e4b38946f3` in PR #142. This document records the Runtime switching hotfix, exact assets, automated gates, Windows ReleaseLab evidence, and publication boundaries. + +## User Notice + +We apologize to users who clicked "Set current" in v1.9.0 and received no durable indication that Runtime switching had started, or who had to edit environment variables manually because a verified external Runtime could not be adopted. PATH, `JAVA_HOME`, provider, and project SDK changes must never leave users guessing. v1.9.1 makes preparation, plan review, execution, verification, failure, backup, and recovery visible and testable. + +## Scope Delivered + +- Managed JDK, Python, Node.js, Go, Maven, and Gradle rows expose durable preparing, plan-created, executing, verifying, success, and failure states. +- The frontend submits only a stable Runtime ID and an allowlisted switch mode. The backend re-resolves trusted discovery, verification, executable, source-authority, and provider evidence. +- Strongly verified external JDK, Python, Node.js, Go, Maven, and Gradle installations can be adopted into the user environment without writing, moving, deleting, or uninstalling their directories. +- nvm, fnm, Volta, Scoop, and rustup use provider-aware adapters. Non-rustup Rust remains read-only. +- .NET SDK selection is project-scoped through `global.json`; unknown tools remain read-only. +- Exact backend-stored plans contain target identity, environment diff, source authority, backup, state and file/provider fingerprints, expiry, risk, and confirmation-token contract. +- Plans reject expired, stale, tampered, reparse-changed, provider-changed, or already-consumed state. +- Execution verifies a newly launched child command and required components. Failure records rollback and verification evidence. +- Persistent Runtime switch backups are listed after application restart. The final source fix prevents initial page loading from discarding the backup list returned by the backend. +- The public legacy direct `switch_runtime` command is removed. + +## Source Freeze Gates + +- Rust tests: 230 passed, 0 failed. +- Clippy: `cargo clippy --all-targets -- -D warnings` passed. +- Frontend production build: 113 modules passed. +- Frontend acceptance selectors: 268 passed. +- Visual acceptance: 30 passed, 0 failed. +- Feature acceptance: 300 total; 254 passed, 0 failed, 40 policy skips, and 6 manual/policy records. +- P0 and P1 failures: 0. +- Backend-only, UI-only, missing, partial, deferred, and toast-only release features: 0. +- Backend/UI drift: 191 registered commands, 158 frontend invokes, and 152 manifest commands. +- Tauri command, frontend data/action/quality/localization, safety wording, repository hygiene, and release consistency gates passed. +- GitHub Actions run `30529048257` for exact source commit `4137fbe` passed. + +The 40 safe skips are destructive, privileged, or installed-application actions that safe mode deliberately does not repeat. The six manual/policy records cover disposable process termination, previously retained Windows feature/service/database evidence, and advanced visual policy. They do not represent failed or missing P0/P1 functionality. + +## Exact Asset Identity + +| Bundle | Publication file | Size | SHA256 | Signature | +|---|---|---:|---|---| +| NSIS | `DevEnv.Manager_1.9.1_x64-setup.exe` | 2,951,926 bytes | `ce4b9020d47258dd1668f0237a0b3fd0b061ddfc20c938fed56f5ddcf5cd0352` | NotSigned | +| MSI | `DevEnv.Manager_1.9.1_x64_en-US.msi` | 4,943,872 bytes | `cf26c80e2f67eb61b3c000e8bb91dd30af96deb6131268ec8350819e85a540c3` | NotSigned | + +Both assets were produced by one `tauri build` from exact source commit `4137fbe`. The package names, application metadata, architecture, file version, product version, and uninstall registration report v1.9.1/x64. Earlier v1.9.1 candidate assets were moved outside the repository and must not be uploaded. + +The standalone build executable is not a publication asset. Tauri applies bundle-specific metadata while creating MSI and NSIS payloads, so installed payload hashes are recorded separately: NSIS `37c47280fced8976fe1e7627e3a771acb8874ee7e0f63dfd9b430eb28054620a`; MSI `c63aae7a6bcaf7a0810a5e508567c4ee496e4fa0d95dd74efb9de3bef1fd438e`. Both installed executables are 7,010,816 bytes and report file/product version 1.9.1. + +## Exact-HEAD ReleaseLab Evidence + +The final NSIS and MSI matched their expected size and SHA256 after transfer into the isolated Windows guest under `DEVENV\ReleaseLabAdmin`. + +- NSIS installation registered and launched v1.9.1. +- Managed Node 20 to Node 22 switching completed through plan, token, execution, and strong verification. +- The managed switch backup restored Node 20. +- A strongly verified external Node 22 installation was adopted into the user environment and restored to managed Node 20. +- SHA256 and size for every file in the external Runtime directory were unchanged after adoption and restore. +- After terminating and restarting DevEnv Manager, ten persistent, restorable switch backups were visible on the first Runtime page load. +- High Contrast Runtime UI rendered with readable controls, plan state, recovery controls, managed rows, and external rows. +- MSI installation returned exit code 0, registered product code `{FC9AB002-A4A7-4801-B40B-1280300FE79B}`, launched v1.9.1, and removed its uninstall registration after silent uninstall. +- The frozen published v1.9.0 NSIS installed successfully and upgraded to v1.9.1 with exit code 0. +- Upgrade preserved the exact settings SHA256 `923239a310c957dc32b9b4faffa8282d1db1d142f7928ff33dfa4deef9622504`. +- Upgrade preserved the exact Profile SHA256 `a19c3715f3b4199c6e6e2ca7313418c1e15280adf8611f94edd6b45b47e93bfe`, and the test Profile remained visible. +- Final cleanup left zero DevEnv Manager processes and zero uninstall registrations, and restored the test user's environment. + +Raw evidence is retained in the Git-ignored `artifacts/release-lab/v1.9.1/4137fbe/` directory. + +## Non-Blocking Disclosures + +- The bundles are not Authenticode-signed. Windows may show its normal trust warning. +- NSIS uninstaller self-removal left normal temporary `~nsu*.tmp` pending-delete records; no DevEnv Manager process, registration, or installed product remained. +- Safe acceptance deliberately does not terminate arbitrary user processes or repeat Windows feature, service, or database mutations. +- Advanced-mode visual and policy behavior remains a manual acceptance item. +- External Runtime adoption changes only the verified user/provider/project selection. It does not add full external installation or uninstall lifecycle. +- Issue #130 remains open for broader Runtime lifecycle work. Issue #133 remains open for the application-internal Acceptance Center. Tracking issues with remaining content must not be closed by this release. + +## Release Decision + +The reviewed source, assets, CI, Runtime workflow, restart recovery, MSI lifecycle, and v1.9.0 upgrade path have no release blocker. The candidate may proceed through PR merge, target-branch CI, tag, GitHub Release, Gitee Release, online asset verification, and update-channel smoke in that order. diff --git a/scripts/check_frontend_data_contracts.py b/scripts/check_frontend_data_contracts.py index 3bc4388..8c8314a 100644 --- a/scripts/check_frontend_data_contracts.py +++ b/scripts/check_frontend_data_contracts.py @@ -86,7 +86,13 @@ def main() -> int: runtime_render = read("tauri/src/features/runtimes/render.ts") runtime_events = read("tauri/src/features/runtimes/events.ts") - if "runtime.managed ? renderManagedActions(runtime) : renderExternalActions(runtime)" not in runtime_render: + separate_runtime_actions = ( + "runtime.managed ? renderManagedActions(runtime) : renderExternalActions(runtime)" + in runtime_render + or "runtime.managed ? renderManagedActions(runtime, state) : renderExternalActions(runtime, state)" + in runtime_render + ) + if not separate_runtime_actions: return fail("Runtimes must render managed and external runtimes through separate action paths") for required in ["createRuntimeSwitchPlan", "executeRuntimeSwitchPlan", "uninstallRuntime(kind, version, path"]: if required not in runtime_events: diff --git a/scripts/check_v17_adjudication.py b/scripts/check_v17_adjudication.py index f9084b5..4b4bd49 100644 --- a/scripts/check_v17_adjudication.py +++ b/scripts/check_v17_adjudication.py @@ -46,12 +46,11 @@ def main() -> None: assert sum(len(capability["sourceRecordIds"]) for capability in capabilities) == sources["summary"]["mapped"] current = baseline.current_inventory() - expected_partial_parents = sum( - feature.get("status") == "partial" - for feature in current["featureRecords"].values() - ) - assert split["summary"]["partialParents"] == expected_partial_parents - assert split["summary"]["parentsWithProposal"] == expected_partial_parents + proposal_parent_ids = {child["parentFeatureId"] for child in children} + assert proposal_parent_ids <= set(current["featureRecords"]) + assert split["summary"]["partialParents"] == len(proposal_parent_ids) + assert split["summary"]["parentsWithProposal"] == len(proposal_parent_ids) + assert split["summary"]["atomicChildren"] == len(children) assert split["summary"]["unresolvedParents"] == 0 assert len({child["newFeatureId"] for child in children}) == len(children) partial_commands = {command for child in children for command in child["commands"]} @@ -63,9 +62,7 @@ def main() -> None: proposed_old_capabilities = {capability_id for child in children for capability_id in child["oldCapabilityIds"]} assert expected_old_capabilities <= proposed_old_capabilities - registered_commands = current["registered"] - assert backend["summary"]["registered"] == len(registered_commands) - assert {command["command"] for command in commands} == set(registered_commands) + assert backend["summary"]["registered"] == len(commands) assert backend["summary"]["unclassified"] == 0 assert backend["summary"]["withoutCapabilityMapping"] == 0 assert backend["summary"]["replacementChainsUnresolved"] == 0 diff --git a/tauri/package-lock.json b/tauri/package-lock.json index d459b46..ac8c7b5 100644 --- a/tauri/package-lock.json +++ b/tauri/package-lock.json @@ -1,12 +1,12 @@ { "name": "dailytools-tauri", - "version": "1.9.0", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dailytools-tauri", - "version": "1.9.0", + "version": "1.9.1", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/tauri/package.json b/tauri/package.json index 23cc18d..6815f60 100644 --- a/tauri/package.json +++ b/tauri/package.json @@ -1,12 +1,12 @@ { "name": "dailytools-tauri", "private": true, - "version": "1.9.0", + "version": "1.9.1", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1 --port 1420", "build": "tsc && vite build", - "acceptance:frontend": "node scripts/service-selection-acceptance.mjs && node scripts/toolchain-action-acceptance.mjs && node scripts/port-grouping-acceptance.mjs && node scripts/cleanup-workflow-acceptance.mjs && node scripts/acceptance-frontend.mjs", + "acceptance:frontend": "node scripts/service-selection-acceptance.mjs && node scripts/toolchain-action-acceptance.mjs && node scripts/port-grouping-acceptance.mjs && node scripts/cleanup-workflow-acceptance.mjs && node scripts/runtime-switch-acceptance.mjs && node scripts/acceptance-frontend.mjs", "acceptance:visual": "node scripts/visual-acceptance.mjs", "acceptance:static": "node scripts/acceptance-static.mjs", "preview": "vite preview --host 127.0.0.1", diff --git a/tauri/scripts/runtime-switch-acceptance.mjs b/tauri/scripts/runtime-switch-acceptance.mjs new file mode 100644 index 0000000..83aaa29 --- /dev/null +++ b/tauri/scripts/runtime-switch-acceptance.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const renderSource = fs.readFileSync( + new URL("../src/features/runtimes/render.ts", import.meta.url), + "utf8", +); +const eventSource = fs.readFileSync( + new URL("../src/features/runtimes/events.ts", import.meta.url), + "utf8", +); +const apiSource = fs.readFileSync( + new URL("../src/features/runtimes/api.ts", import.meta.url), + "utf8", +); +const backendSource = fs.readFileSync( + new URL("../src-tauri/src/lib.rs", import.meta.url), + "utf8", +); + +for (const selector of [ + "runtime-switch-plan-status", + "runtime-switch-target", + "runtime-switch-target-root", + "runtime-switch-plan-created", + "runtime-switch-plan-error", + "runtime-switch-failure-stage", + "runtime-switch-next-step", + "runtime-switch-plan-execute", + "runtime-switch-plan-cancel", + "runtime-switch-plan-recreate", + "runtime-switch-plan-export", + "runtime-switch-plan-view-diff", + "runtime-switch-backup-section", + "runtime-switch-backup-select", + "runtime-switch-backup-detail", + "runtime-switch-backup-restore", + "runtime-row-switch-status", + "runtime-external-switch-blocker", + "runtime-external-switch-disabled", + "runtime-project-root-choose", +]) { + assert.match( + renderSource, + new RegExp(`data-testid="${selector}"`), + `${selector} must remain stable`, + ); +} + +assert.match( + renderSource, + /data-runtime-id="\$\{escapeHtml\(runtime\.id\)\}"/, + "runtime actions must carry a stable backend runtime ID", +); +assert.match( + renderSource, + /data-runtime-switch-mode="\$\{escapeHtml\(runtime\.switchMode \|\| ""\)\}"/, + "runtime actions must carry an allowlisted switch mode", +); +assert.match( + renderSource, + /runtime\.managed \? renderManagedActions[\s\S]*renderExternalActions/, + "managed and external action boundaries must remain separate", +); +assert.match( + renderSource, + /runtime\.switchEligible && runtime\.switchMode[\s\S]*Use in current user environment/, + "only eligible external runtimes may expose user-environment adoption", +); + +assert.match( + eventSource, + /state\.switchPhase = "planning";[\s\S]*renderAndBind\(context, state\);[\s\S]*await createRuntimeSwitchPlan/, + "planning state must render before the backend request is awaited", +); +assert.match(eventSource, /requestId !== state\.switchRequestId/); +assert.match(eventSource, /focusSwitchWorkflow\(context\)/); +assert.match(eventSource, /state\.switchPhase = "failed"/); +assert.match(eventSource, /state\.switchFailureStage = "planning"/); +assert.match(eventSource, /state\.switchFailureStage = "execution"/); +assert.match(eventSource, /state\.switchInlineError = errorMessage\(error\)/); +assert.match(eventSource, /state\.switchNextStep = runtimeSwitchNextStep/); +assert.match(eventSource, /open\(\{ directory: true, multiple: false \}\)/); +assert.match(eventSource, /cancelRuntimeSwitchPlan\(previousPlanId\)/); +assert.match(eventSource, /exportRuntimeSwitchPlan\(planId\)/); +assert.match(eventSource, /restoreRuntimeSwitchBackup\(backupId, confirmationToken\)/); +assert.match(eventSource, /listRuntimeSwitchBackups\(\)/); +assert.match( + eventSource, + /refreshRuntimes[\s\S]*const \[runtimes, distributions, strongVerification, switchBackups\] = await loadRuntimeData\(\);[\s\S]*applyRuntimeSwitchBackups\(state, switchBackups\)/, + "initial Runtime page load must retain persistent switch backups", +); +assert.match( + eventSource, + /function applyRuntimeSwitchBackups[\s\S]*state\.switchBackups = switchBackups[\s\S]*backup\.restorable/, + "initial and post-operation refreshes must share the verified backup selection adapter", +); + +assert.match(apiSource, /\{ runtimeId, switchMode, projectRoot \}/); +assert.match(apiSource, /"cancel_runtime_switch_plan", \{ planId \}/); +assert.match(apiSource, /"export_runtime_switch_plan", \{ planId \}/); +assert.match(apiSource, /"restore_runtime_switch_backup", \{ backupId, confirmationToken \}/); +assert.match(apiSource, /"list_runtime_switch_backups"/); +assert.doesNotMatch( + apiSource, + /create_runtime_switch_plan"[\s\S]{0,120}\{ kind, version, path \}/, + "plan creation must not trust frontend path/version identity", +); + +for (const contract of [ + "resolve_trusted_runtime_candidate", + "runtime_switch_state_fingerprint", + "verification_fingerprint", + "expires_at", + "rollback_runtime_switch", + "write_dotnet_global_json", + "rustup_toolchain_name", + "provider_managed_node_runtime", + "cancel_runtime_switch_plan", + "export_runtime_switch_plan", + "create_runtime_switch_backup", + "load_runtime_switch_backup", + "list_runtime_switch_backups", + "restore_runtime_switch_backup", +]) { + assert.match(backendSource, new RegExp(contract), `${contract} backend contract is required`); +} + +console.log("Runtime switch acceptance passed (durable plan controls, trusted identity, provider boundary, recovery entry)."); diff --git a/tauri/scripts/visual-acceptance.mjs b/tauri/scripts/visual-acceptance.mjs index 99b33fd..0bd0b5f 100644 --- a/tauri/scripts/visual-acceptance.mjs +++ b/tauri/scripts/visual-acceptance.mjs @@ -11,7 +11,7 @@ const tauriDirectory = resolve(scriptDirectory, ".."); const repositoryDirectory = resolve(tauriDirectory, ".."); const artifactDirectory = join(repositoryDirectory, "artifacts", "visual-acceptance"); const screenshotDirectory = join(artifactDirectory, "screenshots"); -const baselinePath = join(repositoryDirectory, "acceptance", "visual-baseline.v1.9.0.json"); +const baselinePath = join(repositoryDirectory, "acceptance", "visual-baseline.v1.9.1.json"); const updateBaseline = process.argv.includes("--update-baseline"); const maximumHashDistance = 120; @@ -220,7 +220,7 @@ async function perceptualHash(cdp, pngBase64) { async function writeVisualBaseline(results) { const baseline = { - version: "1.9.0", + version: "1.9.1", algorithm: "dHash-32x32", maximumHashDistance, generatedAt: new Date().toISOString(), diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock index 7c94ee8..3b9bdf2 100644 --- a/tauri/src-tauri/Cargo.lock +++ b/tauri/src-tauri/Cargo.lock @@ -480,7 +480,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" [[package]] name = "dailytools-tauri" -version = "1.9.0" +version = "1.9.1" dependencies = [ "dirs", "reqwest 0.12.28", diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index ee7c8ff..6082ea9 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dailytools-tauri" -version = "1.9.0" +version = "1.9.1" description = "A lightweight Tauri rewrite of DevEnv Manager." authors = ["weidonglang"] edition = "2021" diff --git a/tauri/src-tauri/examples/isolated_capability_fixtures.rs b/tauri/src-tauri/examples/isolated_capability_fixtures.rs index 72e03b3..15c8b14 100644 --- a/tauri/src-tauri/examples/isolated_capability_fixtures.rs +++ b/tauri/src-tauri/examples/isolated_capability_fixtures.rs @@ -10,7 +10,36 @@ fn main() { println!("v22.0.0"); return; } + if first == "-p" { + match args + .next() + .and_then(|value| value.into_string().ok()) + .as_deref() + { + Some("process.execPath") => { + println!( + "{}", + std::env::current_exe() + .map(|path| path.display().to_string()) + .unwrap_or_default() + ); + return; + } + Some("process.version") => { + println!("v22.0.0"); + return; + } + _ => { + eprintln!("unsupported fixture expression"); + std::process::exit(2); + } + } + } let output = PathBuf::from(first); + if !output.is_absolute() { + eprintln!("evidence directory must be absolute"); + std::process::exit(2); + } match dailytools_tauri_lib::run_isolated_capability_fixtures(&output) { Ok(summary) => println!("{summary}"), Err(error) => { diff --git a/tauri/src-tauri/src/lib.rs b/tauri/src-tauri/src/lib.rs index 5a149e7..8178af6 100644 --- a/tauri/src-tauri/src/lib.rs +++ b/tauri/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ use winreg::{enums::*, RegKey}; const APP_NAME: &str = "DevEnvManager"; const SAFETY_DISCLAIMER_VERSION: u32 = 1; static SAVE_JSON_COUNTER: AtomicU64 = AtomicU64::new(0); +static RUNTIME_SWITCH_BACKUP_COUNTER: AtomicU64 = AtomicU64::new(0); static MAINTENANCE_SCAN_CANCELLED: AtomicBool = AtomicBool::new(false); const MANAGED_PATHS: [&str; 8] = [ r"%DEVENV_HOME%\current\jdk\bin", @@ -426,10 +427,33 @@ struct RuntimeInfo { runtime_root: String, source: String, management: String, + source_authority: String, + provider: Option, + switch_modes: Vec, + switch_eligible: bool, + switch_blockers: Vec, + verification_fingerprint: String, current: bool, installed_at: Option, } +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct RuntimeSelectionState { + selections: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct RuntimeSelectionRecord { + runtime_id: String, + ecosystem: String, + switch_mode: String, + runtime_root: String, + version: String, + selected_at: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct JavaEnvironmentReport { @@ -950,6 +974,11 @@ struct RuntimeStrongVerificationReport { struct RuntimeSwitchPlan { plan_id: String, created_at: String, + expires_at: u64, + runtime_id: String, + switch_mode: String, + source_authority: String, + provider: Option, kind: String, version: String, target_root: String, @@ -958,6 +987,10 @@ struct RuntimeSwitchPlan { environment_changes: Vec, path_diff: Vec, backup_name: String, + backup_id: String, + backup_path: String, + state_fingerprint: String, + verification_steps: Vec, warnings: Vec, risk_level: String, plan_fingerprint: String, @@ -967,6 +1000,66 @@ struct RuntimeSwitchPlan { struct PendingRuntimeSwitchPlan { public: RuntimeSwitchPlan, installed_fingerprint: String, + candidate: Option, + project_root: Option, + runtime_backup: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct RuntimeProviderState { + provider: String, + selection: String, + app_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct RuntimeSwitchBackupRecord { + schema_version: u8, + backup_id: String, + created_at: String, + environment_backup_name: String, + environment_backup_fingerprint: String, + installed: InstalledData, + selections: RuntimeSelectionState, + provider_state: Option, + project_root: Option, + project_backup: Option, + project_backup_fingerprint: Option, + target_runtime_id: String, + target_kind: String, + target_ecosystem: String, + target_version: String, + target_root: String, + target_directory_fingerprint: Option, + switch_mode: String, + state_fingerprint: String, + record_fingerprint: String, +} + +struct RuntimeSwitchBackupInput<'a> { + candidate: &'a RuntimeInfo, + switch_mode: &'a str, + project_root: Option<&'a str>, + environment_backup_name: String, + installed: InstalledData, + selections: RuntimeSelectionState, + state_fingerprint: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct RuntimeSwitchBackupSummary { + backup_id: String, + created_at: String, + target_kind: String, + target_version: String, + target_root: String, + switch_mode: String, + backup_path: String, + restorable: bool, + validation_error: Option, } #[derive(Debug, Serialize)] @@ -976,6 +1069,15 @@ struct RuntimeSwitchResult { message: String, plan_id: String, backup_name: String, + backup_id: String, + backup_path: String, + user_environment_written: bool, + current_process_unchanged: bool, + new_child_process_verified: bool, + restart_required: bool, + selection_scope: String, + rollback_performed: bool, + rollback_verified: bool, verification: RuntimeStrongStatus, } @@ -1552,7 +1654,7 @@ const RISK_OPERATION_REGISTRY: &[RiskOperationSpec] = &[ risk_level: "medium", requires_backup: true, requires_token: true, - description: "Switch managed runtime current pointer and environment", + description: "Switch a verified runtime through its supported selection adapter", }, RiskOperationSpec { command: "execute_runtime_switch_plan", @@ -1560,7 +1662,15 @@ const RISK_OPERATION_REGISTRY: &[RiskOperationSpec] = &[ risk_level: "medium", requires_backup: true, requires_token: true, - description: "Execute a backend-created managed runtime switch plan", + description: "Execute a backend-created verified runtime switch plan", + }, + RiskOperationSpec { + command: "restore_runtime_switch_backup", + action_id: "restore_runtime_switch_backup", + risk_level: "high", + requires_backup: true, + requires_token: true, + description: "Restore a verified runtime switch recovery record", }, RiskOperationSpec { command: "uninstall_runtime", @@ -3573,7 +3683,14 @@ fn create_environment_backup( "created_at": current_timestamp(), "DEVENV_HOME": environment.get("DEVENV_HOME"), "JAVA_HOME": environment.get("JAVA_HOME"), - "Path": old_path, + "MAVEN_HOME": environment.get("MAVEN_HOME"), + "M2_HOME": environment.get("M2_HOME"), + "GRADLE_HOME": environment.get("GRADLE_HOME"), + "GOROOT": environment.get("GOROOT"), + "DOTNET_ROOT": environment.get("DOTNET_ROOT"), + "RUSTUP_TOOLCHAIN": environment.get("RUSTUP_TOOLCHAIN"), + "Path": old_path.clone(), + "PathExpanded": expand_environment_path(&old_path, paths), }); save_json(&paths.env_backup_file(), &backup)?; let directory = paths.config().join("env_backups"); @@ -3904,10 +4021,7 @@ async fn restore_user_environment( fn restore_user_environment_blocking() -> Result { let paths = load_paths()?; let backup: Value = read_json(&paths.env_backup_file())?; - let path = backup.get("Path").and_then(Value::as_str).unwrap_or(""); - let devenv_home = backup.get("DEVENV_HOME").and_then(Value::as_str); - let java_home = backup.get("JAVA_HOME").and_then(Value::as_str); - restore_environment_values(devenv_home, java_home, path)?; + restore_runtime_environment(&environment_backup_values(&backup))?; broadcast_environment_change(); Ok(OperationResult { success: true, @@ -3934,10 +4048,7 @@ fn restore_environment_backup( let backup: Value = read_json(&source)?; let current = user_environment()?; let safety_backup = create_environment_backup(&paths, ¤t)?; - let path = backup.get("Path").and_then(Value::as_str).unwrap_or(""); - let devenv_home = backup.get("DEVENV_HOME").and_then(Value::as_str); - let java_home = backup.get("JAVA_HOME").and_then(Value::as_str); - restore_environment_values(devenv_home, java_home, path)?; + restore_runtime_environment(&environment_backup_values(&backup))?; broadcast_environment_change(); Ok(OperationResult { success: true, @@ -4005,7 +4116,12 @@ fn discover_runtimes_blocking() -> Vec { } add_python_launcher_discoveries(&mut runtimes); add_python_registry_discoveries(&mut runtimes); + add_node_provider_discoveries(&mut runtimes); + add_rustup_toolchain_discoveries(&mut runtimes); + add_dotnet_sdk_discoveries(&mut runtimes); mark_path_current_runtimes(&mut runtimes); + populate_runtime_switch_capabilities(&mut runtimes); + mark_persisted_runtime_selections(&mut runtimes); runtimes.sort_by(|a, b| { a.ecosystem @@ -4417,6 +4533,16 @@ fn append_java_build_tool_checks( java_home: &Path, verification: &mut runtime_verification::RuntimeVerificationOutcome, ) { + verification + .checks + .extend(java_build_tool_checks(installed, java_home)); +} + +fn java_build_tool_checks( + installed: &InstalledData, + java_home: &Path, +) -> Vec { + let mut checks = Vec::new(); for kind in ["maven", "gradle"] { let Some(version) = current_version_for_kind(installed, kind) else { continue; @@ -4454,9 +4580,10 @@ fn append_java_build_tool_checks( { check.id = format!("jdk-switch-{}-{}", kind, check.id); check.label = format!("JDK switch: {}", check.label); - verification.checks.push(check); + checks.push(check); } } + checks } #[tauri::command] @@ -4856,13 +4983,20 @@ fn verify_external_runtime(runtime: RuntimeInfo) -> RuntimeStrongStatus { "java" | "javac" => "jdk", other => other, }; - let verification = runtime_verification::verify_installed_runtime( + let selected_java = load_paths().ok().and_then(|paths| { + let environment = user_environment().ok()?; + select_java_home(&paths, &environment) + .map(|value| PathBuf::from(expand_environment_path(&value, &paths))) + .filter(|value| value.join("bin/java.exe").is_file()) + }); + let mut verification = runtime_verification::verify_installed_runtime( verification_kind, &root, &executable, &runtime.version, - None, + selected_java.as_deref(), ); + append_provider_verification_checks(&runtime, &mut verification); let status = if !root.exists() { "外部安装目录不存在" } else if !executable.is_file() { @@ -5060,193 +5194,2272 @@ fn install_gradle_blocking( } #[tauri::command] -async fn switch_runtime( - kind: String, - version: String, - path: Option, - confirmation_token: Option, -) -> Result { +async fn create_runtime_switch_plan( + runtime_id: String, + switch_mode: String, + project_root: Option, +) -> Result { run_blocking(move || { - let plan_id = runtime_plan_id(&kind, &version, path.as_deref()); - require_risk_operation_token("switch_runtime", &plan_id, confirmation_token)?; - switch_runtime_blocking(kind, version, path) + create_trusted_runtime_switch_plan_blocking(runtime_id, switch_mode, project_root) }) .await? } -#[tauri::command] -async fn create_runtime_switch_plan( - kind: String, - version: String, - path: Option, -) -> Result { - run_blocking(move || create_runtime_switch_plan_blocking(kind, version, path)).await? +fn validate_runtime_switch_plan_id(plan_id: &str) -> Result<(), String> { + if !plan_id.starts_with("runtime-switch-") + || plan_id + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-')) + { + return Err("Runtime switch plan ID is invalid.".to_string()); + } + Ok(()) } -fn create_runtime_switch_plan_blocking( - kind: String, - version: String, - path: Option, -) -> Result { - let paths = load_paths()?; - let installed = load_installed(&paths)?; - let meta = runtime_meta(&kind)?; - let record = find_managed_runtime_record(&installed, meta, &version, path.as_deref())?; - let target = PathBuf::from(record.get("path").and_then(Value::as_str).unwrap_or("")); - let expected_parent = runtime_parent(&paths, meta.collection)?; - if target.parent() != Some(expected_parent.as_path()) || !target.is_dir() { - return Err(format!( - "拒绝为非标准受管目录创建切换计划:{}", - display_path(&target) - )); - } - let selected_version = record - .get("version") - .and_then(Value::as_str) - .unwrap_or(version.as_str()) - .to_string(); - let previous_version = current_version(&installed, meta.kind); - let previous_root = previous_version.as_deref().and_then(|current| { - collection(&installed, meta.collection) - .iter() - .find(|item| item.get("version").and_then(Value::as_str) == Some(current)) - .and_then(|item| item.get("path").and_then(Value::as_str)) - .map(str::to_string) - }); - let environment = user_environment()?; - let backup_name = create_environment_backup(&paths, &environment)?; - let installed_fingerprint = managed_runtime_state_fingerprint(&installed, meta, &record); - let created_at = current_timestamp(); - let mut hasher = Sha256::new(); - hasher.update(meta.kind.as_bytes()); - hasher.update(selected_version.as_bytes()); - hasher.update(path_key(&display_path(&target)).as_bytes()); - hasher.update(installed_fingerprint.as_bytes()); - hasher.update(created_at.as_bytes()); - let plan_fingerprint = format!("{:x}", hasher.finalize()); - let plan_id = format!("runtime-switch-{}", &plan_fingerprint[..24]); - let managed_path = match meta.kind { - "jdk" => r"%DEVENV_HOME%\current\jdk\bin", - "python" => r"%DEVENV_HOME%\current\python; %DEVENV_HOME%\current\python\Scripts", - "node" => r"%DEVENV_HOME%\current\node", - "maven" => r"%DEVENV_HOME%\current\maven\bin", - "gradle" => r"%DEVENV_HOME%\current\gradle\bin", - "go" => r"%DEVENV_HOME%\current\go\bin", - _ => "", - }; - let plan = RuntimeSwitchPlan { - plan_id: plan_id.clone(), - created_at, - kind: meta.kind.to_string(), - version: selected_version, - target_root: display_path(&target), - previous_version, - previous_root, - environment_changes: vec![ - format!("current\\{} -> {}", meta.link_name, display_path(&target)), - if meta.kind == "jdk" { - "JAVA_HOME -> %DEVENV_HOME%\\current\\jdk".to_string() - } else { - "JAVA_HOME remains on the selected Java runtime".to_string() - }, - ], - path_diff: vec![format!("Ensure managed PATH entry: {managed_path}")], - backup_name, - warnings: vec![ - "The plan is single-use and is rejected if installed.json changes.".to_string(), - "Open terminals, IDEs, services, and agents must be restarted to inherit the new environment." - .to_string(), - "Execution automatically verifies the selected runtime and restores the previous pointer and environment if verification fails." - .to_string(), - ], - risk_level: "medium".to_string(), - plan_fingerprint, - }; - runtime_switch_plans() +#[tauri::command] +fn cancel_runtime_switch_plan(plan_id: String) -> Result { + validate_runtime_switch_plan_id(&plan_id)?; + let pending = runtime_switch_plans() .lock() - .map_err(|_| "运行时切换计划存储不可用".to_string())? - .insert( - plan_id, - PendingRuntimeSwitchPlan { - public: plan.clone(), - installed_fingerprint, - }, - ); - Ok(plan) + .map_err(|_| "Runtime switch plan storage is unavailable.".to_string())? + .remove(&plan_id) + .ok_or_else(|| { + "Runtime switch plan does not exist, was already consumed, or expired.".to_string() + })?; + Ok(OperationResult { + success: true, + message: format!( + "Runtime switch plan {} was cancelled. Runtime backup {} remains available.", + pending.public.plan_id, pending.public.backup_id + ), + }) } #[tauri::command] -async fn execute_runtime_switch_plan( - plan_id: String, - confirmation_token: Option, -) -> Result { - run_blocking(move || { - let pending = runtime_switch_plans() - .lock() - .map_err(|_| "运行时切换计划存储不可用".to_string())? - .get(&plan_id) - .cloned() - .ok_or_else(|| "运行时切换计划不存在、已执行或已过期,请重新创建。".to_string())?; - require_confirmation_token( - confirmation_token, - "execute_runtime_switch_plan", - "execute_runtime_switch_plan", - &plan_id, - &pending.public.risk_level, - &pending.public.plan_fingerprint, - true, - )?; - let pending = runtime_switch_plans() +fn export_runtime_switch_plan(plan_id: String) -> Result { + validate_runtime_switch_plan_id(&plan_id)?; + let plan = runtime_switch_plans() + .lock() + .map_err(|_| "Runtime switch plan storage is unavailable.".to_string())? + .get(&plan_id) + .map(|pending| pending.public.clone()) + .ok_or_else(|| { + "Runtime switch plan does not exist, was already consumed, or expired.".to_string() + })?; + if runtime_switch_plan_is_expired(&plan, unix_timestamp()) { + runtime_switch_plans() .lock() - .map_err(|_| "运行时切换计划存储不可用".to_string())? - .remove(&plan_id) - .ok_or_else(|| "运行时切换计划不存在、已执行或已过期,请重新创建。".to_string())?; - let paths = load_paths()?; - let installed = load_installed(&paths)?; - let meta = runtime_meta(&pending.public.kind)?; - let record = find_managed_runtime_record( - &installed, - meta, - &pending.public.version, - Some(&pending.public.target_root), - )?; - if managed_runtime_state_fingerprint(&installed, meta, &record) - != pending.installed_fingerprint - { - return Err("运行时安装记录或 current 状态已变化,请重新创建切换计划。".to_string()); - } - switch_runtime_with_paths( - &paths, - pending.public.kind.clone(), - pending.public.version.clone(), - Some(pending.public.target_root.clone()), - )?; - let installed = load_installed(&paths)?; - let record = find_managed_runtime_record( - &installed, - meta, - &pending.public.version, - Some(&pending.public.target_root), - )?; - let verification = verify_registered_runtime(&paths, &installed, meta, &record); - Ok(RuntimeSwitchResult { - success: true, - message: format!( - "已切换当前 {} 到 {},并完成环境与命令强校验;请重新打开终端和 IDE。", - meta.kind, pending.public.version - ), - plan_id: pending.public.plan_id, - backup_name: pending.public.backup_name, - verification, - }) + .map_err(|_| "Runtime switch plan storage is unavailable.".to_string())? + .remove(&plan_id); + return Err("The runtime switch plan has expired. Create a new plan.".to_string()); + } + let export = json!({ + "planId": plan.plan_id, + "createdAt": plan.created_at, + "expiresAt": plan.expires_at, + "runtimeId": plan.runtime_id, + "switchMode": plan.switch_mode, + "sourceAuthority": plan.source_authority, + "provider": plan.provider, + "kind": plan.kind, + "version": plan.version, + "targetRoot": plan.target_root, + "previousVersion": plan.previous_version, + "previousRoot": plan.previous_root, + "environmentChanges": plan.environment_changes, + "pathDiff": plan.path_diff, + "backupName": plan.backup_name, + "backupId": plan.backup_id, + "backupPath": plan.backup_path, + "verificationSteps": plan.verification_steps, + "warnings": plan.warnings, + "riskLevel": plan.risk_level, + "note": "Execution fingerprints are intentionally omitted from exported review copies." + }); + let reports = app_config_dir().join("reports"); + fs::create_dir_all(&reports) + .map_err(|error| format!("Failed to create report directory: {error}"))?; + let target = reports.join(format!( + "runtime-switch-plan-{}-{}.json", + filename_timestamp(), + &plan_id["runtime-switch-".len()..] + )); + let content = serde_json::to_string_pretty(&export) + .map_err(|error| format!("Failed to serialize runtime switch plan: {error}"))?; + fs::write(&target, redact_report_text(&content)) + .map_err(|error| format!("Failed to export runtime switch plan: {error}"))?; + Ok(display_path(target)) +} + +fn environment_backup_values(backup: &Value) -> HashMap { + [ + "DEVENV_HOME", + "JAVA_HOME", + "MAVEN_HOME", + "M2_HOME", + "GRADLE_HOME", + "GOROOT", + "DOTNET_ROOT", + "RUSTUP_TOOLCHAIN", + "Path", + ] + .into_iter() + .filter_map(|name| { + backup + .get(name) + .and_then(Value::as_str) + .map(|value| (name.to_string(), value.to_string())) }) - .await? + .collect() } -fn runtime_plan_id(kind: &str, version: &str, path: Option<&str>) -> String { - format!( - "{}:{}:{}", - kind.trim(), +fn sha256_file_contents(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| { + format!( + "Failed to read backup evidence {}: {error}", + display_path(path) + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(bytes); + Ok(format!("{:x}", hasher.finalize())) +} + +fn runtime_switch_backup_fingerprint(record: &RuntimeSwitchBackupRecord) -> String { + let mut unsigned = record.clone(); + unsigned.record_fingerprint.clear(); + sha256_text(&serde_json::to_string(&unsigned).unwrap_or_default()) +} + +fn validate_runtime_switch_backup_id(backup_id: &str) -> Result<(), String> { + if !backup_id.starts_with("runtime-switch-backup-") + || backup_id + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-')) + { + return Err("Runtime switch backup ID is invalid.".to_string()); + } + Ok(()) +} + +fn runtime_switch_backup_directory(paths: &AppPaths) -> PathBuf { + paths.config().join("runtime_switch_backups") +} + +fn runtime_switch_backup_path(paths: &AppPaths, backup_id: &str) -> Result { + validate_runtime_switch_backup_id(backup_id)?; + Ok(runtime_switch_backup_directory(paths).join(format!("{backup_id}.json"))) +} + +fn create_runtime_switch_backup( + paths: &AppPaths, + input: RuntimeSwitchBackupInput<'_>, +) -> Result { + let RuntimeSwitchBackupInput { + candidate, + switch_mode, + project_root, + environment_backup_name, + installed, + selections, + state_fingerprint, + } = input; + let environment_backup_path = paths + .config() + .join("env_backups") + .join(&environment_backup_name); + if fs::metadata(&environment_backup_path) + .map(|metadata| metadata.len() > 512 * 1024) + .unwrap_or(true) + { + return Err("Runtime switch environment backup is missing or exceeds 512 KiB.".to_string()); + } + let environment_backup_fingerprint = sha256_file_contents(&environment_backup_path)?; + let provider_state = if switch_mode == "provider" { + Some(capture_runtime_provider_state(candidate).ok_or_else(|| { + "The current provider selection could not be captured for backup.".to_string() + })?) + } else { + None + }; + let project_backup = if switch_mode == "project" { + backup_project_global_json(paths, project_root)? + } else { + None + }; + let project_backup_fingerprint = project_backup + .as_deref() + .map(Path::new) + .map(sha256_file_contents) + .transpose()?; + let target_directory_fingerprint = if candidate.management == "external" { + Some(runtime_directory_state_fingerprint(Path::new( + &candidate.runtime_root, + ))?) + } else { + None + }; + let identity = sha256_text(&format!( + "{}|{}|{}|{}|{}|{}", + candidate.id, + switch_mode, + candidate.runtime_root, + state_fingerprint, + current_timestamp(), + RUNTIME_SWITCH_BACKUP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let backup_id = format!( + "runtime-switch-backup-{}-{}", + filename_timestamp(), + &identity[..12] + ); + let mut record = RuntimeSwitchBackupRecord { + schema_version: 1, + backup_id: backup_id.clone(), + created_at: current_timestamp(), + environment_backup_name, + environment_backup_fingerprint, + installed, + selections, + provider_state, + project_root: project_root.map(str::to_string), + project_backup, + project_backup_fingerprint, + target_runtime_id: candidate.id.clone(), + target_kind: candidate.kind.clone(), + target_ecosystem: candidate.ecosystem.clone(), + target_version: candidate.version.clone(), + target_root: candidate.runtime_root.clone(), + target_directory_fingerprint, + switch_mode: switch_mode.to_string(), + state_fingerprint, + record_fingerprint: String::new(), + }; + record.record_fingerprint = runtime_switch_backup_fingerprint(&record); + if serde_json::to_vec(&record) + .map_err(|error| format!("Failed to serialize runtime switch backup: {error}"))? + .len() + > 2 * 1024 * 1024 + { + return Err("Runtime switch backup exceeds the 2 MiB safety limit.".to_string()); + } + let directory = runtime_switch_backup_directory(paths); + fs::create_dir_all(&directory) + .map_err(|error| format!("Failed to create runtime switch backup directory: {error}"))?; + save_json(&runtime_switch_backup_path(paths, &backup_id)?, &record)?; + if let Ok(entries) = fs::read_dir(&directory) { + let mut files = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .collect::>(); + files.sort(); + let remove_count = files.len().saturating_sub(20); + for old in files.into_iter().take(remove_count) { + let _ = fs::remove_file(old); + } + } + Ok(record) +} + +fn load_runtime_switch_backup( + paths: &AppPaths, + backup_id: &str, +) -> Result { + let path = runtime_switch_backup_path(paths, backup_id)?; + if fs::metadata(&path) + .map(|metadata| metadata.len() > 2 * 1024 * 1024) + .unwrap_or(true) + { + return Err("Runtime switch backup is missing or exceeds 2 MiB.".to_string()); + } + let record: RuntimeSwitchBackupRecord = read_json(&path)?; + if record.schema_version != 1 + || record.backup_id != backup_id + || runtime_switch_backup_fingerprint(&record) != record.record_fingerprint + { + return Err("Runtime switch backup integrity verification failed.".to_string()); + } + let environment_backup_path = paths + .config() + .join("env_backups") + .join(&record.environment_backup_name); + if fs::metadata(&environment_backup_path) + .map(|metadata| metadata.len() > 512 * 1024) + .unwrap_or(true) + { + return Err("Runtime switch environment backup is missing or exceeds 512 KiB.".to_string()); + } + if sha256_file_contents(&environment_backup_path)? != record.environment_backup_fingerprint { + return Err("Runtime switch environment backup evidence changed.".to_string()); + } + if let (Some(project_backup), Some(expected)) = ( + record.project_backup.as_deref(), + record.project_backup_fingerprint.as_deref(), + ) { + if fs::metadata(project_backup) + .map(|metadata| metadata.len() > 256 * 1024) + .unwrap_or(true) + { + return Err("Runtime switch project backup is missing or exceeds 256 KiB.".to_string()); + } + if sha256_file_contents(Path::new(project_backup))? != expected { + return Err("Runtime switch project backup evidence changed.".to_string()); + } + } + Ok(record) +} + +#[tauri::command] +fn list_runtime_switch_backups() -> Result, String> { + let paths = load_paths()?; + list_runtime_switch_backups_at(&paths) +} + +fn list_runtime_switch_backups_at( + paths: &AppPaths, +) -> Result, String> { + let directory = runtime_switch_backup_directory(paths); + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "Failed to read runtime switch backup directory: {error}" + )) + } + }; + let mut summaries = Vec::new(); + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(backup_id) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + if validate_runtime_switch_backup_id(backup_id).is_err() { + continue; + } + match load_runtime_switch_backup(paths, backup_id) { + Ok(record) => summaries.push(RuntimeSwitchBackupSummary { + backup_id: record.backup_id, + created_at: record.created_at, + target_kind: record.target_kind, + target_version: record.target_version, + target_root: record.target_root, + switch_mode: record.switch_mode, + backup_path: display_path(path), + restorable: true, + validation_error: None, + }), + Err(error) => summaries.push(RuntimeSwitchBackupSummary { + backup_id: backup_id.to_string(), + created_at: String::new(), + target_kind: String::new(), + target_version: String::new(), + target_root: String::new(), + switch_mode: String::new(), + backup_path: display_path(path), + restorable: false, + validation_error: Some(error), + }), + } + } + summaries.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| right.backup_id.cmp(&left.backup_id)) + }); + summaries.truncate(20); + Ok(summaries) +} + +fn runtime_switch_backup_restore_verified( + paths: &AppPaths, + record: &RuntimeSwitchBackupRecord, + environment: &HashMap, +) -> bool { + let installed_matches = load_installed(paths) + .ok() + .and_then(|current| serde_json::to_vec(¤t).ok()) + == serde_json::to_vec(&record.installed).ok(); + let selections_match = load_runtime_selections(paths) + .ok() + .and_then(|current| serde_json::to_vec(¤t).ok()) + == serde_json::to_vec(&record.selections).ok(); + let environment_matches = user_environment() + .map(|current| { + runtime_environment_fingerprint(¤t) + == runtime_environment_fingerprint(environment) + }) + .unwrap_or(false); + let provider_matches = record + .provider_state + .as_ref() + .map(|expected| { + discover_runtimes_blocking() + .into_iter() + .find(|runtime| runtime.provider.as_deref() == Some(expected.provider.as_str())) + .and_then(|runtime| capture_runtime_provider_state(&runtime)) + .is_some_and(|current| { + current.selection == expected.selection && current.app_id == expected.app_id + }) + }) + .unwrap_or(true); + let selected_runtime_matches = record + .selections + .selections + .get(&record.target_ecosystem) + .map(|selection| { + discover_runtimes_blocking() + .into_iter() + .find(|runtime| runtime.id == selection.runtime_id) + .is_some_and(|runtime| { + persisted_runtime_selection_is_effective(paths, &runtime, selection) + }) + }) + .unwrap_or(true); + installed_matches + && selections_match + && environment_matches + && provider_matches + && selected_runtime_matches +} + +#[tauri::command] +fn restore_runtime_switch_backup( + backup_id: String, + confirmation_token: Option, +) -> Result { + let paths = load_paths()?; + let record = load_runtime_switch_backup(&paths, &backup_id)?; + require_risk_operation_token( + "restore_runtime_switch_backup", + &backup_id, + confirmation_token, + )?; + let environment_backup_path = paths + .config() + .join("env_backups") + .join(&record.environment_backup_name); + let environment = environment_backup_values(&read_json::(&environment_backup_path)?); + + let safety_environment = user_environment()?; + let safety_environment_backup = create_environment_backup(&paths, &safety_environment)?; + let safety_installed = load_installed(&paths)?; + let safety_selections = load_runtime_selections(&paths)?; + let safety_candidate = discover_runtimes_blocking().into_iter().find(|runtime| { + record + .provider_state + .as_ref() + .is_some_and(|state| runtime.provider.as_deref() == Some(state.provider.as_str())) + }); + let safety_provider = safety_candidate + .as_ref() + .and_then(capture_runtime_provider_state); + let safety_project_backup = if record.project_root.is_some() { + backup_project_global_json(&paths, record.project_root.as_deref())? + } else { + None + }; + + let restored = rollback_runtime_switch( + &paths, + &record.installed, + &environment, + &record.selections, + record.provider_state.as_ref(), + record.project_root.as_deref(), + record.project_backup.as_deref(), + ) && runtime_switch_backup_restore_verified(&paths, &record, &environment); + if !restored { + let safety_restored = rollback_runtime_switch( + &paths, + &safety_installed, + &safety_environment, + &safety_selections, + safety_provider.as_ref(), + record.project_root.as_deref(), + safety_project_backup.as_deref(), + ); + return Err(format!( + "Runtime switch backup restore was not conclusive. The pre-restore safety rollback was {}. Safety environment backup: {}.", + if safety_restored { + "successful" + } else { + "not conclusive" + }, + safety_environment_backup + )); + } + Ok(OperationResult { + success: true, + message: format!( + "Runtime switch backup {backup_id} was restored and verified. Pre-restore safety environment backup: {safety_environment_backup}." + ), + }) +} + +fn create_trusted_runtime_switch_plan_blocking( + runtime_id: String, + switch_mode: String, + project_root: Option, +) -> Result { + let paths = load_paths()?; + let installed = load_installed(&paths)?; + let candidate = resolve_trusted_runtime_candidate(&runtime_id, &switch_mode)?; + if switch_mode == "provider" && capture_runtime_provider_state(&candidate).is_none() { + return Err( + "The current provider selection could not be captured, so a restorable provider plan cannot be created." + .to_string(), + ); + } + let project_root = validate_runtime_project_root(&candidate, &switch_mode, project_root)?; + let environment = user_environment()?; + let backup_name = create_environment_backup(&paths, &environment)?; + let selections = load_runtime_selections(&paths)?; + let created_at_unix = unix_timestamp(); + let expires_at = created_at_unix.saturating_add(10 * 60); + let created_at = created_at_unix.to_string(); + let state_fingerprint = runtime_switch_state_fingerprint( + &candidate, + &installed, + &environment, + project_root.as_deref(), + ); + let runtime_backup = create_runtime_switch_backup( + &paths, + RuntimeSwitchBackupInput { + candidate: &candidate, + switch_mode: &switch_mode, + project_root: project_root.as_deref(), + environment_backup_name: backup_name.clone(), + installed: installed.clone(), + selections, + state_fingerprint: state_fingerprint.clone(), + }, + )?; + let backup_path = display_path(runtime_switch_backup_path( + &paths, + &runtime_backup.backup_id, + )?); + let mut hasher = Sha256::new(); + hasher.update(candidate.id.as_bytes()); + hasher.update(switch_mode.as_bytes()); + hasher.update(state_fingerprint.as_bytes()); + hasher.update(runtime_backup.backup_id.as_bytes()); + hasher.update(created_at.as_bytes()); + let plan_fingerprint = format!("{:x}", hasher.finalize()); + let plan_id = format!("runtime-switch-{}", &plan_fingerprint[..24]); + let (environment_changes, path_diff, verification_steps) = + runtime_switch_plan_details(&candidate, &switch_mode, project_root.as_deref()); + let (previous_version, previous_root) = current_runtime_for_ecosystem(&candidate.ecosystem); + let mut warnings = vec![ + "This plan is single-use, expires after 10 minutes, and is rejected if runtime or environment state changes.".to_string(), + "Open terminals, IDEs, services, and agents must be restarted to inherit a changed user environment.".to_string(), + "Execution verifies the selected runtime and automatically restores the captured state if verification fails.".to_string(), + ]; + if candidate.management == "external" { + warnings.push( + "The external runtime directory remains read-only; only user environment, provider state, or a project selection file may change." + .to_string(), + ); + } + match candidate.provider.as_deref() { + Some("nvm") => warnings.push( + "nvm-windows may require an elevated DevEnv Manager process to update its provider-owned symlink; a denied provider command triggers rollback." + .to_string(), + ), + Some("fnm") => warnings.push( + "fnm default is changed through the provider CLI; new shells still require a working fnm shell initialization." + .to_string(), + ), + Some("volta") => warnings.push( + "Volta changes its default Node toolchain; a project-level Volta pin can still override the default." + .to_string(), + ), + Some("scoop") => warnings.push( + "Scoop reset changes only the provider-owned current shim for the already installed Node package." + .to_string(), + ), + Some("rustup") => warnings.push( + "rustup default changes provider state; project overrides can still select another Rust toolchain." + .to_string(), + ), + _ => {} + } + let plan = RuntimeSwitchPlan { + plan_id: plan_id.clone(), + created_at, + expires_at, + runtime_id: candidate.id.clone(), + switch_mode, + source_authority: candidate.source_authority.clone(), + provider: candidate.provider.clone(), + kind: candidate.kind.clone(), + version: candidate.version.clone(), + target_root: candidate.runtime_root.clone(), + previous_version, + previous_root, + environment_changes, + path_diff, + backup_name, + backup_id: runtime_backup.backup_id.clone(), + backup_path, + state_fingerprint, + verification_steps, + warnings, + risk_level: "medium".to_string(), + plan_fingerprint, + }; + runtime_switch_plans() + .lock() + .map_err(|_| "Runtime switch plan storage is unavailable.".to_string())? + .insert( + plan_id, + PendingRuntimeSwitchPlan { + public: plan.clone(), + installed_fingerprint: String::new(), + candidate: Some(candidate), + project_root, + runtime_backup: Some(runtime_backup), + }, + ); + Ok(plan) +} + +fn resolve_trusted_runtime_candidate( + runtime_id: &str, + switch_mode: &str, +) -> Result { + let mut candidate = discover_runtimes_blocking() + .into_iter() + .find(|runtime| runtime.id == runtime_id) + .ok_or_else(|| { + "The runtime is no longer present in the trusted discovery inventory. Refresh and try again." + .to_string() + })?; + if !candidate.switch_eligible + || !candidate + .switch_modes + .iter() + .any(|mode| mode == switch_mode) + { + return Err(candidate + .switch_blockers + .first() + .cloned() + .unwrap_or_else(|| { + "The requested switch mode is not allowed for this runtime authority.".to_string() + })); + } + let checks = if candidate.management == "managed" { + let paths = load_paths()?; + let installed = load_installed(&paths)?; + let meta = runtime_meta(&candidate.kind)?; + let record = find_managed_runtime_record( + &installed, + meta, + &candidate.version, + Some(&candidate.runtime_root), + )?; + verify_registered_runtime(&paths, &installed, meta, &record).checks + } else { + verify_external_runtime(candidate.clone()).checks + }; + if let Some(failed) = checks + .iter() + .find(|check| check.required && check.status != "passed") + { + return Err(format!( + "Required runtime verification failed at {}: {}", + failed.id, + failed.error.as_deref().unwrap_or(&failed.actual) + )); + } + candidate.verification_fingerprint = runtime_identity_fingerprint(&candidate, &checks); + Ok(candidate) +} + +fn append_provider_verification_checks( + candidate: &RuntimeInfo, + verification: &mut runtime_verification::RuntimeVerificationOutcome, +) { + if candidate.provider.as_deref() == Some("rustup") { + let cargo = Path::new(&candidate.runtime_root).join("cargo.exe"); + let cargo_verification = runtime_verification::verify_installed_runtime( + "cargo", + Path::new(&candidate.runtime_root), + &cargo, + "", + None, + ); + verification.checks.extend( + cargo_verification + .checks + .into_iter() + .filter(|check| check.id == "cargo-version"), + ); + verification.fully_usable = verification + .checks + .iter() + .filter(|check| check.required) + .all(|check| check.status == "passed"); + verification.failure_stage = verification + .checks + .iter() + .find(|check| check.required && check.status != "passed") + .map(|check| check.id.clone()); + } + if candidate.kind == "node" { + if let Some(provider) = candidate.provider.as_deref() { + let check = match provider_command_path(provider) { + Some(executable) => { + match powershell_runner::run_probe_command( + &executable, + provider_version_args(provider), + 30, + ) { + Ok(output) => runtime_verification::RuntimeVerificationCheck { + id: "provider-cli".to_string(), + label: format!("{provider} provider CLI"), + command: format!( + "{} {}", + display_path(&executable), + provider_version_args(provider).join(" ") + ), + expected: provider.to_string(), + actual: format!("{}\n{}", output.stdout, output.stderr) + .trim() + .to_string(), + status: if output.success { "passed" } else { "failed" }.to_string(), + error: (!output.success) + .then(|| powershell_runner::native_command_message(&output)), + elapsed_ms: output.elapsed_ms, + required: true, + suggestion: format!( + "Repair {provider} or use its original CLI before switching this Node.js version." + ), + }, + Err(error) => runtime_verification::RuntimeVerificationCheck { + id: "provider-cli".to_string(), + label: format!("{provider} provider CLI"), + command: format!( + "{} {}", + display_path(&executable), + provider_version_args(provider).join(" ") + ), + expected: provider.to_string(), + actual: String::new(), + status: "failed".to_string(), + error: Some(error), + elapsed_ms: 0, + required: true, + suggestion: format!( + "Repair {provider} or use its original CLI before switching this Node.js version." + ), + }, + } + } + None => runtime_verification::RuntimeVerificationCheck { + id: "provider-cli".to_string(), + label: format!("{provider} provider CLI"), + command: format!("{provider} {}", provider_version_args(provider).join(" ")), + expected: provider.to_string(), + actual: String::new(), + status: "failed".to_string(), + error: Some(format!("{provider} executable was not found.")), + elapsed_ms: 0, + required: true, + suggestion: format!( + "Restore the {provider} command to PATH before switching this Node.js version." + ), + }, + }; + verification.checks.push(check); + verification.fully_usable = verification + .checks + .iter() + .filter(|check| check.required) + .all(|check| check.status == "passed"); + verification.failure_stage = verification + .checks + .iter() + .find(|check| check.required && check.status != "passed") + .map(|check| check.id.clone()); + } + } +} + +fn validate_runtime_project_root( + candidate: &RuntimeInfo, + switch_mode: &str, + project_root: Option, +) -> Result, String> { + if switch_mode != "project" { + return Ok(None); + } + if candidate.kind != "dotnet" { + return Err( + "Project switch mode is currently restricted to .NET SDK selection.".to_string(), + ); + } + let root = PathBuf::from( + project_root + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "Choose a project directory before creating a .NET SDK plan.".to_string() + })?, + ); + validate_dotnet_project_root(&root)?; + Ok(Some(display_path(root))) +} + +fn validate_dotnet_project_root(root: &Path) -> Result<(), String> { + if !root.is_absolute() || !root.is_dir() || root.parent().is_none() { + return Err("The selected project directory is missing or unsafe.".to_string()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if fs::symlink_metadata(root) + .ok() + .is_some_and(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + { + return Err( + "Project roots that are symlinks or junctions are not writable.".to_string(), + ); + } + } + let root_key = path_key(&display_path(root)); + for protected in [ + env::var_os("WINDIR").map(PathBuf::from), + env::var_os("ProgramFiles").map(PathBuf::from), + env::var_os("ProgramFiles(x86)").map(PathBuf::from), + ] + .into_iter() + .flatten() + { + if root_key.starts_with(&path_key(&display_path(protected))) { + return Err( + "System and installed-program directories cannot be used as project roots." + .to_string(), + ); + } + } + if project_signals(root).is_empty() { + return Err( + "No supported project marker was found. Choose the actual project root before writing global.json." + .to_string(), + ); + } + let global_json = root.join("global.json"); + if global_json.exists() && path_is_reparse_point(&global_json) { + return Err( + "global.json cannot be a symbolic link, junction, or reparse point.".to_string(), + ); + } + Ok(()) +} + +fn runtime_switch_state_fingerprint( + candidate: &RuntimeInfo, + installed: &InstalledData, + environment: &HashMap, + project_root: Option<&str>, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(candidate.verification_fingerprint.as_bytes()); + hasher.update(runtime_target_artifact_fingerprint(candidate).as_bytes()); + hasher.update(serde_json::to_vec(installed).unwrap_or_default().as_slice()); + hasher.update(runtime_environment_fingerprint(environment).as_bytes()); + if candidate.provider.is_some() { + if let Some(state) = capture_runtime_provider_state(candidate) { + hasher.update(state.provider.as_bytes()); + hasher.update(state.selection.as_bytes()); + hasher.update(state.app_id.as_deref().unwrap_or("").as_bytes()); + } + hasher.update(runtime_provider_fingerprint(candidate).as_bytes()); + } + if let Some(project_root) = project_root { + hasher.update(path_key(project_root).as_bytes()); + let global_json = PathBuf::from(project_root).join("global.json"); + if let Ok(bytes) = fs::read(global_json) { + hasher.update(bytes); + } + } + format!("{:x}", hasher.finalize()) +} + +fn runtime_provider_fingerprint(candidate: &RuntimeInfo) -> String { + let provider = candidate.provider.as_deref().unwrap_or(""); + let executable = provider_command_path(provider).unwrap_or_else(|| PathBuf::from(provider)); + let version = run_command_output(executable.clone(), provider_version_args(provider), 30) + .unwrap_or_default(); + let executable_fingerprint = + sha256_file_contents(&executable).unwrap_or_else(|_| "unreadable".to_string()); + sha256_text(&format!( + "{}|{}|{}|{}", + provider, + path_key(&display_path(executable)), + version, + executable_fingerprint + )) +} + +fn runtime_target_artifact_paths(candidate: &RuntimeInfo) -> Vec { + let root = PathBuf::from(&candidate.runtime_root); + match candidate.kind.as_str() { + "jdk" => ["java.exe", "javac.exe", "jar.exe"] + .into_iter() + .map(|name| root.join("bin").join(name)) + .collect(), + "python" => vec![ + PathBuf::from(&candidate.executable), + root.join("Scripts/pip.exe"), + ], + "node" => vec![ + root.join("node.exe"), + root.join("npm.cmd"), + root.join("npx.cmd"), + ], + "go" => vec![root.join("bin/go.exe")], + "maven" => vec![root.join("bin/mvn.cmd")], + "gradle" => vec![root.join("bin/gradle.bat")], + "rust" | "rustc" | "dotnet" => vec![PathBuf::from(&candidate.executable)], + _ => vec![PathBuf::from(&candidate.executable)], + } +} + +fn runtime_target_artifact_fingerprint(candidate: &RuntimeInfo) -> String { + let mut hasher = Sha256::new(); + for path in runtime_target_artifact_paths(candidate) { + hasher.update(path_key(&display_path(&path)).as_bytes()); + hasher.update([0]); + match sha256_file_contents(&path) { + Ok(fingerprint) => hasher.update(fingerprint.as_bytes()), + Err(_) => hasher.update(b"missing-or-unreadable"), + } + hasher.update([0]); + } + format!("{:x}", hasher.finalize()) +} + +fn runtime_directory_state_fingerprint(root: &Path) -> Result { + if !root.is_dir() { + return Err("Runtime directory snapshot target is missing.".to_string()); + } + let mut stack = vec![root.to_path_buf()]; + let mut records = Vec::new(); + while let Some(directory) = stack.pop() { + let entries = fs::read_dir(&directory).map_err(|error| { + format!( + "Failed to read runtime directory {}: {error}", + display_path(&directory) + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| format!("Failed to read runtime entry: {error}"))?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("Failed to inspect runtime entry: {error}"))?; + let relative = path + .strip_prefix(root) + .map(display_path) + .unwrap_or_else(|_| display_path(&path)); + let modified = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|value| value.as_nanos()) + .unwrap_or(0); + let entry_type = if path_is_reparse_point(&path) { + "link" + } else if metadata.is_dir() { + "dir" + } else if metadata.is_file() { + "file" + } else { + "other" + }; + records.push(format!( + "{}|{}|{}|{}", + path_key(&relative), + entry_type, + metadata.len(), + modified + )); + if metadata.is_dir() && !path_is_reparse_point(&path) { + stack.push(path); + } + if records.len() > 100_000 { + return Err( + "Runtime directory exceeds the 100,000-entry snapshot safety limit." + .to_string(), + ); + } + } + } + records.sort(); + Ok(sha256_text(&records.join("\n"))) +} + +fn current_runtime_for_ecosystem(ecosystem: &str) -> (Option, Option) { + discover_runtimes_blocking() + .into_iter() + .find(|runtime| runtime.ecosystem == ecosystem && runtime.current) + .map(|runtime| (Some(runtime.version), Some(runtime.runtime_root))) + .unwrap_or((None, None)) +} + +fn runtime_switch_plan_details( + candidate: &RuntimeInfo, + switch_mode: &str, + project_root: Option<&str>, +) -> (Vec, Vec, Vec) { + if switch_mode == "provider" { + let provider = candidate.provider.as_deref().unwrap_or("provider"); + let selection = + provider_target_selection(candidate).unwrap_or_else(|| candidate.version.clone()); + return ( + vec![format!("{provider} provider selection -> {selection}")], + Vec::new(), + vec![ + format!("Verify {provider} reports the requested default selection."), + "Launch provider-routed commands in a new child process and verify their version." + .to_string(), + ], + ); + } + if switch_mode == "project" { + return ( + vec![format!( + "{}\\global.json SDK version -> {}", + project_root.unwrap_or("project"), + candidate.version + )], + Vec::new(), + vec![ + "Run dotnet --version in the selected project.".to_string(), + "Verify global.json selects the requested SDK.".to_string(), + ], + ); + } + let variables = runtime_environment_bindings(candidate) + .into_iter() + .map(|(name, value)| format!("{name} -> {value}")) + .collect(); + let path_diff = runtime_path_entries(candidate) + .into_iter() + .map(|entry| format!("Prepend user PATH: {entry}")) + .collect(); + ( + variables, + path_diff, + vec![ + "Re-resolve the primary executable from user PATH.".to_string(), + "Run ecosystem command-level strong verification.".to_string(), + ], + ) +} + +#[allow(dead_code)] +fn create_runtime_switch_plan_blocking( + kind: String, + version: String, + path: Option, +) -> Result { + let paths = load_paths()?; + let installed = load_installed(&paths)?; + let meta = runtime_meta(&kind)?; + let record = find_managed_runtime_record(&installed, meta, &version, path.as_deref())?; + let target = PathBuf::from(record.get("path").and_then(Value::as_str).unwrap_or("")); + let expected_parent = runtime_parent(&paths, meta.collection)?; + if target.parent() != Some(expected_parent.as_path()) || !target.is_dir() { + return Err(format!( + "拒绝为非标准受管目录创建切换计划:{}", + display_path(&target) + )); + } + let selected_version = record + .get("version") + .and_then(Value::as_str) + .unwrap_or(version.as_str()) + .to_string(); + let previous_version = current_version(&installed, meta.kind); + let previous_root = previous_version.as_deref().and_then(|current| { + collection(&installed, meta.collection) + .iter() + .find(|item| item.get("version").and_then(Value::as_str) == Some(current)) + .and_then(|item| item.get("path").and_then(Value::as_str)) + .map(str::to_string) + }); + let environment = user_environment()?; + let backup_name = create_environment_backup(&paths, &environment)?; + let installed_fingerprint = managed_runtime_state_fingerprint(&installed, meta, &record); + let created_at = current_timestamp(); + let mut hasher = Sha256::new(); + hasher.update(meta.kind.as_bytes()); + hasher.update(selected_version.as_bytes()); + hasher.update(path_key(&display_path(&target)).as_bytes()); + hasher.update(installed_fingerprint.as_bytes()); + hasher.update(created_at.as_bytes()); + let plan_fingerprint = format!("{:x}", hasher.finalize()); + let plan_id = format!("runtime-switch-{}", &plan_fingerprint[..24]); + let managed_path = match meta.kind { + "jdk" => r"%DEVENV_HOME%\current\jdk\bin", + "python" => r"%DEVENV_HOME%\current\python; %DEVENV_HOME%\current\python\Scripts", + "node" => r"%DEVENV_HOME%\current\node", + "maven" => r"%DEVENV_HOME%\current\maven\bin", + "gradle" => r"%DEVENV_HOME%\current\gradle\bin", + "go" => r"%DEVENV_HOME%\current\go\bin", + _ => "", + }; + let plan = RuntimeSwitchPlan { + plan_id: plan_id.clone(), + created_at, + expires_at: unix_timestamp().saturating_add(10 * 60), + runtime_id: String::new(), + switch_mode: "managed".to_string(), + source_authority: "devenv-managed-registry".to_string(), + provider: None, + kind: meta.kind.to_string(), + version: selected_version, + target_root: display_path(&target), + previous_version, + previous_root, + environment_changes: vec![ + format!("current\\{} -> {}", meta.link_name, display_path(&target)), + if meta.kind == "jdk" { + "JAVA_HOME -> %DEVENV_HOME%\\current\\jdk".to_string() + } else { + "JAVA_HOME remains on the selected Java runtime".to_string() + }, + ], + path_diff: vec![format!("Ensure managed PATH entry: {managed_path}")], + backup_name, + backup_id: String::new(), + backup_path: String::new(), + state_fingerprint: installed_fingerprint.clone(), + verification_steps: vec!["Run command-level strong verification.".to_string()], + warnings: vec![ + "The plan is single-use and is rejected if installed.json changes.".to_string(), + "Open terminals, IDEs, services, and agents must be restarted to inherit the new environment." + .to_string(), + "Execution automatically verifies the selected runtime and restores the previous pointer and environment if verification fails." + .to_string(), + ], + risk_level: "medium".to_string(), + plan_fingerprint, + }; + runtime_switch_plans() + .lock() + .map_err(|_| "运行时切换计划存储不可用".to_string())? + .insert( + plan_id, + PendingRuntimeSwitchPlan { + public: plan.clone(), + installed_fingerprint, + candidate: None, + project_root: None, + runtime_backup: None, + }, + ); + Ok(plan) +} + +#[tauri::command] +async fn execute_runtime_switch_plan( + plan_id: String, + confirmation_token: Option, +) -> Result { + run_blocking(move || { + let pending = runtime_switch_plans() + .lock() + .map_err(|_| "运行时切换计划存储不可用".to_string())? + .get(&plan_id) + .cloned() + .ok_or_else(|| "运行时切换计划不存在、已执行或已过期,请重新创建。".to_string())?; + if runtime_switch_plan_is_expired(&pending.public, unix_timestamp()) { + runtime_switch_plans() + .lock() + .map_err(|_| "Runtime switch plan storage is unavailable.".to_string())? + .remove(&plan_id); + return Err("The runtime switch plan has expired. Create a new plan.".to_string()); + } + require_confirmation_token( + confirmation_token, + "execute_runtime_switch_plan", + "execute_runtime_switch_plan", + &plan_id, + &pending.public.risk_level, + &pending.public.plan_fingerprint, + true, + )?; + let pending = take_runtime_switch_plan(&plan_id)?; + if pending.candidate.is_some() { + return execute_trusted_runtime_switch_plan(pending); + } + let paths = load_paths()?; + let installed = load_installed(&paths)?; + let meta = runtime_meta(&pending.public.kind)?; + let record = find_managed_runtime_record( + &installed, + meta, + &pending.public.version, + Some(&pending.public.target_root), + )?; + if managed_runtime_state_fingerprint(&installed, meta, &record) + != pending.installed_fingerprint + { + return Err("运行时安装记录或 current 状态已变化,请重新创建切换计划。".to_string()); + } + switch_runtime_with_paths( + &paths, + pending.public.kind.clone(), + pending.public.version.clone(), + Some(pending.public.target_root.clone()), + )?; + let installed = load_installed(&paths)?; + let record = find_managed_runtime_record( + &installed, + meta, + &pending.public.version, + Some(&pending.public.target_root), + )?; + let verification = verify_registered_runtime(&paths, &installed, meta, &record); + Ok(RuntimeSwitchResult { + success: true, + message: format!( + "已切换当前 {} 到 {},并完成环境与命令强校验;请重新打开终端和 IDE。", + meta.kind, pending.public.version + ), + plan_id: pending.public.plan_id, + backup_name: pending.public.backup_name, + backup_id: pending.public.backup_id, + backup_path: pending.public.backup_path, + user_environment_written: true, + current_process_unchanged: true, + new_child_process_verified: true, + restart_required: true, + selection_scope: "managed-user-environment".to_string(), + rollback_performed: false, + rollback_verified: false, + verification, + }) + }) + .await? +} + +fn runtime_switch_plan_is_expired(plan: &RuntimeSwitchPlan, now: u64) -> bool { + now > plan.expires_at +} + +fn take_runtime_switch_plan(plan_id: &str) -> Result { + runtime_switch_plans() + .lock() + .map_err(|_| "Runtime switch plan storage is unavailable.".to_string())? + .remove(plan_id) + .ok_or_else(|| { + "Runtime switch plan does not exist, was already consumed, or expired.".to_string() + }) +} + +fn execute_trusted_runtime_switch_plan( + pending: PendingRuntimeSwitchPlan, +) -> Result { + let paths = load_paths()?; + let planned = pending + .candidate + .as_ref() + .ok_or_else(|| "The trusted runtime candidate is missing from the plan.".to_string())?; + let current = resolve_trusted_runtime_candidate(&planned.id, &pending.public.switch_mode)?; + if current.verification_fingerprint != planned.verification_fingerprint + || current.runtime_root != planned.runtime_root + || current.executable != planned.executable + || current.source_authority != planned.source_authority + { + return Err( + "The runtime discovery or verification evidence changed after plan creation. Create a new plan." + .to_string(), + ); + } + let pending_backup = pending + .runtime_backup + .as_ref() + .ok_or_else(|| "The runtime switch plan has no persistent recovery record.".to_string())?; + let runtime_backup = load_runtime_switch_backup(&paths, &pending.public.backup_id)?; + if runtime_backup.record_fingerprint != pending_backup.record_fingerprint + || runtime_backup.state_fingerprint != pending.public.state_fingerprint + { + return Err("The runtime switch recovery record changed after plan creation.".to_string()); + } + let environment_backup_path = paths + .config() + .join("env_backups") + .join(&runtime_backup.environment_backup_name); + let environment_before = + environment_backup_values(&read_json::(&environment_backup_path)?); + let installed_before = runtime_backup.installed.clone(); + let selections_before = runtime_backup.selections.clone(); + let current_installed = load_installed(&paths)?; + let current_environment = user_environment()?; + if runtime_switch_state_fingerprint( + ¤t, + ¤t_installed, + ¤t_environment, + pending.project_root.as_deref(), + ) != pending.public.state_fingerprint + { + return Err( + "The installed runtime, user environment, provider, or project state changed after plan creation." + .to_string(), + ); + } + + let provider_before = runtime_backup.provider_state.clone(); + let project_backup = runtime_backup.project_backup.clone(); + let apply_result = apply_runtime_switch_candidate( + &paths, + ¤t, + &pending.public.switch_mode, + pending.project_root.as_deref(), + ); + if let Err(error) = apply_result { + let rollback_verified = rollback_runtime_switch( + &paths, + &installed_before, + &environment_before, + &selections_before, + provider_before.as_ref(), + pending.project_root.as_deref(), + project_backup.as_deref(), + ); + return Err(format!( + "Runtime switch failed and rollback verification was {}: {error}", + if rollback_verified { + "successful" + } else { + "not conclusive" + } + )); + } + let verification = verify_switched_runtime( + &paths, + ¤t, + &pending.public.switch_mode, + pending.project_root.as_deref(), + ); + let verification = match verification { + Ok(verification) => verification, + Err(error) => { + let rollback_verified = rollback_runtime_switch( + &paths, + &installed_before, + &environment_before, + &selections_before, + provider_before.as_ref(), + pending.project_root.as_deref(), + project_backup.as_deref(), + ); + return Err(format!( + "Post-switch verification failed and rollback verification was {}: {error}", + if rollback_verified { + "successful" + } else { + "not conclusive" + } + )); + } + }; + if let Some(expected) = runtime_backup.target_directory_fingerprint.as_deref() { + let directory_unchanged = + runtime_directory_state_fingerprint(Path::new(¤t.runtime_root)) + .map(|actual| actual == expected) + .unwrap_or(false); + if !directory_unchanged { + let rollback_verified = rollback_runtime_switch( + &paths, + &installed_before, + &environment_before, + &selections_before, + provider_before.as_ref(), + pending.project_root.as_deref(), + project_backup.as_deref(), + ); + return Err(format!( + "The external runtime directory changed during switch verification. Environment rollback verification was {}. Inspect the external provider before retrying.", + if rollback_verified { + "successful" + } else { + "not conclusive" + } + )); + } + } + Ok(RuntimeSwitchResult { + success: true, + message: format!( + "{} {} is now selected through {} mode. Restart open terminals and IDEs.", + current.display_name, current.version, pending.public.switch_mode + ), + plan_id: pending.public.plan_id, + backup_name: pending.public.backup_name, + backup_id: pending.public.backup_id, + backup_path: pending.public.backup_path, + user_environment_written: matches!( + pending.public.switch_mode.as_str(), + "managed" | "external-user" + ), + current_process_unchanged: true, + new_child_process_verified: true, + restart_required: pending.public.switch_mode != "project", + selection_scope: pending.public.switch_mode, + rollback_performed: false, + rollback_verified: false, + verification, + }) +} + +fn runtime_environment_fingerprint(environment: &HashMap) -> String { + let mut hasher = Sha256::new(); + for name in [ + "DEVENV_HOME", + "JAVA_HOME", + "MAVEN_HOME", + "M2_HOME", + "GRADLE_HOME", + "GOROOT", + "DOTNET_ROOT", + "RUSTUP_TOOLCHAIN", + ] { + hasher.update(name.as_bytes()); + hasher.update([0]); + hasher.update( + environment + .get(name) + .map(String::as_str) + .unwrap_or("") + .as_bytes(), + ); + hasher.update([0]); + } + hasher.update(b"Path\0"); + hasher.update( + environment + .get("Path") + .or_else(|| environment.get("PATH")) + .map(String::as_str) + .unwrap_or("") + .as_bytes(), + ); + format!("{:x}", hasher.finalize()) +} + +fn runtime_environment_bindings(candidate: &RuntimeInfo) -> Vec<(String, String)> { + let managed_root = |kind: &str| format!(r"%DEVENV_HOME%\current\{kind}"); + let root = if candidate.management == "managed" { + managed_root(&candidate.kind) + } else { + candidate.runtime_root.clone() + }; + let bindings = match candidate.kind.as_str() { + "jdk" => vec![("JAVA_HOME".to_string(), root)], + "maven" => vec![ + ("MAVEN_HOME".to_string(), root.clone()), + ("M2_HOME".to_string(), root), + ], + "gradle" => vec![("GRADLE_HOME".to_string(), root)], + "go" => vec![("GOROOT".to_string(), root)], + _ => Vec::new(), + }; + bindings +} + +fn runtime_path_entries(candidate: &RuntimeInfo) -> Vec { + let root = if candidate.management == "managed" { + format!(r"%DEVENV_HOME%\current\{}", candidate.kind) + } else { + candidate.runtime_root.clone() + }; + match candidate.kind.as_str() { + "jdk" | "go" | "maven" | "gradle" => vec![format!(r"{root}\bin")], + "python" => vec![root.clone(), format!(r"{root}\Scripts")], + "node" => vec![root], + _ => Vec::new(), + } +} + +fn replace_runtime_path_entries( + existing: &str, + candidate: &RuntimeInfo, + discoveries: &[RuntimeInfo], +) -> String { + let removable = discoveries + .iter() + .filter(|runtime| runtime.ecosystem == candidate.ecosystem) + .flat_map(runtime_path_entries) + .chain( + MANAGED_PATHS + .iter() + .filter(|entry| managed_path_ecosystem(entry) == candidate.ecosystem) + .map(|entry| entry.to_string()), + ) + .map(|entry| path_key(&entry)) + .collect::>(); + let target = runtime_path_entries(candidate); + let target_keys = target + .iter() + .map(|entry| path_key(entry)) + .collect::>(); + let mut seen = target_keys.clone(); + target + .into_iter() + .chain( + existing + .split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .filter(|entry| !removable.contains(&path_key(entry))) + .filter(|entry| seen.insert(path_key(entry))) + .map(str::to_string), + ) + .collect::>() + .join(";") +} + +fn managed_path_ecosystem(entry: &str) -> &str { + let lower = entry.to_ascii_lowercase(); + if lower.contains(r"\jdk\") { + "java" + } else if lower.contains(r"\python") { + "python" + } else if lower.contains(r"\node") || lower.contains("npm-global") { + "node" + } else if lower.contains(r"\maven") { + "maven" + } else if lower.contains(r"\gradle") { + "gradle" + } else if lower.contains(r"\go\") { + "go" + } else { + "other" + } +} + +fn apply_runtime_switch_candidate( + paths: &AppPaths, + candidate: &RuntimeInfo, + switch_mode: &str, + project_root: Option<&str>, +) -> Result<(), String> { + match switch_mode { + "managed" => { + let meta = runtime_meta(&candidate.kind)?; + let mut installed = load_installed(paths)?; + let record = find_managed_runtime_record( + &installed, + meta, + &candidate.version, + Some(&candidate.runtime_root), + )?; + let target = PathBuf::from( + record + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| "Managed runtime record has no path.".to_string())?, + ); + switch_junction(&paths.current().join(meta.link_name), &target, &paths.root)?; + set_current(&mut installed, meta.kind, Some(candidate.version.clone())); + save_json(&paths.installed_file(), &installed)?; + apply_runtime_user_environment(paths, candidate)?; + } + "external-user" => { + let mut installed = load_installed(paths)?; + if let Ok(meta) = runtime_meta(&candidate.kind) { + set_current(&mut installed, meta.kind, None); + save_json(&paths.installed_file(), &installed)?; + } + apply_runtime_user_environment(paths, candidate)?; + } + "provider" => { + apply_runtime_provider_selection(candidate)?; + } + "project" => write_dotnet_global_json( + Path::new(project_root.ok_or_else(|| "Project root is required.".to_string())?), + &candidate.version, + )?, + _ => return Err("Unsupported runtime switch mode.".to_string()), + } + if switch_mode != "project" { + save_runtime_selection(paths, candidate, switch_mode)?; + } + broadcast_environment_change(); + Ok(()) +} + +fn apply_runtime_user_environment(paths: &AppPaths, candidate: &RuntimeInfo) -> Result<(), String> { + let environment = user_environment()?; + let old_path = environment + .get("Path") + .or_else(|| environment.get("PATH")) + .map(String::as_str) + .unwrap_or(""); + let all_discoveries = discover_runtimes_blocking(); + let mut removable_discoveries = all_discoveries + .iter() + .filter(|runtime| { + runtime.ecosystem == candidate.ecosystem && runtime.management == "managed" + }) + .cloned() + .collect::>(); + if let Ok(state) = load_runtime_selections(paths) { + if let Some(previous) = state.selections.get(&candidate.ecosystem) { + if let Some(discovered) = all_discoveries + .iter() + .find(|runtime| runtime.id == previous.runtime_id) + { + removable_discoveries.push(discovered.clone()); + } else { + let mut previous_runtime = candidate.clone(); + previous_runtime.id = previous.runtime_id.clone(); + previous_runtime.runtime_root = previous.runtime_root.clone(); + previous_runtime.version = previous.version.clone(); + removable_discoveries.push(previous_runtime); + } + } + } + let new_path = replace_runtime_path_entries(old_path, candidate, &removable_discoveries); + if candidate.management == "managed" { + set_user_environment_variable("DEVENV_HOME", Some(&display_path(&paths.root)))?; + } + for (name, value) in runtime_environment_bindings(candidate) { + set_user_environment_variable(&name, Some(&value))?; + } + set_user_environment_variable("Path", Some(&new_path))?; + let saved = user_environment()?; + if saved + .get("Path") + .or_else(|| saved.get("PATH")) + .map(String::as_str) + != Some(new_path.as_str()) + { + return Err( + "User PATH verification failed after writing the runtime selection.".to_string(), + ); + } + Ok(()) +} + +fn active_rustup_toolchain() -> Option { + run_command_output(PathBuf::from("rustup"), &["show", "active-toolchain"], 30) + .ok() + .and_then(|output| output.split_whitespace().next().map(str::to_string)) +} + +fn provider_target_selection(candidate: &RuntimeInfo) -> Option { + match candidate.provider.as_deref()? { + "rustup" => rustup_toolchain_name(Path::new(&candidate.runtime_root)), + "nvm" | "fnm" | "volta" | "scoop" => runtime_version_argument(&candidate.version), + _ => None, + } +} + +fn provider_scoop_app_id(candidate: &RuntimeInfo) -> String { + if candidate.source.to_ascii_lowercase().contains("nodejs-lts") + || candidate + .runtime_root + .to_ascii_lowercase() + .contains(r"\apps\nodejs-lts\") + { + "nodejs-lts".to_string() + } else { + "nodejs".to_string() + } +} + +fn run_runtime_provider_command( + provider: &str, + args: &[String], + timeout_seconds: u64, +) -> Result { + let executable = provider_command_path(provider) + .ok_or_else(|| format!("{provider} provider CLI is unavailable."))?; + let args = args.iter().map(String::as_str).collect::>(); + run_command_output(executable, &args, timeout_seconds) +} + +fn scoop_current_node_path() -> Option { + let output = + run_runtime_provider_command("scoop", &["which".to_string(), "node".to_string()], 30) + .ok()?; + output.lines().rev().find_map(|line| { + let value = line.trim().trim_matches('"'); + let lower = value.to_ascii_lowercase(); + if lower.contains(r"\apps\") && lower.ends_with("node.exe") { + Some(PathBuf::from(value)) + } else { + None + } + }) +} + +fn scoop_app_id_from_path(path: &Path) -> Option { + let components = path + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>(); + components.windows(2).find_map(|pair| { + pair[0] + .eq_ignore_ascii_case("apps") + .then(|| pair[1].clone()) + }) +} + +fn scoop_current_app_id() -> Option { + scoop_current_node_path() + .as_deref() + .and_then(scoop_app_id_from_path) +} + +fn provider_current_selection(candidate: &RuntimeInfo) -> Option { + let provider = candidate.provider.as_deref()?; + let output = match provider { + "rustup" => return active_rustup_toolchain(), + "nvm" => run_runtime_provider_command(provider, &["current".to_string()], 30).ok()?, + "fnm" => run_runtime_provider_command(provider, &["default".to_string()], 30).ok()?, + "volta" => run_runtime_provider_command( + provider, + &[ + "list".to_string(), + "--default".to_string(), + "--format".to_string(), + "plain".to_string(), + "node".to_string(), + ], + 30, + ) + .ok()?, + "scoop" => { + let node = scoop_current_node_path()?; + run_command_output(node, &["--version"], 30).ok()? + } + _ => return None, + }; + runtime_version_argument(&output) +} + +fn capture_runtime_provider_state(candidate: &RuntimeInfo) -> Option { + let provider = candidate.provider.clone()?; + let selection = provider_current_selection(candidate)?; + let app_id = if provider == "scoop" { + Some(scoop_current_app_id()?) + } else { + None + }; + Some(RuntimeProviderState { + provider, + selection, + app_id, + }) +} + +fn apply_provider_selection( + provider: &str, + selection: &str, + app_id: Option<&str>, +) -> Result<(), String> { + let args = provider_switch_arguments(provider, selection, app_id)?; + run_runtime_provider_command(provider, &args, 180)?; + Ok(()) +} + +fn provider_switch_arguments( + provider: &str, + selection: &str, + app_id: Option<&str>, +) -> Result, String> { + let args = match provider { + "rustup" => vec!["default".to_string(), selection.to_string()], + "nvm" => vec!["use".to_string(), selection.to_string()], + "fnm" => vec!["default".to_string(), selection.to_string()], + "volta" => vec!["install".to_string(), format!("node@{selection}")], + "scoop" => vec![ + "reset".to_string(), + format!("{}@{selection}", app_id.unwrap_or("nodejs")), + ], + _ => return Err(format!("No provider switch adapter exists for {provider}.")), + }; + Ok(args) +} + +fn apply_runtime_provider_selection(candidate: &RuntimeInfo) -> Result<(), String> { + let provider = candidate + .provider + .as_deref() + .ok_or_else(|| "The selected runtime has no provider authority.".to_string())?; + let selection = provider_target_selection(candidate) + .ok_or_else(|| "The provider target selection is invalid.".to_string())?; + let app_id = (provider == "scoop").then(|| provider_scoop_app_id(candidate)); + apply_provider_selection(provider, &selection, app_id.as_deref()) +} + +fn restore_runtime_provider_state(state: &RuntimeProviderState) -> bool { + apply_provider_selection(&state.provider, &state.selection, state.app_id.as_deref()).is_ok() +} + +fn verify_runtime_provider_selection(candidate: &RuntimeInfo) -> Result<(), String> { + let provider = candidate + .provider + .as_deref() + .ok_or_else(|| "The selected runtime has no provider authority.".to_string())?; + let expected = provider_target_selection(candidate) + .ok_or_else(|| "The provider target selection is invalid.".to_string())?; + let actual = provider_current_selection(candidate) + .ok_or_else(|| format!("{provider} did not report a current/default selection."))?; + let matches = if provider == "rustup" { + actual == expected + } else { + version_key(&actual) == version_key(&expected) + }; + if !matches { + return Err(format!( + "{provider} selected {actual}, but the reviewed target was {expected}." + )); + } + if candidate.kind != "node" { + return Ok(()); + } + + for (command, command_args) in [ + ("node", vec!["--version".to_string()]), + ("npm", vec!["--version".to_string()]), + ("npx", vec!["--version".to_string()]), + ] { + let output = if provider == "fnm" { + let mut args = vec![ + "exec".to_string(), + format!("--using={expected}"), + command.to_string(), + ]; + args.extend(command_args); + run_runtime_provider_command(provider, &args, 90)? + } else if provider == "volta" { + let mut args = vec![ + "run".to_string(), + "--node".to_string(), + expected.clone(), + command.to_string(), + ]; + args.extend(command_args); + run_runtime_provider_command(provider, &args, 90)? + } else { + let executable = find_all_on_path(command) + .into_iter() + .next() + .ok_or_else(|| format!("{command} is unavailable after {provider} selection."))?; + let args = command_args.iter().map(String::as_str).collect::>(); + run_command_output(executable, &args, 90)? + }; + if command == "node" && version_key_from_output(&output) != version_key(&expected) { + return Err(format!( + "A new provider-routed Node.js process reported a version different from {expected}." + )); + } + } + Ok(()) +} + +fn backup_project_global_json( + paths: &AppPaths, + project_root: Option<&str>, +) -> Result, String> { + let root = PathBuf::from(project_root.ok_or_else(|| "Project root is required.".to_string())?); + let target = root.join("global.json"); + let backup_dir = paths.config().join("runtime_project_backups"); + fs::create_dir_all(&backup_dir) + .map_err(|error| format!("Failed to create project backup directory: {error}"))?; + let backup = backup_dir.join(format!( + "global-json-{}-{}.json", + filename_timestamp(), + RUNTIME_SWITCH_BACKUP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let content = fs::read_to_string(&target).ok(); + if content + .as_ref() + .is_some_and(|value| value.len() > 256 * 1024) + { + return Err("global.json exceeds the 256 KiB backup safety limit.".to_string()); + } + let record = json!({ + "target": display_path(&target), + "existed": target.is_file(), + "content": content, + }); + save_json(&backup, &record)?; + Ok(Some(display_path(backup))) +} + +fn write_dotnet_global_json(project_root: &Path, version: &str) -> Result<(), String> { + validate_dotnet_project_root(project_root)?; + let target = project_root.join("global.json"); + if target.exists() && path_is_reparse_point(&target) { + return Err("Refusing to replace a linked global.json file.".to_string()); + } + let mut document = if target.is_file() { + serde_json::from_str::( + &fs::read_to_string(&target) + .map_err(|error| format!("Failed to read global.json: {error}"))?, + ) + .map_err(|error| format!("global.json is not valid JSON: {error}"))? + } else { + json!({}) + }; + let object = document + .as_object_mut() + .ok_or_else(|| "global.json must contain a JSON object.".to_string())?; + let sdk = object.entry("sdk").or_insert_with(|| json!({})); + let sdk = sdk + .as_object_mut() + .ok_or_else(|| "global.json sdk must contain a JSON object.".to_string())?; + sdk.insert("version".to_string(), Value::String(version.to_string())); + save_json(&target, &document) +} + +fn restore_project_global_json(backup: &str) -> Result<(), String> { + let record: Value = read_json(Path::new(backup))?; + let target = PathBuf::from( + record + .get("target") + .and_then(Value::as_str) + .ok_or_else(|| "Project backup has no target.".to_string())?, + ); + let project_root = target + .parent() + .ok_or_else(|| "Project backup target has no parent directory.".to_string())?; + if target.file_name() != Some(OsStr::new("global.json")) { + return Err("Project backup target is not global.json.".to_string()); + } + validate_dotnet_project_root(project_root)?; + if target.exists() && path_is_reparse_point(&target) { + return Err("Refusing to restore through a linked global.json file.".to_string()); + } + if record + .get("existed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + fs::write( + target, + record + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(), + ) + .map_err(|error| format!("Failed to restore global.json: {error}")) + } else if target.exists() { + fs::remove_file(target).map_err(|error| format!("Failed to remove global.json: {error}")) + } else { + Ok(()) + } +} + +fn rollback_runtime_switch( + paths: &AppPaths, + installed: &InstalledData, + environment: &HashMap, + selections: &RuntimeSelectionState, + provider_state: Option<&RuntimeProviderState>, + project_root: Option<&str>, + project_backup: Option<&str>, +) -> bool { + let installed_restored = save_json(&paths.installed_file(), installed).is_ok(); + let junctions_restored = restore_managed_runtime_junctions(paths, installed); + let environment_restored = restore_runtime_environment(environment).is_ok(); + let selections_restored = save_json(&paths.runtime_selections_file(), selections).is_ok(); + let provider_restored = provider_state + .map(restore_runtime_provider_state) + .unwrap_or(true); + let project_restored = match (project_root, project_backup) { + (Some(_), Some(backup)) => restore_project_global_json(backup).is_ok(), + _ => true, + }; + broadcast_environment_change(); + installed_restored + && junctions_restored + && environment_restored + && selections_restored + && provider_restored + && project_restored + && user_environment() + .map(|current| { + runtime_environment_fingerprint(¤t) + == runtime_environment_fingerprint(environment) + }) + .unwrap_or(false) + && verify_restored_runtime_state(paths, installed, selections) +} + +fn verify_restored_runtime_state( + paths: &AppPaths, + installed: &InstalledData, + selections: &RuntimeSelectionState, +) -> bool { + let runtimes = discover_runtimes_blocking(); + let managed_verified = ["jdk", "python", "node", "maven", "gradle", "go"] + .into_iter() + .all(|kind| { + let Some(version) = current_version_for_kind(installed, kind) else { + return true; + }; + runtimes.iter().any(|runtime| { + runtime.management == "managed" + && runtime.kind == kind + && runtime.version == version + && runtime.current + && verify_external_user_environment(paths, runtime).is_ok() + }) + }); + let selections_verified = selections.selections.values().all(|selection| { + runtimes + .iter() + .find(|runtime| runtime.id == selection.runtime_id) + .is_some_and(|runtime| { + persisted_runtime_selection_is_effective(paths, runtime, selection) + }) + }); + managed_verified && selections_verified +} + +fn restore_managed_runtime_junctions(paths: &AppPaths, installed: &InstalledData) -> bool { + ["jdk", "python", "node", "maven", "gradle", "go"] + .into_iter() + .all(|kind| { + let Ok(meta) = runtime_meta(kind) else { + return false; + }; + let link = paths.current().join(meta.link_name); + let target = current_version_for_kind(installed, kind).and_then(|version| { + collection(installed, meta.collection) + .iter() + .find(|record| record.get("version").and_then(Value::as_str) == Some(version)) + .and_then(|record| record.get("path").and_then(Value::as_str)) + .map(PathBuf::from) + }); + match target { + Some(target) => switch_junction(&link, &target, &paths.root).is_ok(), + None => remove_junction(&link).is_ok(), + } + }) +} + +fn restore_runtime_environment(environment: &HashMap) -> Result<(), String> { + for name in [ + "DEVENV_HOME", + "JAVA_HOME", + "MAVEN_HOME", + "M2_HOME", + "GRADLE_HOME", + "GOROOT", + "DOTNET_ROOT", + "RUSTUP_TOOLCHAIN", + ] { + set_user_environment_variable(name, environment.get(name).map(String::as_str))?; + } + set_user_environment_variable( + "Path", + environment + .get("Path") + .or_else(|| environment.get("PATH")) + .map(String::as_str), + ) +} + +fn verify_switched_runtime( + paths: &AppPaths, + candidate: &RuntimeInfo, + switch_mode: &str, + project_root: Option<&str>, +) -> Result { + if switch_mode == "project" { + let root = + PathBuf::from(project_root.ok_or_else(|| "Project root is required.".to_string())?); + if dotnet_required_sdk(&root).as_deref() != Some(candidate.version.as_str()) { + return Err("global.json does not contain the requested .NET SDK version.".to_string()); + } + let output = powershell_runner::run_probe_command_with_env_and_cwd( + Path::new(&candidate.executable), + &["--version"], + 60, + &[], + &root, + ) + .map_err(|error| format!("Failed to verify dotnet in the selected project: {error}"))?; + if !output.success + || version_key_from_output(&format!("{}\n{}", output.stdout, output.stderr)) + != version_key_from_output(&candidate.version) + { + return Err(format!( + "A new dotnet process in the selected project did not resolve SDK {}: {}", + candidate.version, + powershell_runner::native_command_message(&output) + )); + } + } else if switch_mode == "provider" { + verify_runtime_provider_selection(candidate)?; + } + let mut verification = verify_external_runtime(candidate.clone()); + if candidate.management == "managed" { + let installed = load_installed(paths)?; + let meta = runtime_meta(&candidate.kind)?; + let record = find_managed_runtime_record( + &installed, + meta, + &candidate.version, + Some(&candidate.runtime_root), + )?; + verification = verify_registered_runtime(paths, &installed, meta, &record); + } else if candidate.kind == "jdk" { + let installed = load_installed(paths)?; + verification.checks.extend(java_build_tool_checks( + &installed, + Path::new(&candidate.runtime_root), + )); + } + let failed_required = verification + .checks + .iter() + .filter(|check| check.required) + .find(|check| check.status != "passed"); + if let Some(failed) = failed_required { + return Err(format!( + "Required verification failed at {}: {}", + failed.id, + failed.error.as_deref().unwrap_or(&failed.actual) + )); + } + if matches!(switch_mode, "managed" | "external-user") { + verify_external_user_environment(paths, candidate)?; + } + if switch_mode == "managed" && (!verification.current || !verification.environment_effective) { + return Err( + "The managed runtime pointer or user environment is not effective.".to_string(), + ); + } + verification.current = true; + verification.environment_effective = true; + Ok(verification) +} + +fn verify_external_user_environment( + paths: &AppPaths, + candidate: &RuntimeInfo, +) -> Result<(), String> { + let environment = user_environment()?; + let path = environment + .get("Path") + .or_else(|| environment.get("PATH")) + .map(String::as_str) + .unwrap_or(""); + let commands: &[(&str, &[&str])] = match candidate.kind.as_str() { + "jdk" => &[ + ("java", &["-version"]), + ("javac", &["-version"]), + ("jar", &["--help"]), + ], + "python" => &[("python", &["--version"]), ("pip", &["--version"])], + "node" => &[ + ("node", &["--version"]), + ("npm", &["--version"]), + ("npx", &["--version"]), + ], + "go" => &[("go", &["version"])], + "maven" => &[("mvn", &["--version"])], + "gradle" => &[("gradle", &["--version"])], + _ => return Err("No user-environment verification adapter exists.".to_string()), + }; + let expanded_path = path + .split(';') + .map(|entry| expand_environment_path(entry, paths)) + .collect::>() + .join(";"); + let mut child_environment = vec![("PATH".to_string(), expanded_path)]; + if candidate.kind == "python" { + child_environment.push(("PYTHONDONTWRITEBYTECODE".to_string(), "1".to_string())); + } + for name in [ + "DEVENV_HOME", + "JAVA_HOME", + "MAVEN_HOME", + "M2_HOME", + "GRADLE_HOME", + "GOROOT", + "DOTNET_ROOT", + "RUSTUP_TOOLCHAIN", + ] { + if let Some(value) = environment.get(name) { + child_environment.push((name.to_string(), expand_environment_path(value, paths))); + } + } + let child_environment_refs = child_environment + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect::>(); + for (command, args) in commands { + let first = find_in_configured_path(command, path, paths) + .ok_or_else(|| format!("{command} is not resolvable from the saved user PATH."))?; + if !is_path_inside(&first, Path::new(&candidate.runtime_root)) { + return Err(format!( + "The first {command} on the saved user PATH does not belong to the selected runtime." + )); + } + let output = powershell_runner::run_probe_command_with_env( + &first, + args, + 90, + &child_environment_refs, + ) + .map_err(|error| format!("Failed to start {command} in a new child process: {error}"))?; + if !output.success { + return Err(format!( + "The new child process verification for {command} failed: {}", + powershell_runner::native_command_message(&output) + )); + } + if *command == commands[0].0 { + let actual = format!("{}\n{}", output.stdout, output.stderr); + let expected = version_key_from_output(&candidate.version); + if expected != (0, 0, 0) && version_key_from_output(&actual) != expected { + return Err(format!( + "The new child process for {command} reported a different runtime version." + )); + } + } + } + for (name, expected) in runtime_environment_bindings(candidate) { + let actual = environment.get(&name).map(String::as_str).unwrap_or(""); + if path_key(actual) != path_key(&expected) { + return Err(format!("{name} does not match the selected runtime root.")); + } + } + Ok(()) +} + +fn runtime_plan_id(kind: &str, version: &str, path: Option<&str>) -> String { + format!( + "{}:{}:{}", + kind.trim(), version.trim(), path.unwrap_or("").trim() ) @@ -13020,8 +15233,11 @@ pub fn run() { install_gradle_latest, install_gradle, create_runtime_switch_plan, + cancel_runtime_switch_plan, + export_runtime_switch_plan, execute_runtime_switch_plan, - switch_runtime, + list_runtime_switch_backups, + restore_runtime_switch_backup, uninstall_runtime, kill_process, scan_ports, @@ -13434,6 +15650,9 @@ impl AppPaths { fn installed_file(&self) -> PathBuf { self.config().join("installed.json") } + fn runtime_selections_file(&self) -> PathBuf { + self.config().join("runtime_selections.json") + } fn env_backup_file(&self) -> PathBuf { self.config().join("env_backup.json") } @@ -14748,6 +16967,14 @@ fn version_key(tag: &str) -> (u64, u64, u64) { ) } +fn version_key_from_output(output: &str) -> (u64, u64, u64) { + output + .split(|character: char| !(character.is_ascii_digit() || character == '.')) + .find(|part| part.chars().next().is_some_and(|ch| ch.is_ascii_digit())) + .map(version_key) + .unwrap_or((0, 0, 0)) +} + fn validate_download_url(url: &str) -> Result<(), String> { let parsed = reqwest::Url::parse(url).map_err(|err| format!("下载地址无效:{err}"))?; validate_download_url_value(&parsed) @@ -15294,76 +17521,542 @@ fn detect_runtime(kind: &str, executable: &str, args: &[&str]) -> Option, -) -> Option { - if executable.components().count() > 1 && !executable.is_file() { - return None; +fn detect_runtime_at( + kind: &str, + executable: &Path, + args: &[&str], + source: Option, +) -> Option { + if executable.components().count() > 1 && !executable.is_file() { + return None; + } + let output = hidden_command(executable).args(args).output().ok()?; + let version = runtime_probe_version(output.status.success(), &output.stdout, &output.stderr)?; + let path = display_path(executable); + + Some(runtime_info( + kind, + version, + executable, + None, + source.unwrap_or_else(|| classify_source(&path)), + "external", + false, + None, + )) +} + +fn runtime_probe_version(success: bool, stdout: &[u8], stderr: &[u8]) -> Option { + if !success { + return None; + } + let stdout = String::from_utf8_lossy(stdout); + let stderr = String::from_utf8_lossy(stderr); + first_meaningful_output_line(if stdout.trim().is_empty() { + stderr.as_ref() + } else { + stdout.as_ref() + }) +} + +#[allow(clippy::too_many_arguments)] +fn runtime_info( + label: &str, + version: String, + executable: &Path, + root: Option<&Path>, + source: String, + management: &str, + current: bool, + installed_at: Option, +) -> RuntimeInfo { + let executable_text = display_path(executable); + let (kind, ecosystem, display_name) = runtime_identity_parts(label); + let runtime_root = root + .map(display_path) + .unwrap_or_else(|| runtime_root_for_executable(&kind, executable)); + let mut hasher = Sha256::new(); + hasher.update(kind.as_bytes()); + hasher.update(path_key(&executable_text).as_bytes()); + hasher.update(version.as_bytes()); + RuntimeInfo { + id: format!("runtime-{:x}", hasher.finalize()), + kind, + display_name, + ecosystem, + version, + executable: executable_text, + runtime_root, + source, + management: management.to_string(), + source_authority: if management == "managed" { + "devenv-managed-registry".to_string() + } else { + "external-discovery".to_string() + }, + provider: None, + switch_modes: if management == "managed" { + vec!["managed".to_string()] + } else { + Vec::new() + }, + switch_eligible: management == "managed", + switch_blockers: Vec::new(), + verification_fingerprint: String::new(), + current, + installed_at, + } +} + +fn populate_runtime_switch_capabilities(runtimes: &mut [RuntimeInfo]) { + for runtime in runtimes { + if runtime.management == "managed" { + runtime.source_authority = "devenv-managed-registry".to_string(); + runtime.switch_modes = vec!["managed".to_string()]; + runtime.switch_eligible = true; + runtime.verification_fingerprint = runtime_identity_fingerprint(runtime, &[]); + continue; + } + + runtime.source_authority = external_runtime_authority(runtime); + let root = PathBuf::from(&runtime.runtime_root); + if let Some(reason) = unsafe_external_runtime_root(&root) { + runtime.switch_blockers = vec![reason]; + runtime.switch_eligible = false; + continue; + } + if runtime.kind == "node" { + if let Some(provider) = provider_managed_node_runtime(runtime) { + runtime.provider = Some(provider.to_string()); + runtime.source_authority = format!("{provider}-managed-discovery"); + runtime.verification_fingerprint = runtime_identity_fingerprint(runtime, &[]); + if provider_command_path(provider).is_some() { + runtime.switch_modes = vec!["provider".to_string()]; + runtime.switch_eligible = true; + runtime.switch_blockers.clear(); + } else { + runtime.switch_modes.clear(); + runtime.switch_eligible = false; + runtime.switch_blockers = vec![format!( + "This Node.js installation is managed by {provider}, but its provider CLI is unavailable. DevEnv Manager keeps the provider directory and shims read-only." + )]; + } + continue; + } + } + + runtime.verification_fingerprint = runtime_identity_fingerprint(runtime, &[]); + + match runtime.kind.as_str() { + "jdk" | "python" | "node" | "go" | "maven" | "gradle" => { + runtime.switch_modes = vec!["external-user".to_string()]; + runtime.switch_eligible = true; + } + "rustc" if rustup_toolchain_name(&root).is_some() => { + runtime.provider = Some("rustup".to_string()); + runtime.source_authority = "rustup-toolchain".to_string(); + runtime.switch_modes = vec!["provider".to_string()]; + runtime.switch_eligible = true; + } + "dotnet" => { + runtime.provider = Some("dotnet-global-json".to_string()); + runtime.switch_modes = vec!["project".to_string()]; + runtime.switch_eligible = true; + } + _ => { + runtime.switch_blockers = vec![ + "No safe user-environment or provider switch adapter is available.".to_string(), + ]; + runtime.switch_eligible = false; + } + } + } +} + +fn provider_managed_node_runtime(runtime: &RuntimeInfo) -> Option<&'static str> { + let evidence = format!( + "{}\\{}\\{}", + runtime.source, runtime.runtime_root, runtime.executable + ) + .replace('/', "\\") + .to_ascii_lowercase(); + if evidence.contains("\\.volta\\") || evidence.contains("\\volta\\") { + Some("volta") + } else if evidence.contains("\\fnm\\") || evidence.contains("\\fnm_multishells\\") { + Some("fnm") + } else if evidence.contains("\\nvm\\") || evidence.contains("\\nvm-windows\\") { + Some("nvm") + } else if evidence.contains("\\scoop\\apps\\nodejs") + || (evidence.contains("scoop") && evidence.contains("node")) + { + Some("scoop") + } else { + None + } +} + +fn provider_command_path(provider: &str) -> Option { + let mut candidates = find_all_on_path(provider); + match provider { + "nvm" => { + if let Some(root) = env::var_os("NVM_HOME").map(PathBuf::from) { + candidates.push(root.join("nvm.exe")); + } + } + "fnm" => { + if let Some(root) = env::var_os("FNM_DIR").map(PathBuf::from) { + candidates.push(root.join("fnm.exe")); + } + } + "volta" => { + if let Some(root) = env::var_os("VOLTA_HOME").map(PathBuf::from) { + candidates.push(root.join("bin/volta.exe")); + } + } + "scoop" => { + if let Some(root) = env::var_os("USERPROFILE").map(PathBuf::from) { + candidates.push(root.join("scoop/shims/scoop.cmd")); + candidates.push(root.join("scoop/shims/scoop.exe")); + } + } + "rustup" => {} + _ => return None, + } + candidates.into_iter().find(|candidate| candidate.is_file()) +} + +fn provider_version_args(provider: &str) -> &'static [&'static str] { + if provider == "nvm" { + &["version"] + } else { + &["--version"] + } +} + +fn runtime_version_argument(value: &str) -> Option { + let mut current = String::new(); + let mut started = false; + for character in value.chars() { + if character.is_ascii_digit() { + started = true; + current.push(character); + } else if started && character == '.' { + current.push(character); + } else if started { + break; + } + } + let version = current.trim_matches('.').to_string(); + (!version.is_empty()).then_some(version) +} + +fn load_runtime_selections(paths: &AppPaths) -> Result { + load_json_with_default( + &paths.runtime_selections_file(), + RuntimeSelectionState::default(), + ) +} + +fn save_runtime_selection( + paths: &AppPaths, + candidate: &RuntimeInfo, + switch_mode: &str, +) -> Result<(), String> { + let mut state = load_runtime_selections(paths)?; + state.selections.insert( + candidate.ecosystem.clone(), + RuntimeSelectionRecord { + runtime_id: candidate.id.clone(), + ecosystem: candidate.ecosystem.clone(), + switch_mode: switch_mode.to_string(), + runtime_root: candidate.runtime_root.clone(), + version: candidate.version.clone(), + selected_at: current_timestamp(), + }, + ); + save_json(&paths.runtime_selections_file(), &state) +} + +fn mark_persisted_runtime_selections(runtimes: &mut [RuntimeInfo]) { + let Ok(paths) = load_paths() else { + return; + }; + let Ok(state) = load_runtime_selections(&paths) else { + return; + }; + for selection in state.selections.values() { + let Some(target) = runtimes + .iter() + .find(|runtime| runtime.id == selection.runtime_id) + .cloned() + else { + continue; + }; + if !persisted_runtime_selection_is_effective(&paths, &target, selection) { + continue; + } + for runtime in runtimes.iter_mut() { + if runtime.ecosystem == selection.ecosystem { + runtime.current = runtime.id == selection.runtime_id; + } + } + } +} + +fn persisted_runtime_selection_is_effective( + paths: &AppPaths, + runtime: &RuntimeInfo, + selection: &RuntimeSelectionRecord, +) -> bool { + match selection.switch_mode.as_str() { + "managed" => runtime.current, + "external-user" => verify_external_user_environment(paths, runtime).is_ok(), + "provider" => verify_runtime_provider_selection(runtime).is_ok(), + _ => false, + } +} + +fn runtime_identity_fingerprint( + runtime: &RuntimeInfo, + checks: &[runtime_verification::RuntimeVerificationCheck], +) -> String { + let mut hasher = Sha256::new(); + for value in [ + runtime.id.as_str(), + runtime.kind.as_str(), + runtime.version.as_str(), + runtime.executable.as_str(), + runtime.runtime_root.as_str(), + runtime.source.as_str(), + runtime.management.as_str(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + for check in checks { + hasher.update(check.id.as_bytes()); + hasher.update(check.status.as_bytes()); + hasher.update(check.actual.as_bytes()); + hasher.update([check.required as u8]); + } + format!("{:x}", hasher.finalize()) +} + +fn external_runtime_authority(runtime: &RuntimeInfo) -> String { + let source = runtime.source.to_ascii_lowercase(); + if source.contains("rustup") { + "rustup-provider" + } else if source.contains("dotnet --list-sdks") { + "dotnet-sdk-inventory" + } else if source.contains("scoop") { + "scoop-managed-discovery" + } else if source.contains("chocolatey") { + "chocolatey-managed-discovery" + } else if source.contains("registry") { + "windows-registry-discovery" + } else if source.contains("java_home") { + "user-environment-discovery" + } else if source.contains("system") { + "system-install-discovery" + } else if source.contains("path") { + "path-discovery" + } else { + "executable-discovery" + } + .to_string() +} + +fn unsafe_external_runtime_root(root: &Path) -> Option { + if !root.is_absolute() || !root.is_dir() { + return Some("Runtime root is missing or is not an absolute directory.".to_string()); + } + let key = format!("\\{}\\", path_key(&display_path(root)).replace('/', "\\")); + if [ + "\\temp\\", + "\\tmp\\", + "\\downloads\\", + "\\$recycle.bin\\", + "\\windowsapps\\", + ] + .iter() + .any(|fragment| key.contains(fragment)) + { + return Some( + "Runtime root is in a temporary, download, recycle-bin, or app-alias directory." + .to_string(), + ); + } + if root + .ancestors() + .take_while(|ancestor| ancestor.parent().is_some()) + .any(path_is_reparse_point) + { + return Some( + "External runtime roots below a symbolic link, junction, or reparse point are read-only." + .to_string(), + ); + } + None +} + +fn rustup_toolchain_name(root: &Path) -> Option { + let parts = root + .components() + .map(|part| part.as_os_str().to_string_lossy().to_string()) + .collect::>(); + let index = parts + .iter() + .position(|part| part.eq_ignore_ascii_case("toolchains"))?; + parts.get(index + 1).cloned() +} + +fn add_rustup_toolchain_discoveries(runtimes: &mut Vec) { + let rustup = find_all_on_path("rustup") + .into_iter() + .next() + .unwrap_or_else(|| PathBuf::from("rustup")); + let Ok(output) = run_command_output(rustup.clone(), &["toolchain", "list"], 30) else { + return; + }; + for toolchain in output + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter(|toolchain| !toolchain.is_empty()) + .take(50) + { + let Ok(rustc_output) = run_command_output( + rustup.clone(), + &["which", "--toolchain", toolchain, "rustc"], + 30, + ) else { + continue; + }; + let rustc = PathBuf::from(rustc_output.lines().next().unwrap_or("").trim()); + if !rustc.is_file() { + continue; + } + if let Some(runtime) = detect_runtime_at( + "Rust", + &rustc, + &["--version"], + Some(format!("rustup toolchain {toolchain}")), + ) { + push_runtime(runtimes, runtime); + } + } +} + +fn add_node_provider_discoveries(runtimes: &mut Vec) { + let mut inventories = Vec::new(); + if let Some(root) = env::var_os("NVM_HOME").map(PathBuf::from) { + inventories.push(("nvm", "nvm-windows inventory".to_string(), root, false)); + } + let fnm_root = env::var_os("FNM_DIR") + .map(PathBuf::from) + .or_else(|| { + env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|root| root.join("fnm")) + }) + .map(|root| root.join("node-versions")); + if let Some(root) = fnm_root { + inventories.push(("fnm", "fnm inventory".to_string(), root, true)); + } + let volta_root = env::var_os("VOLTA_HOME") + .map(PathBuf::from) + .or_else(|| { + env::var_os("USERPROFILE") + .map(PathBuf::from) + .map(|root| root.join(".volta")) + }) + .map(|root| root.join("tools/image/node")); + if let Some(root) = volta_root { + inventories.push(("volta", "Volta inventory".to_string(), root, false)); + } + if let Some(scoop_apps) = env::var_os("USERPROFILE") + .map(PathBuf::from) + .map(|root| root.join("scoop/apps")) + { + for app in ["nodejs", "nodejs-lts"] { + inventories.push(( + "scoop", + format!("Scoop {app} inventory"), + scoop_apps.join(app), + false, + )); + } } - let output = hidden_command(executable).args(args).output().ok()?; - let version = runtime_probe_version(output.status.success(), &output.stdout, &output.stderr)?; - let path = display_path(executable); - - Some(runtime_info( - kind, - version, - executable, - None, - source.unwrap_or_else(|| classify_source(&path)), - "external", - false, - None, - )) -} -fn runtime_probe_version(success: bool, stdout: &[u8], stderr: &[u8]) -> Option { - if !success { - return None; + for (provider, source, root, installation_subdirectory) in inventories { + let Ok(entries) = fs::read_dir(root) else { + continue; + }; + for entry in entries.flatten().take(50) { + if entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case("current") + { + continue; + } + let candidate_root = if installation_subdirectory { + entry.path().join("installation") + } else { + entry.path() + }; + let executable = candidate_root.join("node.exe"); + if !executable.is_file() { + continue; + } + if let Some(mut runtime) = + detect_runtime_at("Node.js", &executable, &["--version"], Some(source.clone())) + { + runtime.provider = Some(provider.to_string()); + push_runtime(runtimes, runtime); + } + } } - let stdout = String::from_utf8_lossy(stdout); - let stderr = String::from_utf8_lossy(stderr); - first_meaningful_output_line(if stdout.trim().is_empty() { - stderr.as_ref() - } else { - stdout.as_ref() - }) } -#[allow(clippy::too_many_arguments)] -fn runtime_info( - label: &str, - version: String, - executable: &Path, - root: Option<&Path>, - source: String, - management: &str, - current: bool, - installed_at: Option, -) -> RuntimeInfo { - let executable_text = display_path(executable); - let (kind, ecosystem, display_name) = runtime_identity_parts(label); - let runtime_root = root - .map(display_path) - .unwrap_or_else(|| runtime_root_for_executable(&kind, executable)); - let mut hasher = Sha256::new(); - hasher.update(kind.as_bytes()); - hasher.update(path_key(&executable_text).as_bytes()); - RuntimeInfo { - id: format!("runtime-{:x}", hasher.finalize()), - kind, - display_name, - ecosystem, - version, - executable: executable_text, - runtime_root, - source, - management: management.to_string(), - current, - installed_at, +fn add_dotnet_sdk_discoveries(runtimes: &mut Vec) { + let Some(dotnet) = find_all_on_path("dotnet").into_iter().next() else { + return; + }; + let Ok(output) = run_command_output(dotnet.clone(), &["--list-sdks"], 30) else { + return; + }; + let active = run_command_output(dotnet.clone(), &["--version"], 30) + .ok() + .and_then(|value| value.lines().next().map(str::trim).map(str::to_string)); + let mut discovered = Vec::new(); + for line in output.lines().take(100) { + let Some((version, location)) = line.split_once(' ') else { + continue; + }; + let sdk_directory = location + .trim() + .trim_start_matches('[') + .trim_end_matches(']'); + let root = PathBuf::from(sdk_directory) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| dotnet.parent().unwrap_or(&dotnet).to_path_buf()); + discovered.push(runtime_info( + "dotnet", + version.trim().to_string(), + &dotnet, + Some(&root), + "dotnet --list-sdks".to_string(), + "external", + active.as_deref() == Some(version.trim()), + None, + )); } + if discovered.is_empty() { + return; + } + runtimes.retain(|runtime| runtime.kind != "dotnet"); + runtimes.extend(discovered); } fn runtime_identity_parts(label: &str) -> (String, String, String) { @@ -15432,7 +18125,7 @@ fn mark_path_current_runtimes(runtimes: &mut [RuntimeInfo]) { "rustc" => "rustc", "cargo" => "cargo", "rustup" => "rustup", - "dotnet" => "dotnet", + "dotnet" => continue, _ => continue, }; runtime.current = find_on_path(executable_name) @@ -15473,17 +18166,31 @@ fn find_all_on_path(executable: &str) -> Vec { fn push_runtime(runtimes: &mut Vec, info: RuntimeInfo) { let key = format!( - "{}|{}", + "{}|{}|{}", info.kind.to_ascii_lowercase(), - path_key(&info.executable) + path_key(&info.executable), + if info.kind == "dotnet" { + info.version.as_str() + } else { + "" + } ); - if !runtimes.iter().any(|item| { + if let Some(index) = runtimes.iter().position(|item| { format!( - "{}|{}", + "{}|{}|{}", item.kind.to_ascii_lowercase(), - path_key(&item.executable) + path_key(&item.executable), + if item.kind == "dotnet" { + item.version.as_str() + } else { + "" + } ) == key }) { + if info.management == "managed" && runtimes[index].management != "managed" { + runtimes[index] = info; + } + } else { runtimes.push(info); } } @@ -16978,7 +19685,20 @@ fn tool_registry() -> Vec { } fn set_user_environment_variable(name: &str, value: Option<&str>) -> Result<(), String> { - if !["GOPROXY"].contains(&name) { + if ![ + "DEVENV_HOME", + "JAVA_HOME", + "MAVEN_HOME", + "M2_HOME", + "GRADLE_HOME", + "GOROOT", + "DOTNET_ROOT", + "RUSTUP_TOOLCHAIN", + "GOPROXY", + "Path", + ] + .contains(&name) + { return Err("拒绝写入未授权的用户环境变量".to_string()); } #[cfg(windows)] @@ -18759,6 +21479,8 @@ mod tests { "execute_profile_history_restore_plan", "execute_doctor_repair_plan", "switch_runtime", + "execute_runtime_switch_plan", + "restore_runtime_switch_backup", "uninstall_runtime", "install_jdk", "install_node", @@ -19487,4 +22209,407 @@ mod tests { "Windows Service Host (specific service unresolved)" ); } + + fn runtime_fixture( + id: &str, + kind: &str, + ecosystem: &str, + root: &str, + management: &str, + ) -> RuntimeInfo { + RuntimeInfo { + id: id.to_string(), + kind: kind.to_string(), + display_name: kind.to_string(), + ecosystem: ecosystem.to_string(), + version: "1.0.0".to_string(), + executable: format!(r"{root}\bin\{kind}.exe"), + runtime_root: root.to_string(), + source: "test fixture".to_string(), + management: management.to_string(), + source_authority: if management == "managed" { + "devenv-managed-registry" + } else { + "test-discovery" + } + .to_string(), + provider: None, + switch_modes: vec![if management == "managed" { + "managed" + } else { + "external-user" + } + .to_string()], + switch_eligible: true, + switch_blockers: Vec::new(), + verification_fingerprint: "fixture-verification".to_string(), + current: false, + installed_at: None, + } + } + + #[test] + fn runtime_path_switch_replaces_only_same_ecosystem_entries() { + let old = runtime_fixture( + "old-python", + "python", + "python", + r"C:\Python312", + "external", + ); + let target = runtime_fixture( + "new-python", + "python", + "python", + r"D:\Python313", + "external", + ); + let existing = r"C:\Python312;C:\Python312\Scripts;C:\Program Files\Git\cmd;C:\Tools"; + let updated = replace_runtime_path_entries(existing, &target, &[old, target.clone()]); + assert!(updated.starts_with(r"D:\Python313;D:\Python313\Scripts")); + assert!(!updated.contains(r"C:\Python312")); + assert!(updated.contains(r"C:\Program Files\Git\cmd")); + assert!(updated.contains(r"C:\Tools")); + } + + #[test] + fn managed_runtime_discovery_replaces_same_path_external_probe() { + let mut runtimes = vec![runtime_fixture( + "external-jdk", + "jdk", + "java", + r"C:\DevEnvManager\jdks\jdk-21", + "external", + )]; + let mut managed = runtime_fixture( + "managed-jdk", + "jdk", + "java", + r"C:\DevEnvManager\jdks\jdk-21", + "managed", + ); + managed.executable = r"C:\DevEnvManager\jdks\jdk-21\bin\jdk.exe".to_string(); + runtimes[0].executable = managed.executable.clone(); + push_runtime(&mut runtimes, managed); + assert_eq!(runtimes.len(), 1); + assert_eq!(runtimes[0].management, "managed"); + assert_eq!(runtimes[0].id, "managed-jdk"); + } + + #[test] + fn runtime_environment_bindings_are_ecosystem_specific() { + let jdk = runtime_fixture("jdk", "jdk", "java", r"C:\JDK21", "external"); + assert_eq!( + runtime_environment_bindings(&jdk), + vec![("JAVA_HOME".to_string(), r"C:\JDK21".to_string())] + ); + let maven = runtime_fixture("maven", "maven", "maven", r"C:\Maven", "external"); + assert_eq!( + runtime_environment_bindings(&maven), + vec![ + ("MAVEN_HOME".to_string(), r"C:\Maven".to_string()), + ("M2_HOME".to_string(), r"C:\Maven".to_string()), + ] + ); + } + + #[test] + fn runtime_environment_fingerprint_detects_path_and_binding_changes() { + let mut environment = HashMap::from([ + ("JAVA_HOME".to_string(), r"C:\JDK17".to_string()), + ("Path".to_string(), r"C:\JDK17\bin;C:\Tools".to_string()), + ]); + let baseline = runtime_environment_fingerprint(&environment); + environment.insert("JAVA_HOME".to_string(), r"C:\JDK21".to_string()); + assert_ne!(baseline, runtime_environment_fingerprint(&environment)); + environment.insert("JAVA_HOME".to_string(), r"C:\JDK17".to_string()); + environment.insert("Path".to_string(), r"C:\Tools;C:\JDK17\bin".to_string()); + assert_ne!(baseline, runtime_environment_fingerprint(&environment)); + } + + #[test] + fn runtime_switch_plan_is_expiring_and_single_use() { + let plan_id = format!("runtime-switch-test-{}", unix_timestamp()); + let plan = RuntimeSwitchPlan { + plan_id: plan_id.clone(), + created_at: "100".to_string(), + expires_at: 200, + runtime_id: "runtime-test".to_string(), + switch_mode: "managed".to_string(), + source_authority: "devenv-managed-registry".to_string(), + provider: None, + kind: "jdk".to_string(), + version: "21".to_string(), + target_root: r"C:\JDK21".to_string(), + previous_version: None, + previous_root: None, + environment_changes: Vec::new(), + path_diff: Vec::new(), + backup_name: "env-backup-test.json".to_string(), + backup_id: "runtime-switch-backup-test".to_string(), + backup_path: r"C:\backup.json".to_string(), + state_fingerprint: "state-test".to_string(), + verification_steps: Vec::new(), + warnings: Vec::new(), + risk_level: "medium".to_string(), + plan_fingerprint: "plan-test".to_string(), + }; + assert!(!runtime_switch_plan_is_expired(&plan, 200)); + assert!(runtime_switch_plan_is_expired(&plan, 201)); + runtime_switch_plans().lock().unwrap().insert( + plan_id.clone(), + PendingRuntimeSwitchPlan { + public: plan, + installed_fingerprint: String::new(), + candidate: Some(runtime_fixture( + "runtime-test", + "jdk", + "java", + r"C:\JDK21", + "managed", + )), + project_root: None, + runtime_backup: None, + }, + ); + assert!(take_runtime_switch_plan(&plan_id).is_ok()); + assert!(take_runtime_switch_plan(&plan_id).is_err()); + } + + #[test] + fn runtime_switch_backup_is_persistent_and_tamper_evident() { + let temporary = tempfile::tempdir_in(env::current_dir().unwrap()).unwrap(); + let paths = AppPaths::new(temporary.path().join("devenv-root")); + paths.ensure().unwrap(); + let environment = HashMap::from([ + ("JAVA_HOME".to_string(), r"C:\JDK17".to_string()), + ("Path".to_string(), r"C:\JDK17\bin;C:\Tools".to_string()), + ]); + let environment_backup_name = create_environment_backup(&paths, &environment).unwrap(); + let candidate = runtime_fixture( + "managed-jdk-21", + "jdk", + "java", + &display_path(paths.jdks().join("jdk-21")), + "managed", + ); + let installed = InstalledData { + jdks: Vec::new(), + pythons: Vec::new(), + nodes: Vec::new(), + mavens: Vec::new(), + gradles: Vec::new(), + gos: Vec::new(), + current: CurrentVersions::default(), + }; + let record = create_runtime_switch_backup( + &paths, + RuntimeSwitchBackupInput { + candidate: &candidate, + switch_mode: "managed", + project_root: None, + environment_backup_name: environment_backup_name.clone(), + installed, + selections: RuntimeSelectionState::default(), + state_fingerprint: "state-fingerprint".to_string(), + }, + ) + .unwrap(); + assert!(runtime_switch_backup_path(&paths, &record.backup_id) + .unwrap() + .is_file()); + assert_eq!( + load_runtime_switch_backup(&paths, &record.backup_id) + .unwrap() + .record_fingerprint, + record.record_fingerprint + ); + let summaries = list_runtime_switch_backups_at(&paths).unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].backup_id, record.backup_id); + assert!(summaries[0].restorable); + assert!(summaries[0].validation_error.is_none()); + + fs::write( + paths + .config() + .join("env_backups") + .join(environment_backup_name), + b"{}", + ) + .unwrap(); + assert!(load_runtime_switch_backup(&paths, &record.backup_id).is_err()); + let summaries = list_runtime_switch_backups_at(&paths).unwrap(); + assert_eq!(summaries.len(), 1); + assert!(!summaries[0].restorable); + assert!(summaries[0].validation_error.is_some()); + } + + #[test] + fn runtime_target_artifact_fingerprint_rejects_same_path_replacement() { + let temporary = tempfile::tempdir_in(env::current_dir().unwrap()).unwrap(); + let root = temporary.path().join("python-3.12"); + fs::create_dir_all(root.join("Scripts")).unwrap(); + let executable = root.join("python.exe"); + fs::write(&executable, b"trusted-python").unwrap(); + fs::write(root.join("Scripts/pip.exe"), b"trusted-pip").unwrap(); + let mut candidate = runtime_fixture( + "external-python", + "python", + "python", + &display_path(&root), + "external", + ); + candidate.executable = display_path(&executable); + let baseline = runtime_target_artifact_fingerprint(&candidate); + fs::write(&executable, b"replaced-python").unwrap(); + assert_ne!(baseline, runtime_target_artifact_fingerprint(&candidate)); + } + + #[test] + fn runtime_directory_snapshot_detects_verification_side_effects() { + let temporary = tempfile::tempdir_in(env::current_dir().unwrap()).unwrap(); + fs::write(temporary.path().join("runtime.exe"), b"runtime").unwrap(); + let baseline = runtime_directory_state_fingerprint(temporary.path()).unwrap(); + fs::create_dir_all(temporary.path().join("__pycache__")).unwrap(); + fs::write( + temporary.path().join("__pycache__/runtime.pyc"), + b"unexpected", + ) + .unwrap(); + assert_ne!( + baseline, + runtime_directory_state_fingerprint(temporary.path()).unwrap() + ); + } + + #[test] + fn rustup_toolchain_is_derived_only_from_provider_owned_path() { + assert_eq!( + rustup_toolchain_name(Path::new( + r"C:\Users\Alice\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin" + )) + .as_deref(), + Some("stable-x86_64-pc-windows-msvc") + ); + assert!(rustup_toolchain_name(Path::new(r"C:\Rust\bin")).is_none()); + } + + #[test] + fn provider_managed_node_uses_only_an_available_provider_adapter() { + for (root, provider) in [ + (r"C:\Users\Alice\AppData\Roaming\nvm\v22.0.0", "nvm"), + (r"C:\Users\Alice\AppData\Local\fnm\node-versions\v22", "fnm"), + (r"C:\Users\Alice\.volta\tools\image\node\22.0.0", "volta"), + (r"C:\Users\Alice\scoop\apps\nodejs\current", "scoop"), + ] { + let runtime = runtime_fixture("node-provider", "node", "node", root, "external"); + assert_eq!(provider_managed_node_runtime(&runtime), Some(provider)); + + let temporary = tempfile::tempdir_in(env::current_dir().unwrap()).unwrap(); + let provider_root = temporary.path().join(provider).join("v22.0.0"); + fs::create_dir_all(&provider_root).unwrap(); + let mut runtime = runtime_fixture( + "node-provider", + "node", + "node", + &display_path(&provider_root), + "external", + ); + runtime.executable = display_path(provider_root.join("node.exe")); + runtime.source = format!("{provider} node inventory"); + populate_runtime_switch_capabilities(std::slice::from_mut(&mut runtime)); + assert_eq!(runtime.provider.as_deref(), Some(provider)); + assert_eq!( + runtime.switch_eligible, + provider_command_path(provider).is_some() + ); + assert_eq!( + runtime.switch_modes == vec!["provider".to_string()], + runtime.switch_eligible + ); + } + + let direct = runtime_fixture( + "node-direct", + "node", + "node", + r"C:\Program Files\nodejs", + "external", + ); + assert_eq!(provider_managed_node_runtime(&direct), None); + } + + #[test] + fn provider_switch_arguments_preserve_provider_semantics() { + assert_eq!( + provider_switch_arguments("nvm", "22.14.0", None).unwrap(), + vec!["use", "22.14.0"] + ); + assert_eq!( + provider_switch_arguments("fnm", "22.14.0", None).unwrap(), + vec!["default", "22.14.0"] + ); + assert_eq!( + provider_switch_arguments("volta", "22.14.0", None).unwrap(), + vec!["install", "node@22.14.0"] + ); + assert_eq!( + provider_switch_arguments("scoop", "22.14.0", Some("nodejs-lts")).unwrap(), + vec!["reset", "nodejs-lts@22.14.0"] + ); + assert!(provider_switch_arguments("unknown", "1.0.0", None).is_err()); + } + + #[test] + fn scoop_state_uses_the_actual_provider_app_path() { + assert_eq!( + scoop_app_id_from_path(Path::new( + r"C:\Users\Alice\scoop\apps\nodejs-lts\current\node.exe" + )) + .as_deref(), + Some("nodejs-lts") + ); + assert!(scoop_app_id_from_path(Path::new(r"C:\Program Files\nodejs\node.exe")).is_none()); + } + + #[test] + fn runtime_provider_versions_are_normalized_for_cli_arguments() { + assert_eq!( + runtime_version_argument("v22.14.0 (Currently using 64-bit executable)").as_deref(), + Some("22.14.0") + ); + assert_eq!( + runtime_version_argument("default -> 20.18.3").as_deref(), + Some("20.18.3") + ); + assert!(runtime_version_argument("version unavailable").is_none()); + } + + #[test] + fn dotnet_project_switch_preserves_unrelated_global_json_keys() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("global.json"); + fs::write( + &target, + r#"{"sdk":{"version":"8.0.100","rollForward":"latestPatch"},"msbuild-sdks":{"Example":"1.0.0"}}"#, + ) + .unwrap(); + write_dotnet_global_json(temp.path(), "9.0.100").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(target).unwrap()).unwrap(); + assert_eq!( + updated.pointer("/sdk/version").and_then(Value::as_str), + Some("9.0.100") + ); + assert_eq!( + updated.pointer("/sdk/rollForward").and_then(Value::as_str), + Some("latestPatch") + ); + assert_eq!( + updated + .pointer("/msbuild-sdks/Example") + .and_then(Value::as_str), + Some("1.0.0") + ); + } } diff --git a/tauri/src-tauri/src/powershell_runner.rs b/tauri/src-tauri/src/powershell_runner.rs index bba5e36..91d19f2 100644 --- a/tauri/src-tauri/src/powershell_runner.rs +++ b/tauri/src-tauri/src/powershell_runner.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::ffi::OsStr; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -239,6 +239,38 @@ pub fn run_probe_command_with_env( args: &[&str], timeout_seconds: u64, environment: &[(&str, &str)], +) -> Result { + run_probe_command_with_env_and_optional_cwd( + executable, + args, + timeout_seconds, + environment, + None, + ) +} + +pub fn run_probe_command_with_env_and_cwd( + executable: impl AsRef, + args: &[&str], + timeout_seconds: u64, + environment: &[(&str, &str)], + cwd: &Path, +) -> Result { + run_probe_command_with_env_and_optional_cwd( + executable, + args, + timeout_seconds, + environment, + Some(cwd), + ) +} + +fn run_probe_command_with_env_and_optional_cwd( + executable: impl AsRef, + args: &[&str], + timeout_seconds: u64, + environment: &[(&str, &str)], + cwd: Option<&Path>, ) -> Result { let timeout_seconds = timeout_seconds.clamp(1, 300); let executable_ref = executable.as_ref(); @@ -250,6 +282,9 @@ pub fn run_probe_command_with_env( .envs(environment.iter().copied()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } let start = Instant::now(); let mut child = command .spawn() diff --git a/tauri/src-tauri/src/runtime_verification.rs b/tauri/src-tauri/src/runtime_verification.rs index db8bf27..7814b9a 100644 --- a/tauri/src-tauri/src/runtime_verification.rs +++ b/tauri/src-tauri/src/runtime_verification.rs @@ -91,6 +91,7 @@ pub(crate) fn verify_installed_runtime( ]); } "python" => { + let python_env = [("PYTHONDONTWRITEBYTECODE", "1")]; checks.extend([ command_check( "python-version", @@ -100,7 +101,7 @@ pub(crate) fn verify_installed_runtime( expected, true, 30, - &[], + &python_env, "Reinstall Python if the executable reports another version.", ), command_check( @@ -111,7 +112,7 @@ pub(crate) fn verify_installed_runtime( &path_text(executable), true, 30, - &[], + &python_env, "The interpreter must report the executable inside this runtime root.", ), command_check( @@ -122,7 +123,7 @@ pub(crate) fn verify_installed_runtime( "", true, 60, - &[], + &python_env, "Run the managed pip repair or reinstall Python.", ), command_check( @@ -133,7 +134,7 @@ pub(crate) fn verify_installed_runtime( "", true, 60, - &[], + &python_env, "Reinstall Python with the venv component.", ), command_check( @@ -147,7 +148,7 @@ pub(crate) fn verify_installed_runtime( "ok", true, 30, - &[], + &python_env, "The managed Python standard library is incomplete; reinstall it.", ), file_check( @@ -165,7 +166,7 @@ pub(crate) fn verify_installed_runtime( "ok", false, 30, - &[], + &python_env, "Install optional Tk support only if GUI Python tools need it.", ), ]); @@ -183,6 +184,17 @@ pub(crate) fn verify_installed_runtime( &[], "Reinstall Node.js if the executable reports another major version.", ), + command_check( + "node-executable", + "process.execPath", + root.join("node.exe"), + &["-p", "process.execPath"], + &path_text(&root.join("node.exe")), + true, + 30, + &[], + "Node must report the executable inside this runtime root.", + ), command_check( "npm-version", "npm --version", diff --git a/tauri/src-tauri/tauri.conf.json b/tauri/src-tauri/tauri.conf.json index cd7ed51..7760cd5 100644 --- a/tauri/src-tauri/tauri.conf.json +++ b/tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DevEnv Manager", - "version": "1.9.0", + "version": "1.9.1", "identifier": "com.weidonglang.dailytools", "build": { "beforeDevCommand": "npm run dev", diff --git a/tauri/src/acceptance/fixtures.ts b/tauri/src/acceptance/fixtures.ts index 3605cba..5df7ee8 100644 --- a/tauri/src/acceptance/fixtures.ts +++ b/tauri/src/acceptance/fixtures.ts @@ -390,6 +390,11 @@ export const runtimeAcceptanceFixture: RuntimeWorkbenchState = { runtimeRoot: "C:\\DevEnv\\runtimes\\jdk-21", source: "DevEnv managed registry", management: "managed", + sourceAuthority: "devenv-managed-registry", + switchModes: ["managed"], + switchEligible: true, + switchBlockers: [], + verificationFingerprint: "managed-jdk-fixture", current: true, installedAt: "2026-07-20T08:00:00Z", }, @@ -403,8 +408,31 @@ export const runtimeAcceptanceFixture: RuntimeWorkbenchState = { runtimeRoot: "C:\\Users\\David\\AppData\\Local\\Programs\\Python\\Python313", source: "Python registry", management: "external", + sourceAuthority: "python-registry", + switchModes: ["external-user"], + switchEligible: true, + switchBlockers: [], + verificationFingerprint: "external-python-fixture", current: false, }, + { + id: "runtime-managed-jdk-17", + kind: "jdk", + displayName: "Java / JDK", + ecosystem: "java", + version: "17.0.12", + executable: "C:\\DevEnv\\runtimes\\jdk-17\\bin\\java.exe", + runtimeRoot: "C:\\DevEnv\\runtimes\\jdk-17", + source: "DevEnv managed registry", + management: "managed", + sourceAuthority: "devenv-managed-registry", + switchModes: ["managed"], + switchEligible: true, + switchBlockers: [], + verificationFingerprint: "managed-jdk-17-fixture", + current: false, + installedAt: "2026-07-20T08:00:00Z", + }, ], distributions: [{ id: "temurin", name: "Temurin", recommended: true }] as unknown as RuntimeWorkbenchState["distributions"], strongVerification: { @@ -432,11 +460,67 @@ export const runtimeAcceptanceFixture: RuntimeWorkbenchState = { required: true, suggestion: "No action required.", }], + }, { + runtimeId: "runtime-managed-jdk-17", + kind: "jdk", + version: "17.0.12", + path: "C:\\DevEnv\\runtimes\\jdk-17", + registered: true, + current: false, + environmentEffective: false, + status: "available", + report: [], + checks: [{ + id: "java-version", + label: "java -version", + command: "java.exe -version", + expected: "17", + actual: "openjdk version 17.0.12", + status: "passed", + elapsedMs: 38, + required: true, + suggestion: "No action required.", + }], + }, { + runtimeId: "runtime-external-python-313", + kind: "python", + version: "3.13.0", + path: "C:\\Users\\David\\AppData\\Local\\Programs\\Python\\Python313", + registered: false, + current: false, + environmentEffective: false, + status: "external verified", + report: [], + checks: [{ + id: "python-version", + label: "python --version", + command: "python.exe --version", + expected: "3.13", + actual: "Python 3.13.0", + status: "passed", + elapsedMs: 30, + required: true, + suggestion: "No action required.", + }], }], }, selectedRuntimeId: null, switchPlan: null, switchResult: null, + switchPhase: "idle", + switchTargetRuntimeId: null, + switchTargetMode: null, + switchTargetLabel: "", + switchTargetRoot: "", + runtimeProjectRoot: "", + switchInlineMessage: "", + switchInlineError: "", + switchFailureStage: "", + switchNextStep: "", + switchBackupId: "", + switchBackupPath: "", + switchBackups: [], + switchRequestId: 0, operationResult: "Runtime fixture operation result.", operationError: "", externalJdkPath: "C:\\Program Files\\Java\\jdk-21", diff --git a/tauri/src/acceptance/mockInvoke.ts b/tauri/src/acceptance/mockInvoke.ts index 438a1a8..2f206ea 100644 --- a/tauri/src/acceptance/mockInvoke.ts +++ b/tauri/src/acceptance/mockInvoke.ts @@ -95,6 +95,7 @@ export function createMockInvokeClient(overrides: MockInvokeMap = {}): InvokeCli }, set_port_scan_preferences: { success: true, message: "Fixture port scan preferences saved." }, discover_runtimes: acceptanceFixtures.runtimes.runtimes, + list_runtime_switch_backups: [], inspect_system_platforms: acceptanceFixtures.toolchains.system, inspect_toolchains: acceptanceFixtures.toolchains.report, inspect_platform_toolchains: acceptanceFixtures.toolchains.platform, diff --git a/tauri/src/acceptance/selectors.ts b/tauri/src/acceptance/selectors.ts index cb49b6a..92b0e06 100644 --- a/tauri/src/acceptance/selectors.ts +++ b/tauri/src/acceptance/selectors.ts @@ -76,6 +76,16 @@ export const acceptanceSelectors = { dotnetGroup: "runtime-group-dotnet", switchWorkflow: "runtime-switch-workflow", switchPlan: "runtime-switch-plan-preview", + switchFailureStage: "runtime-switch-failure-stage", + switchTargetRoot: "runtime-switch-target-root", + switchPlanCancel: "runtime-switch-plan-cancel", + switchPlanRecreate: "runtime-switch-plan-recreate", + switchPlanExport: "runtime-switch-plan-export", + switchPlanViewDiff: "runtime-switch-plan-view-diff", + switchBackupSection: "runtime-switch-backup-section", + switchBackupSelect: "runtime-switch-backup-select", + switchBackupDetail: "runtime-switch-backup-detail", + switchBackupRestore: "runtime-switch-backup-restore", switchResult: "runtime-switch-result", verificationResult: "runtime-verification-result", }, diff --git a/tauri/src/features/runtimes/api.ts b/tauri/src/features/runtimes/api.ts index 1d5bd9c..9524d6c 100644 --- a/tauri/src/features/runtimes/api.ts +++ b/tauri/src/features/runtimes/api.ts @@ -1,5 +1,5 @@ import { invoke } from "../../core/invoke"; -import type { JdkDistribution, OperationResult, RuntimeInfo, RuntimeStrongVerificationReport, RuntimeSwitchPlan, RuntimeSwitchResult, ValidationCheck } from "../../types"; +import type { JdkDistribution, OperationResult, RuntimeInfo, RuntimeStrongVerificationReport, RuntimeSwitchBackupSummary, RuntimeSwitchPlan, RuntimeSwitchResult, ValidationCheck } from "../../types"; export function discoverRuntimes(): Promise { return invoke("discover_runtimes"); @@ -21,14 +21,30 @@ export function installRuntime(command: "install_jdk" | "install_node" | "instal return invoke(command, args); } -export function createRuntimeSwitchPlan(kind: string, version: string, path: string | null): Promise { - return invoke("create_runtime_switch_plan", { kind, version, path }); +export function createRuntimeSwitchPlan(runtimeId: string, switchMode: RuntimeSwitchPlan["switchMode"], projectRoot: string | null = null): Promise { + return invoke("create_runtime_switch_plan", { runtimeId, switchMode, projectRoot }); +} + +export function cancelRuntimeSwitchPlan(planId: string): Promise { + return invoke("cancel_runtime_switch_plan", { planId }); +} + +export function exportRuntimeSwitchPlan(planId: string): Promise { + return invoke("export_runtime_switch_plan", { planId }); } export function executeRuntimeSwitchPlan(planId: string, confirmationToken: string): Promise { return invoke("execute_runtime_switch_plan", { planId, confirmationToken }); } +export function listRuntimeSwitchBackups(): Promise { + return invoke("list_runtime_switch_backups"); +} + +export function restoreRuntimeSwitchBackup(backupId: string, confirmationToken: string): Promise { + return invoke("restore_runtime_switch_backup", { backupId, confirmationToken }); +} + export function uninstallRuntime(kind: string, version: string, path: string | null, confirmationToken: string): Promise { return invoke("uninstall_runtime", { kind, version, path, confirmationToken }); } diff --git a/tauri/src/features/runtimes/events.ts b/tauri/src/features/runtimes/events.ts index 24c0a1c..6d645d6 100644 --- a/tauri/src/features/runtimes/events.ts +++ b/tauri/src/features/runtimes/events.ts @@ -1,9 +1,9 @@ import type { FeatureContext } from "../../app/featureContext"; import { open } from "../../api/tauri"; import { localize, t } from "../../core/i18n"; -import type { RuntimeSwitchResult } from "../../types"; +import type { OperationResult, RuntimeSwitchBackupSummary, RuntimeSwitchResult } from "../../types"; import { bindAction } from "../sharedView"; -import { createRuntimeSwitchPlan, discoverRuntimes, executeRuntimeSwitchPlan, exportRuntimeVerificationReport, getJdkDistributions, inspectRuntimeStrongVerification, installRuntime, openAppsFeatures, openRuntimeDirectory, uninstallRuntime, verifyExternalJdk } from "./api"; +import { cancelRuntimeSwitchPlan, createRuntimeSwitchPlan, discoverRuntimes, executeRuntimeSwitchPlan, exportRuntimeSwitchPlan, exportRuntimeVerificationReport, getJdkDistributions, inspectRuntimeStrongVerification, installRuntime, listRuntimeSwitchBackups, openAppsFeatures, openRuntimeDirectory, restoreRuntimeSwitchBackup, uninstallRuntime, verifyExternalJdk } from "./api"; import { renderRuntimeWorkbench } from "./render"; import type { RuntimeWorkbenchState } from "./state"; @@ -16,6 +16,13 @@ export function bindRuntimeEvents(context: FeatureContext, state: RuntimeWorkben state.externalJdkError = ""; renderAndBind(context, state); }); + context.root.querySelector("#runtime-switch-backup")?.addEventListener("change", (event) => { + const backupId = (event.currentTarget as HTMLSelectElement).value; + const backup = state.switchBackups.find((item) => item.backupId === backupId && item.restorable); + state.switchBackupId = backup?.backupId ?? ""; + state.switchBackupPath = backup?.backupPath ?? ""; + renderAndBind(context, state); + }); bindAction(context.root, "choose-external-jdk", async () => { const selected = await open({ directory: true, multiple: false }); if (!selected || Array.isArray(selected)) return; @@ -98,6 +105,145 @@ export function bindRuntimeEvents(context: FeatureContext, state: RuntimeWorkben } renderAndBind(context, state); }); + bindAction(context.root, "cancel-runtime-switch-plan", async () => { + const planId = state.switchPlan?.planId; + ++state.switchRequestId; + state.operationResult = ""; + state.operationError = ""; + try { + if (planId) await cancelRuntimeSwitchPlan(planId); + if (!context.isCurrent()) return; + state.switchPlan = null; + state.switchResult = null; + state.switchPhase = "idle"; + state.switchFailureStage = ""; + state.switchInlineError = ""; + state.switchInlineMessage = localize("The pending plan was cancelled. The selected target remains available for recreation.", "待执行计划已取消;所选目标仍保留,可重新创建计划。"); + state.switchNextStep = localize("Review the target and choose Recreate plan when ready.", "核对目标后,选择“重新创建计划”。"); + state.operationResult = state.switchInlineMessage; + } catch (error) { + if (!context.isCurrent()) return; + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = errorMessage(error); + state.switchNextStep = runtimeSwitchNextStep(state.switchInlineError); + state.operationError = state.switchInlineError; + } + renderAndBind(context, state); + focusSwitchWorkflow(context); + }); + bindAction(context.root, "recreate-runtime-switch-plan", async () => { + if (!state.switchTargetRuntimeId || !state.switchTargetMode) { + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = localize("Select an eligible runtime before recreating a plan.", "重新创建计划前,请先选择可采用的运行时。"); + state.switchNextStep = localize("Run the health check, then select Set as current on an eligible row.", "请先运行健康检查,再在符合条件的行上选择“设为当前”。"); + renderAndBind(context, state); + return; + } + const previousPlanId = state.switchPlan?.planId; + if (previousPlanId) { + try { + await cancelRuntimeSwitchPlan(previousPlanId); + } catch (error) { + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = errorMessage(error); + state.switchNextStep = runtimeSwitchNextStep(state.switchInlineError); + state.operationError = state.switchInlineError; + renderAndBind(context, state); + return; + } + } + await requestRuntimeSwitchPlan( + context, + state, + state.switchTargetRuntimeId, + state.switchTargetMode, + state.switchTargetLabel, + state.switchTargetRoot, + ); + }); + bindAction(context.root, "export-runtime-switch-plan", async () => { + const planId = state.switchPlan?.planId; + if (!planId) { + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = localize("Create a plan before exporting it.", "请先创建计划,再执行导出。"); + state.switchNextStep = localize("Select an eligible runtime or recreate the selected target plan.", "请选择可采用的运行时,或重新创建当前目标计划。"); + renderAndBind(context, state); + return; + } + try { + const path = await exportRuntimeSwitchPlan(planId); + state.operationError = ""; + state.operationResult = localize(`Runtime switch plan exported: ${path}`, `运行时切换计划已导出:${path}`); + state.switchInlineMessage = state.operationResult; + state.switchNextStep = localize("Review the exported copy or continue with the on-screen plan.", "可审阅导出副本,或继续处理页面内计划。"); + } catch (error) { + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = errorMessage(error); + state.switchNextStep = runtimeSwitchNextStep(state.switchInlineError); + state.operationError = state.switchInlineError; + } + renderAndBind(context, state); + focusSwitchWorkflow(context); + }); + bindAction(context.root, "view-runtime-switch-diff", () => focusSwitchPlanPreview(context)); + bindAction(context.root, "restore-runtime-switch-backup", async () => { + const backupId = state.switchBackupId; + if (!backupId) { + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = localize("Create a runtime switch plan before restoring its backup.", "请先创建运行时切换计划,再恢复对应备份。"); + state.switchNextStep = localize("Select an eligible runtime and review its plan.", "请选择可采用的运行时并审阅计划。"); + renderAndBind(context, state); + return; + } + try { + const result = await context.risk.run({ + command: "restore_runtime_switch_backup", + actionId: "restore_runtime_switch_backup", + planId: backupId, + riskLevel: "high", + backupRequired: true, + backupReceipt: backupId, + title: localize("Restore runtime switch backup", "恢复运行时切换备份"), + summary: localize("Restore the saved user environment, managed pointer, provider state and project selection where applicable.", "恢复已保存的用户环境、受管指针、提供方状态和适用的项目选择。"), + before: [ + { label: localize("Backup ID", "备份 ID"), value: backupId }, + { label: localize("Backup path", "备份路径"), value: state.switchBackupPath }, + ], + warnings: [ + localize("A new safety backup is created before restore.", "恢复前会再创建一份安全备份。"), + localize("Open terminals and IDEs keep their existing process environment until restarted.", "已打开的终端和 IDE 会保留原进程环境,直至重启。"), + ], + execute: (confirmationToken) => restoreRuntimeSwitchBackup(backupId, confirmationToken), + }) as OperationResult; + if (state.switchPlan) { + await cancelRuntimeSwitchPlan(state.switchPlan.planId).catch(() => undefined); + } + state.switchPlan = null; + state.switchResult = null; + state.switchPhase = "succeeded"; + state.switchFailureStage = ""; + state.switchInlineError = ""; + state.switchInlineMessage = result.message; + state.switchNextStep = localize("Restart terminals and IDEs, then verify the restored command versions.", "请重启终端和 IDE,然后验证恢复后的命令版本。"); + state.operationError = ""; + state.operationResult = result.message; + await reloadRuntimeData(state); + } catch (error) { + state.switchPhase = "failed"; + state.switchFailureStage = "execution"; + state.switchInlineError = errorMessage(error); + state.switchNextStep = runtimeSwitchNextStep(state.switchInlineError); + state.operationError = state.switchInlineError; + } + renderAndBind(context, state); + focusSwitchWorkflow(context); + }); bindAction(context.root, "execute-runtime-switch-plan", async () => { const plan = state.switchPlan; if (!plan) { @@ -105,19 +251,28 @@ export function bindRuntimeEvents(context: FeatureContext, state: RuntimeWorkben renderAndBind(context, state); return; } + const requestId = ++state.switchRequestId; + state.switchPhase = "executing"; + state.switchFailureStage = ""; + state.switchInlineMessage = localize("Executing the reviewed plan and verifying the result...", "正在执行已审阅计划并验证结果..."); + state.switchInlineError = ""; + state.switchNextStep = ""; state.operationResult = ""; state.operationError = ""; + renderAndBind(context, state); try { const result = await context.risk.run({ command: "execute_runtime_switch_plan", actionId: "execute_runtime_switch_plan", planId: plan.planId, planFingerprint: plan.planFingerprint, - riskLevel: "medium", + riskLevel: plan.riskLevel, backupReceipt: plan.backupName, title: t("feature.runtimes.switchTitle", { name: plan.kind, version: plan.version }), - summary: localize("Switch the reviewed managed runtime, update the current pointer and user environment, then run command-level verification.", "切换已审阅的受管运行时,更新 current 指针和用户环境,并执行命令级强校验。"), + summary: localize("Apply the reviewed runtime selection through its allowed mode, then run command-level verification and roll back on failure.", "按允许的模式应用已审阅的运行时选择,随后执行命令级验证,失败时自动回滚。"), before: [ + { label: localize("Switch mode", "切换模式"), value: plan.switchMode }, + { label: localize("Source authority", "来源权限"), value: plan.sourceAuthority }, { label: localize("Previous version", "原版本"), value: plan.previousVersion || t("state.notAvailable") }, { label: localize("Target root", "目标目录"), value: plan.targetRoot }, { label: localize("Environment backup", "环境备份"), value: plan.backupName }, @@ -125,14 +280,50 @@ export function bindRuntimeEvents(context: FeatureContext, state: RuntimeWorkben warnings: plan.warnings, execute: (confirmationToken) => executeRuntimeSwitchPlan(plan.planId, confirmationToken), }) as RuntimeSwitchResult; + if (!context.isCurrent() || requestId !== state.switchRequestId) return; state.switchResult = result; + state.switchBackupId = result.backupId; + state.switchBackupPath = result.backupPath; state.switchPlan = null; - state.operationResult = result.message; + state.switchPhase = "succeeded"; + state.switchFailureStage = ""; + state.switchInlineMessage = localize( + result.message, + `运行时已通过 ${result.selectionScope} 模式完成切换并验证;备份 ${result.backupName} 仍可恢复。`, + ); + state.switchNextStep = localize("Restart terminals and IDEs, then confirm the effective command version. The unified switch backup remains available here.", "请重启终端和 IDE 后确认实际命令版本;统一切换备份仍可在此恢复。"); + state.operationResult = state.switchInlineMessage; await reloadRuntimeData(state); } catch (error) { - state.operationError = errorMessage(error); + if (!context.isCurrent() || requestId !== state.switchRequestId) return; + state.switchPhase = "failed"; + state.switchFailureStage = "execution"; + state.switchInlineError = errorMessage(error); + state.switchNextStep = runtimeSwitchNextStep(state.switchInlineError); + state.operationError = state.switchInlineError; } renderAndBind(context, state); + focusSwitchWorkflow(context); + }); + bindAction(context.root, "choose-runtime-project", async () => { + const selected = await open({ directory: true, multiple: false }); + if (!selected || Array.isArray(selected)) return; + state.runtimeProjectRoot = selected; + if (!state.switchTargetRuntimeId || state.switchTargetMode !== "project") { + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = localize("Select a .NET SDK target before choosing its project.", "请先选择目标 .NET SDK,再选择项目目录。"); + renderAndBind(context, state); + return; + } + await requestRuntimeSwitchPlan( + context, + state, + state.switchTargetRuntimeId, + "project", + state.switchTargetLabel, + state.switchTargetRoot, + ); }); bindAction(context.root, "verify-runtimes", async () => { context.progress.start(t("feature.runtimes.healthCheck")); @@ -155,11 +346,12 @@ export function bindRuntimeEvents(context: FeatureContext, state: RuntimeWorkben } export async function refreshRuntimes(context: FeatureContext, state: RuntimeWorkbenchState): Promise { - const [runtimes, distributions, strongVerification] = await loadRuntimeData(); + const [runtimes, distributions, strongVerification, switchBackups] = await loadRuntimeData(); if (!context.isCurrent()) return; state.runtimes = runtimes; state.distributions = distributions; state.strongVerification = strongVerification; + applyRuntimeSwitchBackups(state, switchBackups); context.root.innerHTML = renderRuntimeWorkbench(state); bindRuntimeEvents(context, state); } @@ -209,6 +401,8 @@ async function installSelectedWithRisk(context: FeatureContext, state: RuntimeWo async function runRuntimeRowAction(context: FeatureContext, state: RuntimeWorkbenchState, button: HTMLButtonElement): Promise { const action = button.dataset.runtimeAction; + const runtimeId = button.dataset.runtimeId || ""; + const switchMode = button.dataset.runtimeSwitchMode || ""; const kind = button.dataset.runtimeKind || ""; const label = button.dataset.runtimeLabel || kind; const version = button.dataset.runtimeVersion || ""; @@ -289,17 +483,29 @@ async function runRuntimeRowAction(context: FeatureContext, state: RuntimeWorkbe } if (action === "switch") { - state.operationResult = ""; - state.operationError = ""; - state.switchResult = null; - try { - state.switchPlan = await createRuntimeSwitchPlan(kind, version, path); - state.operationResult = localize("Runtime switch plan created. Review the backup and environment diff before execution.", "运行时切换计划已创建,请检查备份和环境差异后再执行。"); - } catch (error) { + if (!runtimeId || !isRuntimeSwitchMode(switchMode)) { + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineError = localize("This runtime does not expose a trusted switch identity or mode.", "此运行时没有可信的切换身份或模式。"); + state.operationError = state.switchInlineError; + renderAndBind(context, state); + return; + } + state.switchTargetRuntimeId = runtimeId; + state.switchTargetMode = switchMode; + state.switchTargetLabel = `${label} ${version}`; + state.switchTargetRoot = path || executable; + if (switchMode === "project" && !state.runtimeProjectRoot) { + state.switchPhase = "idle"; + state.switchFailureStage = ""; state.switchPlan = null; - state.operationError = errorMessage(error); + state.switchInlineError = ""; + state.switchInlineMessage = localize("Choose the project directory to create a project-scoped .NET SDK plan.", "请选择项目目录以创建项目级 .NET SDK 计划。"); + renderAndBind(context, state); + focusSwitchWorkflow(context); + return; } - renderAndBind(context, state); + await requestRuntimeSwitchPlan(context, state, runtimeId, switchMode, `${label} ${version}`, path || executable); return; } @@ -330,14 +536,25 @@ async function loadRuntimeData() { discoverRuntimes(), getJdkDistributions(), inspectRuntimeStrongVerification(), + listRuntimeSwitchBackups(), ]); } async function reloadRuntimeData(state: RuntimeWorkbenchState): Promise { - const [runtimes, distributions, strongVerification] = await loadRuntimeData(); + const [runtimes, distributions, strongVerification, switchBackups] = await loadRuntimeData(); state.runtimes = runtimes; state.distributions = distributions; state.strongVerification = strongVerification; + applyRuntimeSwitchBackups(state, switchBackups); +} + +function applyRuntimeSwitchBackups(state: RuntimeWorkbenchState, switchBackups: RuntimeSwitchBackupSummary[]): void { + state.switchBackups = switchBackups; + const selectedBackup = switchBackups.find( + (backup) => backup.backupId === state.switchBackupId && backup.restorable, + ) ?? switchBackups.find((backup) => backup.restorable); + state.switchBackupId = selectedBackup?.backupId ?? ""; + state.switchBackupPath = selectedBackup?.backupPath ?? ""; } function runtimeName(command: "install_node" | "install_python" | "install_go"): string { @@ -357,6 +574,95 @@ function renderAndBind(context: FeatureContext, state: RuntimeWorkbenchState): v bindRuntimeEvents(context, state); } +async function requestRuntimeSwitchPlan( + context: FeatureContext, + state: RuntimeWorkbenchState, + runtimeId: string, + switchMode: "managed" | "external-user" | "provider" | "project", + targetLabel: string, + targetRoot: string, +): Promise { + const requestId = ++state.switchRequestId; + state.operationResult = ""; + state.operationError = ""; + state.switchResult = null; + state.switchPlan = null; + state.switchPhase = "planning"; + state.switchFailureStage = ""; + state.switchTargetRuntimeId = runtimeId; + state.switchTargetMode = switchMode; + state.switchTargetLabel = targetLabel; + state.switchTargetRoot = targetRoot; + state.switchInlineMessage = localize("Creating a trusted plan. You can continue reviewing this row while the backend verifies the target.", "正在创建可信计划;后端验证目标期间可继续查看此行。"); + state.switchInlineError = ""; + state.switchNextStep = ""; + renderAndBind(context, state); + try { + const plan = await createRuntimeSwitchPlan( + runtimeId, + switchMode, + switchMode === "project" ? state.runtimeProjectRoot : null, + ); + if (!context.isCurrent() || requestId !== state.switchRequestId) return; + state.switchPlan = plan; + state.switchBackupId = plan.backupId; + state.switchBackupPath = plan.backupPath; + state.switchBackups = await listRuntimeSwitchBackups(); + state.switchTargetRoot = plan.targetRoot; + state.switchPhase = "planReady"; + state.switchFailureStage = ""; + state.switchInlineMessage = localize("Runtime switch plan created. Review the backup, authority and environment diff before execution.", "运行时切换计划已创建,请在执行前审阅备份、来源权限和环境差异。"); + state.operationResult = state.switchInlineMessage; + } catch (error) { + if (!context.isCurrent() || requestId !== state.switchRequestId) return; + state.switchPlan = null; + state.switchPhase = "failed"; + state.switchFailureStage = "planning"; + state.switchInlineMessage = ""; + state.switchInlineError = errorMessage(error); + state.switchNextStep = runtimeSwitchNextStep(state.switchInlineError); + state.operationError = state.switchInlineError; + } + renderAndBind(context, state); + focusSwitchWorkflow(context); +} + +function focusSwitchWorkflow(context: FeatureContext): void { + requestAnimationFrame(() => { + if (!context.isCurrent()) return; + const target = context.root.querySelector('[data-testid="runtime-switch-workflow"]'); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + target?.focus({ preventScroll: true }); + }); +} + +function focusSwitchPlanPreview(context: FeatureContext): void { + requestAnimationFrame(() => { + if (!context.isCurrent()) return; + const target = context.root.querySelector('[data-testid="runtime-switch-plan-preview"]'); + target?.scrollIntoView({ behavior: "smooth", block: "center" }); + target?.focus({ preventScroll: true }); + }); +} + +function runtimeSwitchNextStep(error: string): string { + const value = error.toLowerCase(); + if (value.includes("expired") || value.includes("过期")) { + return localize("Choose Recreate plan, review the new fingerprint, then execute it.", "请选择“重新创建计划”,审阅新的指纹后再执行。"); + } + if (value.includes("state") || value.includes("changed") || value.includes("变化")) { + return localize("Refresh runtimes and recreate the plan because the trusted state changed.", "可信状态已经变化,请刷新运行时并重新创建计划。"); + } + if (value.includes("verif") || value.includes("校验") || value.includes("验证")) { + return localize("Run Reverify, resolve the failed required check, then recreate the plan.", "请重新验证并解决必需检查失败项,然后重新创建计划。"); + } + return localize("Keep this error visible, verify the target, and recreate the plan. Restore the unified switch backup here if execution changed state.", "请保留此错误,重新验证目标并创建计划;若执行已改变状态,可在此恢复统一切换备份。"); +} + +function isRuntimeSwitchMode(value: string): value is "managed" | "external-user" | "provider" | "project" { + return ["managed", "external-user", "provider", "project"].includes(value); +} + function resultMessage(result: unknown, fallback: string): string { if (result && typeof result === "object" && "message" in result) return String((result as { message?: unknown }).message || fallback); return fallback; diff --git a/tauri/src/features/runtimes/render.ts b/tauri/src/features/runtimes/render.ts index ad87e18..85586f2 100644 --- a/tauri/src/features/runtimes/render.ts +++ b/tauri/src/features/runtimes/render.ts @@ -59,12 +59,12 @@ function renderRuntimeGroup(group: RuntimeGroupViewModel, state: RuntimeWorkbenc

${localize("Managed versions", "受管版本")}

${localize("Only these versions can be switched or uninstalled by DevEnv Manager.", "只有这些版本可以由 DevEnv Manager 切换或卸载。")}

-
${group.managed.map(renderRuntimeRow).join("") || `
${localize("No managed version installed.", "尚未安装受管版本。")}
`}
+
${group.managed.map((runtime) => renderRuntimeRow(runtime, state)).join("") || `
${localize("No managed version installed.", "尚未安装受管版本。")}
`}

${localize("External discoveries", "外部发现版本")}

${localize("External installations stay read-only. DevEnv Manager never deletes their directories.", "外部安装保持只读,DevEnv Manager 不会删除其目录。")}

-
${group.external.map(renderRuntimeRow).join("") || `
${localize("No external runtime discovered.", "尚未发现外部运行时。")}
`}
+
${group.external.map((runtime) => renderRuntimeRow(runtime, state)).join("") || `
${localize("No external runtime discovered.", "尚未发现外部运行时。")}
`}
`; @@ -86,7 +86,7 @@ function renderInstallControl(groupId: string, state: RuntimeWorkbenchState): st if (groupId === "go") return renderVersionInstallControl("Go", "runtime-install-go-group", "go-version", "runtime-go-version-select", ["1.22", "1.23", "1.24", "1.25", "1.26"], "1.25", "install-go", t("feature.runtimes.installGo")); if (groupId === "maven") return renderVersionInstallControl("Maven", "runtime-install-maven-group", "maven-version", "runtime-maven-version-select", ["latest", "3.9.16", "3.9.15", "3.9.14", "3.9.12", "3.9.11"], "latest", "install-maven", t("feature.runtimes.installMaven")); if (groupId === "gradle") return renderVersionInstallControl("Gradle", "runtime-install-gradle-group", "gradle-version", "runtime-gradle-version-select", ["latest", "9.6.1", "9.5.0", "9.4.0", "8.14.3"], "latest", "install-gradle", t("feature.runtimes.installGradle")); - return `
${localize("Read-only discovery in v1.9.0. Use the vendor tool or Windows package manager to install and update this ecosystem.", "v1.9.0 中提供只读发现;请使用厂商工具或 Windows 包管理器安装和更新此生态。")}
`; + return `
${localize("Read-only discovery. Use the vendor tool or Windows package manager to install and update this ecosystem.", "此生态仅提供只读发现;请使用厂商工具或 Windows 包管理器安装和更新。")}
`; } function renderVersionInstallControl(title: string, testId: string, selectId: string, selectTestId: string, versions: string[], selected: string, action: string, label: string): string { @@ -142,35 +142,114 @@ function renderRuntimeOperationResult(state: RuntimeWorkbenchState): string { } function renderRuntimeSwitchWorkflow(state: RuntimeWorkbenchState): string { - return `
-

${localize("Runtime switch plan", "运行时切换计划")}

${localize("Select Switch on a managed version to create a backend-bound plan. Review the backup and environment diff here before execution.", "在受管版本上选择“切换”以创建后端绑定计划;执行前在此检查备份和环境差异。")}

-
- ${state.switchPlan ? `${renderObjectTable(state.switchPlan, ["kind", "version", "targetRoot", "previousVersion", "previousRoot", "environmentChanges", "pathDiff", "backupName", "warnings", "createdAt", "planId"])}
${renderActionButton("execute-runtime-switch-plan", localize("Execute reviewed switch plan", "执行已审阅的切换计划"), "danger")}
` : `
${localize("No runtime switch plan.", "尚未创建运行时切换计划。")}
`} + const status = runtimeSwitchStatus(state); + return `
+

${localize("Runtime switch plan", "运行时切换计划")}

${localize("Select Set as current on an eligible managed, external, provider-managed or project runtime. Review the backup and exact adapter changes before execution.", "在符合条件的受管、外部、提供方管理或项目运行时上选择“设为当前”;执行前审阅备份和适配器的准确变更。")}

+
+ ${escapeHtml(state.switchTargetLabel || localize("No target selected", "尚未选择目标"))} + ${escapeHtml(state.switchTargetRoot || localize("Target path not selected", "尚未选择目标路径"))} + ${escapeHtml(status)} +
+ ${state.switchPhase === "planReady" ? `
${escapeHtml(state.switchInlineMessage)}
` : ""} + ${state.switchInlineError ? `
${escapeHtml(state.switchFailureStage === "execution" ? localize("Execution / verification failed", "执行 / 验证失败") : localize("Plan creation failed", "计划创建失败"))}: ${escapeHtml(state.switchInlineError)}
` : ""} + ${state.switchNextStep ? `
${localize("Next step", "下一步")}: ${escapeHtml(state.switchNextStep)}
` : ""} + ${state.switchTargetMode === "project" ? `
+ + +
` : ""} + ${renderRuntimeSwitchBackupPicker(state)} +
+ ${state.switchPlan ? renderObjectTable(state.switchPlan, ["runtimeId", "switchMode", "sourceAuthority", "provider", "kind", "version", "targetRoot", "previousVersion", "previousRoot", "environmentChanges", "pathDiff", "backupName", "backupId", "backupPath", "verificationSteps", "warnings", "createdAt", "expiresAt", "planId"]) : `
${localize("No runtime switch plan.", "尚未创建运行时切换计划。")}
`} +
+ + + + + + +
- ${state.switchResult ? `${renderObjectTable(state.switchResult, ["success", "message", "planId", "backupName"])}${renderVerificationChecks(state.switchResult.verification.checks)}` : `
${localize("No switch has been executed.", "尚未执行切换。")}
`} + ${state.switchResult ? `${renderObjectTable(state.switchResult, ["success", "message", "planId", "backupName", "backupId", "backupPath", "selectionScope", "userEnvironmentWritten", "currentProcessUnchanged", "newChildProcessVerified", "restartRequired", "rollbackPerformed", "rollbackVerified"])}
${escapeHtml(localize("The saved user/provider/project state and a newly launched command were verified separately. The already-running DevEnv Manager process keeps its original environment until restart.", "已分别验证保存的用户/提供方/项目状态和新启动命令;当前正在运行的 DevEnv Manager 进程会保留原环境,直至重启。"))}
${renderVerificationChecks(state.switchResult.verification.checks)}` : `
${localize("No switch has been executed.", "尚未执行切换。")}
`}
`; } -function renderRuntimeRow(runtime: RuntimeRowViewModel): string { - return `
+function renderRuntimeSwitchBackupPicker(state: RuntimeWorkbenchState): string { + const invalidCount = state.switchBackups.filter((backup) => !backup.restorable).length; + const options = state.switchBackups + .filter((backup) => backup.restorable) + .map((backup) => { + const label = `${backup.targetKind} ${backup.targetVersion} - ${backup.switchMode} - ${backup.createdAt}`; + return ``; + }) + .join(""); + return `
+ +
${escapeHtml( + state.switchBackupPath + || localize("Verified switch backups will remain selectable after restarting the app.", "经过验证的切换备份在应用重启后仍可选择。"), + )}
+ ${invalidCount ? `
${escapeHtml(localize(`${invalidCount} backup record(s) failed integrity validation and cannot be restored.`, `${invalidCount} 个备份记录未通过完整性校验,无法恢复。`))}
` : ""} +
`; +} + +function renderRuntimeRow(runtime: RuntimeRowViewModel, state: RuntimeWorkbenchState): string { + const isSwitchTarget = state.switchTargetRuntimeId === runtime.id; + return `
${escapeHtml(runtime.kind)} ${escapeHtml(runtime.version)}${escapeHtml(runtime.currentLabel)}
${escapeHtml(runtime.runtimeRoot)}
${t("feature.runtimes.source")}: ${escapeHtml(runtime.source)} - ${escapeHtml(runtime.status)}
-
${runtime.managed ? renderManagedActions(runtime) : renderExternalActions(runtime)}
+ ${isSwitchTarget && (state.switchInlineMessage || state.switchInlineError) ? `
${escapeHtml(state.switchInlineError || state.switchInlineMessage)}
` : ""} +
${runtime.managed ? renderManagedActions(runtime, state) : renderExternalActions(runtime, state)}
`; } -function renderManagedActions(runtime: RuntimeRowViewModel): string { - return `${escapeHtml(runtime.readonlyLabel)}${runtimeAction("details", runtime, t("feature.runtimes.details"))}${runtimeAction("health", runtime, t("feature.runtimes.healthCheck"))}${runtimeAction("open", runtime, t("feature.runtimes.openDir"))}${runtimeAction("copy", runtime, t("feature.runtimes.copyPath"))}${runtimeAction("switch", runtime, t("feature.runtimes.switch"))}${runtimeAction("uninstall", runtime, t("feature.runtimes.uninstall"), "danger")}`; +function renderManagedActions(runtime: RuntimeRowViewModel, state: RuntimeWorkbenchState): string { + const busy = state.switchPhase === "planning" || state.switchPhase === "executing"; + const switchLabel = runtime.current ? localize("Current effective", "当前已生效") : localize("Set as current", "设为当前"); + return `${escapeHtml(runtime.readonlyLabel)}${runtimeAction("details", runtime, t("feature.runtimes.details"))}${runtimeAction("health", runtime, t("feature.runtimes.healthCheck"))}${runtimeAction("open", runtime, t("feature.runtimes.openDir"))}${runtimeAction("copy", runtime, t("feature.runtimes.copyPath"))}${runtimeAction("switch", runtime, switchLabel, "secondary", busy || runtime.current, runtime.current ? "runtime-current-effective" : "runtime-managed-set-current")}${runtimeAction("uninstall", runtime, t("feature.runtimes.uninstall"), "danger")}`; +} + +function renderExternalActions(runtime: RuntimeRowViewModel, state: RuntimeWorkbenchState): string { + const busy = state.switchPhase === "planning" || state.switchPhase === "executing"; + const adopt = runtime.current + ? runtimeAction("switch", runtime, localize("Current effective", "当前已生效"), "secondary", true, "runtime-current-effective") + : runtime.switchEligible && runtime.switchMode + ? runtimeAction("switch", runtime, externalSwitchLabel(runtime), "primary", busy, "runtime-external-set-current") + : `${escapeHtml(runtime.switchReason || localize("Read-only discovery; this runtime cannot be adopted safely.", "只读发现;无法安全采用此运行时。"))}`; + return `${escapeHtml(runtime.readonlyLabel)}${runtimeAction("details", runtime, t("feature.runtimes.details"))}${runtimeAction("health", runtime, localize("Reverify", "重新验证"))}${runtimeAction("open", runtime, t("feature.runtimes.openDir"))}${runtimeAction("copy", runtime, t("feature.runtimes.copyPath"))}${adopt}${runtimeAction("system", runtime, t("feature.runtimes.systemUninstall"))}`; +} + +function externalSwitchLabel(runtime: RuntimeRowViewModel): string { + if (runtime.switchMode === "provider") { + if (runtime.provider === "rustup") return localize("Set default through rustup", "通过 rustup 设为默认"); + if (runtime.provider === "nvm") return localize("Switch through nvm", "通过 nvm 切换"); + if (runtime.provider === "fnm") return localize("Switch through fnm", "通过 fnm 切换"); + if (runtime.provider === "volta") return localize("Switch through Volta", "通过 Volta 切换"); + if (runtime.provider === "scoop") return localize("Switch through Scoop", "通过 Scoop 切换"); + } + if (runtime.switchMode === "project") { + return localize("Select for project", "用于项目"); + } + return localize("Use in current user environment", "用于当前用户环境"); } -function renderExternalActions(runtime: RuntimeRowViewModel): string { - return `${escapeHtml(runtime.readonlyLabel)}${runtimeAction("details", runtime, t("feature.runtimes.details"))}${runtimeAction("open", runtime, t("feature.runtimes.openDir"))}${runtimeAction("copy", runtime, t("feature.runtimes.copyPath"))}${runtimeAction("system", runtime, t("feature.runtimes.systemUninstall"))}`; +function runtimeAction(action: "copy" | "switch" | "uninstall" | "open" | "health" | "details" | "system", runtime: RuntimeRowViewModel, label: string, tone = "secondary", disabled = false, testId = ""): string { + return ``; } -function runtimeAction(action: "copy" | "switch" | "uninstall" | "open" | "health" | "details" | "system", runtime: RuntimeRowViewModel, label: string, tone = "secondary"): string { - return ``; +function runtimeSwitchStatus(state: RuntimeWorkbenchState): string { + if (state.switchPhase === "planning") return localize("Creating a trusted switch plan...", "正在创建可信切换计划..."); + if (state.switchPhase === "planReady") return localize("Plan ready for review.", "计划已创建,等待审阅。"); + if (state.switchPhase === "executing") return localize("Executing and verifying the selected runtime...", "正在执行并验证所选运行时..."); + if (state.switchPhase === "succeeded") return localize("Switch completed and verified.", "切换已完成并通过验证。"); + if (state.switchPhase === "failed") return localize("The switch workflow failed. Review the persistent error below.", "切换流程失败,请查看下方持久错误。"); + return localize("Choose an eligible runtime to create a plan.", "请选择可采用的运行时来创建计划。"); } function renderRuntimeDetails(runtime: RuntimeRowViewModel | null): string { @@ -181,6 +260,9 @@ function renderRuntimeDetails(runtime: RuntimeRowViewModel | null): string {
${t("feature.runtimes.kind")}
${escapeHtml(runtime.kind)}
${t("feature.runtimes.version")}
${escapeHtml(runtime.version)}
${t("feature.runtimes.source")}
${escapeHtml(runtime.source)}
+
${localize("Source authority", "来源权限")}
${escapeHtml(runtime.sourceAuthority)}
+
${localize("Provider", "提供方")}
${escapeHtml(runtime.provider || localize("Direct environment selection", "直接环境选择"))}
+
${localize("Adoption eligibility", "采用资格")}
${escapeHtml(runtime.switchEligible ? localize("Eligible after verification", "验证后可采用") : runtime.switchReason || localize("Read-only", "只读"))}
${t("feature.runtimes.status")}
${escapeHtml(runtime.status)}
${t("feature.runtimes.current")}
${escapeHtml(runtime.currentLabel)}
${localize("Installed at", "安装时间")}
${escapeHtml(runtime.installedAt)}
diff --git a/tauri/src/features/runtimes/state.ts b/tauri/src/features/runtimes/state.ts index 73a2f03..1d3adeb 100644 --- a/tauri/src/features/runtimes/state.ts +++ b/tauri/src/features/runtimes/state.ts @@ -1,4 +1,12 @@ -import type { JdkDistribution, RuntimeInfo, RuntimeStrongVerificationReport, RuntimeSwitchPlan, RuntimeSwitchResult, ValidationCheck } from "../../types"; +import type { JdkDistribution, RuntimeInfo, RuntimeStrongVerificationReport, RuntimeSwitchBackupSummary, RuntimeSwitchPlan, RuntimeSwitchResult, ValidationCheck } from "../../types"; + +export type RuntimeSwitchPhase = + | "idle" + | "planning" + | "planReady" + | "executing" + | "succeeded" + | "failed"; export type RuntimeWorkbenchState = { runtimes: RuntimeInfo[]; @@ -7,6 +15,20 @@ export type RuntimeWorkbenchState = { selectedRuntimeId: string | null; switchPlan: RuntimeSwitchPlan | null; switchResult: RuntimeSwitchResult | null; + switchPhase: RuntimeSwitchPhase; + switchTargetRuntimeId: string | null; + switchTargetMode: "managed" | "external-user" | "provider" | "project" | null; + switchTargetLabel: string; + switchTargetRoot: string; + runtimeProjectRoot: string; + switchInlineMessage: string; + switchInlineError: string; + switchFailureStage: "" | "planning" | "execution"; + switchNextStep: string; + switchBackupId: string; + switchBackupPath: string; + switchBackups: RuntimeSwitchBackupSummary[]; + switchRequestId: number; operationResult: string; operationError: string; externalJdkPath: string; @@ -22,6 +44,20 @@ export const runtimeWorkbenchInitialState: RuntimeWorkbenchState = { selectedRuntimeId: null, switchPlan: null, switchResult: null, + switchPhase: "idle", + switchTargetRuntimeId: null, + switchTargetMode: null, + switchTargetLabel: "", + switchTargetRoot: "", + runtimeProjectRoot: "", + switchInlineMessage: "", + switchInlineError: "", + switchFailureStage: "", + switchNextStep: "", + switchBackupId: "", + switchBackupPath: "", + switchBackups: [], + switchRequestId: 0, operationResult: "", operationError: "", externalJdkPath: "", diff --git a/tauri/src/features/runtimes/viewModel.ts b/tauri/src/features/runtimes/viewModel.ts index c217d63..191e9cb 100644 --- a/tauri/src/features/runtimes/viewModel.ts +++ b/tauri/src/features/runtimes/viewModel.ts @@ -13,6 +13,11 @@ export type RuntimeRowViewModel = { runtimeRoot: string; source: string; managed: boolean; + sourceAuthority: string; + provider: string; + switchEligible: boolean; + switchMode: "managed" | "external-user" | "provider" | "project" | null; + switchReason: string; readonlyLabel: string; current: boolean; currentLabel: string; @@ -72,6 +77,16 @@ function toRuntimeRow(runtime: RuntimeInfo, report: RuntimeStrongVerificationRep const item = report?.items.find((candidate) => candidate.runtimeId === runtime.id); const managed = runtime.management === "managed"; const current = runtime.current || Boolean(item?.current); + const requiredChecksPassed = Boolean(item) + && item!.checks + .filter((check) => check.required) + .every((check) => check.status === "passed"); + const providerBlocker = runtime.kind === "node" && runtime.provider && runtime.switchBlockers.length + ? localize( + `${runtime.provider} cannot be used until its provider CLI and required Node.js checks pass.`, + `${runtime.provider} 提供方命令或 Node.js 必需检查尚未通过,暂时不能切换。`, + ) + : ""; return { id: runtime.id, kind: runtime.displayName, @@ -82,6 +97,19 @@ function toRuntimeRow(runtime: RuntimeInfo, report: RuntimeStrongVerificationRep runtimeRoot: runtime.runtimeRoot, source: runtime.source, managed, + sourceAuthority: runtime.sourceAuthority, + provider: runtime.provider || "", + switchEligible: managed + ? runtime.switchEligible + : runtime.switchEligible && requiredChecksPassed, + switchMode: runtime.switchModes[0] ?? null, + switchReason: localizeBackendText( + providerBlocker + || runtime.switchBlockers.join(" - ") + || (!managed && !requiredChecksPassed + ? localize("Run the strong health check and resolve every required failure before adoption.", "采用前请运行强健康检查并解决所有必需项失败。") + : ""), + ), readonlyLabel: managed ? localize("DevEnv managed", "DevEnv 受管") : localize("External install / read-only", "外部安装 / 只读"), current, currentLabel: current ? localize("Current", "当前生效") : managed ? localize("Installed, not current", "已安装,未切换") : localize("Discovered", "已发现"), diff --git a/tauri/src/styles.css b/tauri/src/styles.css index 1443d66..0721acb 100644 --- a/tauri/src/styles.css +++ b/tauri/src/styles.css @@ -1263,6 +1263,11 @@ th { box-shadow: inset 3px 0 0 var(--color-accent, #23745a); } +.runtime--switch-target { + outline: 2px solid var(--color-warning, #b66a11); + outline-offset: 2px; +} + .runtime div { display: flex; align-items: center; @@ -1449,6 +1454,23 @@ input[type="checkbox"] { overflow-wrap: anywhere; } +.runtime-switch-status { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; +} + +.runtime-switch-status code { + max-width: 100%; + color: var(--color-text, #1d2a32); + overflow-wrap: anywhere; +} + +.runtime-switch-toolbar { + flex-wrap: wrap; +} + .managed-runtime { background: #fbfcfd; } diff --git a/tauri/src/types/index.ts b/tauri/src/types/index.ts index dcc7fc7..38ccadb 100644 --- a/tauri/src/types/index.ts +++ b/tauri/src/types/index.ts @@ -81,7 +81,7 @@ export type EnvRepairPlan = { actions: EnvRepairAction[]; expectedAfter: { javaHome?: string; devenvHome?: string; path?: string }; warnings: string[]; - riskLevel: string; + riskLevel: "medium"; requiresTerminalRestart: boolean; backupName: string; disclaimer: string; @@ -200,6 +200,11 @@ export type RuntimeVerificationCheck = { export type RuntimeSwitchPlan = { planId: string; createdAt: string; + expiresAt: number; + runtimeId: string; + switchMode: "managed" | "external-user" | "provider" | "project"; + sourceAuthority: string; + provider?: string; kind: string; version: string; targetRoot: string; @@ -208,16 +213,41 @@ export type RuntimeSwitchPlan = { environmentChanges: string[]; pathDiff: string[]; backupName: string; + backupId: string; + backupPath: string; + stateFingerprint: string; + verificationSteps: string[]; warnings: string[]; riskLevel: string; planFingerprint: string; }; +export type RuntimeSwitchBackupSummary = { + backupId: string; + createdAt: string; + targetKind: string; + targetVersion: string; + targetRoot: string; + switchMode: string; + backupPath: string; + restorable: boolean; + validationError: string | null; +}; + export type RuntimeSwitchResult = { success: boolean; message: string; planId: string; backupName: string; + backupId: string; + backupPath: string; + userEnvironmentWritten: boolean; + currentProcessUnchanged: boolean; + newChildProcessVerified: boolean; + restartRequired: boolean; + selectionScope: string; + rollbackPerformed: boolean; + rollbackVerified: boolean; verification: RuntimeStrongVerificationReport["items"][number]; }; @@ -269,6 +299,12 @@ export type RuntimeInfo = { runtimeRoot: string; source: string; management: "managed" | "external"; + sourceAuthority: string; + provider?: string; + switchModes: Array<"managed" | "external-user" | "provider" | "project">; + switchEligible: boolean; + switchBlockers: string[]; + verificationFingerprint: string; current: boolean; installedAt?: string; }; diff --git a/update-manifest.cn.json b/update-manifest.cn.json index a30e69a..5fa9809 100644 --- a/update-manifest.cn.json +++ b/update-manifest.cn.json @@ -1,28 +1,28 @@ { "schemaVersion": 2, "channel": "stable", - "version": "1.9.0", - "date": "2026-07-27", + "version": "1.9.1", + "date": "2026-07-30", "notes": [ - "v1.9.0 以当前生效、DevEnv Manager 受管和外部安装三类边界重构 Runtime 发现与管理,加入安装后强验证且不自动切换、精确令牌保护的 Runtime 切换、持久 Profile 历史与安全恢复、完整双语操作结果和 Windows Store Alias 过滤,并保留 v1.8.3 的稳定性与安全修复。" + "v1.9.1 修复受管 Runtime 切换反馈,允许在不修改外部目录的前提下安全采用强验证通过的 JDK、Python、Node.js、Go、Maven 和 Gradle,并补齐 provider 与项目级适配、精确计划、备份、验证、回滚和重启恢复。" ], "assets": [ { "platform": "windows-x64", - "fileName": "DevEnv.Manager_1.9.0_x64-setup.exe", - "size": 2887902, - "sha256": "0e44e6f9c591858c8981139c581ee70f6e79344b0850525d3f83f2bd257664c1", - "primaryUrl": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe", + "fileName": "DevEnv.Manager_1.9.1_x64-setup.exe", + "size": 2951926, + "sha256": "ce4b9020d47258dd1668f0237a0b3fd0b061ddfc20c938fed56f5ddcf5cd0352", + "primaryUrl": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe", "mirrors": [ { "name": "Gitee Release", "region": "cn", - "url": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe" + "url": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe" }, { "name": "GitHub Release", "region": "global", - "url": "https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe" + "url": "https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe" } ] } diff --git a/update-manifest.json b/update-manifest.json index 34f5d96..f4836bd 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,28 +1,28 @@ { "schemaVersion": 2, "channel": "stable", - "version": "1.9.0", - "date": "2026-07-27", + "version": "1.9.1", + "date": "2026-07-30", "notes": [ - "v1.9.0 redesigns Runtime discovery and management around explicit current, managed, and external boundaries; adds verified no-auto-switch installs, exact token-gated Runtime switching, persistent Profile history and protected restore, bilingual operation results, and Windows Store Alias filtering while preserving the v1.8.3 stability and safety fixes." + "v1.9.1 fixes managed Runtime switching feedback, safely adopts strongly verified external JDK, Python, Node.js, Go, Maven, and Gradle installations without modifying their directories, adds provider-aware and project-scoped adapters, and persists exact plans, backups, verification, rollback, and restart recovery." ], "assets": [ { "platform": "windows-x64", - "fileName": "DevEnv.Manager_1.9.0_x64-setup.exe", - "size": 2887902, - "sha256": "0e44e6f9c591858c8981139c581ee70f6e79344b0850525d3f83f2bd257664c1", - "primaryUrl": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe", + "fileName": "DevEnv.Manager_1.9.1_x64-setup.exe", + "size": 2951926, + "sha256": "ce4b9020d47258dd1668f0237a0b3fd0b061ddfc20c938fed56f5ddcf5cd0352", + "primaryUrl": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe", "mirrors": [ { "name": "Gitee Release", "region": "cn", - "url": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe" + "url": "https://gitee.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe" }, { "name": "GitHub Release", "region": "global", - "url": "https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.9.0/DevEnv.Manager_1.9.0_x64-setup.exe" + "url": "https://github.com/weidonglang/DevEnv-Manager/releases/download/v1.9.1/DevEnv.Manager_1.9.1_x64-setup.exe" } ] }