From 75c2dfdd2381fc9e6766bc0fce7ddeb56dfb670b Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 16:01:18 +0800 Subject: [PATCH 01/13] =?UTF-8?q?docs(plugins):=20=E6=8F=90=E5=87=BA=20Win?= =?UTF-8?q?dows=20=E5=A3=B0=E6=98=8E=E5=BC=8F=E6=8F=92=E4=BB=B6=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E6=8F=90=E6=A1=88=20Note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-10-22-windows-declarative-plugin-host.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md diff --git a/.agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md new file mode 100644 index 0000000000..4a8eeea11e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -0,0 +1,76 @@ +# Agent 笔记:Windows 声明式插件宿主 + +状态:提议中 + +## 先说结论 + +Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`;真正要建的是宿主侧的包存储、信任审批、启动接线和管理界面。v0 能力声明只开放 `languages:1`、`languageServers:1`、`serverProcess:1`,声明了更高能力的插件包由 Core 自动拒绝。 + +## 问题 + +- 功能矩阵 `declarative-plugin-contract`、`plugin-functions` 对 Windows 记为 missing(2026-10-22 矩阵对齐时确认);[声明式语言插件](../../implemented/architecture/2026-10-12-declarative-language-plugins.md) 也写明"Windows 现有扩展清单暂未改造,接入时必须通过同一组 fixture"。 +- Windows 当前插件生态是渲染进程内的 Worker 扩展:`Plugins/win/Official/PhpSupport` 是 Worker 包格式(`main: "plugin.js"`),与契约包不同,无法复用 Lithe Store 面向 macOS/契约发布的语言插件包。 +- Windows 没有插件信任链与仓库源管理;`install_extension_from_url` 前端已调用但 Rust 宿主从未注册,非语言扩展的落盘安装链路实际不可用。 +- 契约的确定性逻辑(清单校验、启动计划展开、安装审查)按仓库规则只在 `rust/lithe-core/src/plugins/` 实现一次,而 `windows/tauri/src-tauri` 已依赖 `lithe-core`——差的只是宿主侧接线,不是契约本身。 + +## 提案 + +### 双模型边界 + +判定句:扩展需要"宿主代表用户拉起进程"(语言服务器、构建系统、运行/测试提供方)就走契约宿主;扩展需要"在宿主内运行自己的代码来表达 UI 或服务行为"(颜色主题、图标主题、AI provider、数据库 sidecar、ACP agent)就留在 Worker 模型。 + +- 正确做法:新增 Go 语言支持时写契约 `plugin.json` 声明 `languageServers` 和 `executionProviders`,由 Core 展开启动与运行计划。 +- 不要这样做:把主题扩展改写成契约包;为 Worker 扩展桥接一套函数通道冒充 litheFunctions;在宿主里按插件 ID 写 `if` 分支。 + +### 能力声明渐进开放 + +v0 宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1], serverProcess:[1] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 + +### 存储、信任与安装(v0) + +- 安装根为 `app_local_data_dir()/plugins///`,每包一份 `installation.json` 记录来源、版本与内容摘要;文件损坏时拒写、不覆盖既有记录。 +- bundled 包从 `resource_dir()` 只读加载;运行时不写安装目录与资源目录,遵守发行基线只读边界。 +- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入与市场下载一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 +- 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 + +### 与 Worker 扩展模型共存 + +- 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 +- PhpSupport 迁移:M3 用契约包替换 Worker 包,保留用户启用状态后移除 Worker 版本。 +- M2 一并处理 `install_extension_from_url` 死链:补注册命令或删除前端死调用,二选一在 PR 描述里说明。 +- 管理界面按模型分组展示两类扩展并使用不同文案,避免用户把 Worker 扩展当成契约插件。 + +### 实施切分 + +M1 包存储 + 启动校验 + 语言注册 + LSP 启动;M2 磁盘/市场安装链路 + 管理界面 v1;M3 声明式设置 + 运行/测试计划 + PHP 契约包替换;M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 + +## 考虑过的备选方案 + +- 把 Worker 扩展模型整体改造成契约宿主:契约没有主题、AI provider 这些概念,改造成本高且无收益,不采用。 +- 在 Tauri 侧另写一套清单校验:违反"校验只在 Core 实现一次",共享 fixtures 无法复用,双端语义必然漂移,不采用。 +- 把 Worker RPC 桥接成 litheFunctions:两者协议语义不同,会同时存在两套函数协议,等有真实插件需求时再按[插件函数契约](../../implemented/architecture/2026-10-20-plugin-function-contract.md)接入。 +- 信任模型一步到位 publisher 签名:Windows 验签载体需要单独评估,v0 以 bundled 加全量审批兜底,签名延后是显式记录的缺口而不是遗漏。 + +## 验收标准 + +- M1:`shared/contracts/plugins/v1/fixtures/` 全量经 `plugins.declarative.validate` 验收,`invalid-manifests.json` 的每类错误码逐一断言;合法样例包被启动扫描注册并在语言清单中出现;`languageServerLaunchPlan` 的结果能拉起真实进程。`cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml`、`scripts/build-windows.ps1`、`./scripts/verify-windows-boundaries.sh` 通过;`declarative-plugin-contract.json` Windows 状态随代码更新为 partial。 +- M2:磁盘导入完成快照、全量审批、staging 重验、回滚与损坏 `installation.json` 拒写;管理界面按模型分组;`install_extension_from_url` 死链有明确处置。 +- M3:PHP 契约包完成安装、启用、打开 `.php` 文件、Intelephense 启动的全链路;声明设置值代入启动模板;Worker 版 PhpSupport 移除且启用状态迁移。 +- M4:`ui` 2 样例页面在 WebView2 中显示,CSP 拒绝联网与 eval,禁用、更新、卸载和窗口关闭同步销毁页面。 +- 每个里程碑:`./scripts/verify-platform-feature-matrix-change.sh HEAD` 通过;实机验收完成前对应矩阵条目的 `verificationStatus` 保持 pending。 + +## 风险 + +- `lsp.rs` 现以 JDTLS 专用路径为主,启动计划的挂接形状需要开工时先通读探底,可能影响 M1 工作量估计。 +- Windows 的 reparse point/junction 语义与 macOS symlink 不完全相同,包内路径逃逸判断需要单独测试,不能照搬 macOS 用例。 +- 签名延后意味着 v0 社区包的信任完全依赖审批界面,文案必须明示未认证状态,否则用户可能把审批当成签名背书。 +- 双模型并存对用户可见,管理界面若不清晰分组会造成"为什么有两类扩展"的困惑。 +- WebView2 对自定义 scheme 与 CSP 的实现细节与 WKWebView 有差异,M4 需要实机验证后再声明 `ui:2`。 + +## 适用范围 + +- `windows/tauri/src-tauri/src/`(M1 在此新增 `plugin_host/` 模块:存储、扫描、启动接线) +- `windows/tauri/src-tauri/src/lsp.rs`、`language_tools.rs`、`main.rs`(接线与命令注册) +- `windows/tauri/src/extensions/languages/`(契约插件语言来源) +- `windows/tauri/src/features/settings/`(契约插件管理区块) +- `shared/platform-feature-matrix/features/declarative-plugin-contract.json`、`plugin-marketplace.json`、`plugin-extension-management.json`、`declarative-plugin-settings.json`、`php-optional-plugin.json`、`plugin-web-tool-windows.json` From fea041ff27d9641bc65c9c7b0f3e58a118e86bc3 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 17:02:26 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(windows):=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E6=8F=92=E4=BB=B6=E5=AE=BF=E4=B8=BB=20v0=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=8C=85=E5=AD=98=E5=82=A8=E3=80=81=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=A0=A1=E9=AA=8C=E4=B8=8E=E8=AF=AD=E8=A8=80=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E5=90=AF=E5=8A=A8=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src-tauri plugin_host 模块:包存储布局(app_local_data_dir/plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities,拒绝包隔离并展示原因)、 启动计划解析(packaged 入口拒绝 reparse point 与路径逃逸,runtime/ systemCommand 走 PATH 与 LITHE__PATH 约定) - 共享 fixtures 验收:invalid-manifests.json 全部 154 个用例在 Windows 二进制 内断言同一稳定错误码;合法样例按契约模式与 v0 能力门控模式分别断言 - 前端:resolve-editor-lsp-launch 在 Worker 扩展无服务器时回落契约插件启动; bootstrap 增加 declarative plugins 扫描步骤 - Note 转正为 implemented 并记录 M1 落地形状与 M2-M4 切分;矩阵 declarative-plugin-contract Windows 状态随代码更新为 partial(pending) --- ...6-10-22-windows-declarative-plugin-host.md | 86 ++++ ...6-10-22-windows-declarative-plugin-host.md | 76 ---- .../features/declarative-plugin-contract.json | 13 +- windows/tauri/src-tauri/src/language_tools.rs | 2 +- windows/tauri/src-tauri/src/main.rs | 5 + .../tauri/src-tauri/src/plugin_host/launch.rs | 424 ++++++++++++++++++ .../tauri/src-tauri/src/plugin_host/mod.rs | 133 ++++++ .../tauri/src-tauri/src/plugin_host/scan.rs | 230 ++++++++++ .../tauri/src-tauri/src/plugin_host/store.rs | 258 +++++++++++ .../tauri/src-tauri/src/plugin_host/tests.rs | 324 +++++++++++++ .../src/features/bootstrap/bootstrap-async.ts | 8 + .../lsp/resolve-editor-lsp-launch.test.ts | 52 +++ .../editor/lsp/resolve-editor-lsp-launch.ts | 28 +- .../plugins/declarative-plugin-launch.ts | 69 +++ .../services/workspace-git-bootstrap.test.ts | 7 +- 15 files changed, 1633 insertions(+), 82 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md delete mode 100644 .agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md create mode 100644 windows/tauri/src-tauri/src/plugin_host/launch.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/mod.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/scan.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/store.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/tests.rs create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-launch.ts diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md new file mode 100644 index 0000000000..3570061b5d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -0,0 +1,86 @@ +# Agent 笔记:Windows 声明式插件宿主 + +状态:已实现 + +## 先说结论 + +Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;能力声明只开放 `languages:1`、`languageServers:1`、`serverProcess:1`,声明了更高能力的插件包由 Core 自动拒绝。 + +## 问题 + +- 功能矩阵 `declarative-plugin-contract`、`plugin-functions` 对 Windows 记为 missing(2026-10-22 矩阵对齐时确认);[声明式语言插件](2026-10-12-declarative-language-plugins.md) 也写明"Windows 现有扩展清单暂未改造,接入时必须通过同一组 fixture"。 +- Windows 当前插件生态是渲染进程内的 Worker 扩展:`Plugins/win/Official/PhpSupport` 是 Worker 包格式(`main: "plugin.js"`),与契约包不同,无法复用 Lithe Store 面向 macOS/契约发布的语言插件包。 +- Windows 没有插件信任链与仓库源管理;`install_extension_from_url` 前端已调用但 Rust 宿主从未注册,非语言扩展的落盘安装链路实际不可用。 +- 契约的确定性逻辑(清单校验、启动计划展开、安装审查)按仓库规则只在 `rust/lithe-core/src/plugins/` 实现一次,而 `windows/tauri/src-tauri` 已依赖 `lithe-core`——差的只是宿主侧接线,不是契约本身。 + +## 决策 + +### 双模型边界 + +判定句:扩展需要"宿主代表用户拉起进程"(语言服务器、构建系统、运行/测试提供方)就走契约宿主;扩展需要"在宿主内运行自己的代码来表达 UI 或服务行为"(颜色主题、图标主题、AI provider、数据库 sidecar、ACP agent)就留在 Worker 模型。 + +- 正确做法:新增 Go 语言支持时写契约 `plugin.json` 声明 `languageServers` 和 `executionProviders`,由 Core 展开启动与运行计划。 +- 不要这样做:把主题扩展改写成契约包;为 Worker 扩展桥接一套函数通道冒充 litheFunctions;在宿主里按插件 ID 写 `if` 分支。 + +### 能力声明渐进开放 + +宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1], serverProcess:[1] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 + +### 存储、信任与安装(v0) + +- 安装根为 `app_local_data_dir()/plugins///`,`installation.json` 记录版本与启用状态;记录缺失或损坏的包隔离展示,绝不静默加载。 +- bundled 包从 `resource_dir()/OfficialPlugins` 只读加载,多版本目录取最高版本;运行时不写安装目录与资源目录,遵守发行基线只读边界。 +- 每个插件的语言服务器状态目录在 `app_local_data_dir()/plugin-state//`,由宿主注入启动计划请求。 +- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入与市场下载(M2)一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 +- 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 + +### 启动接线(M1 落地形状) + +- `plugin_host` 模块(`store.rs` 存储布局、`scan.rs` 扫描与校验、`launch.rs` 启动计划解析)是宿主侧唯一入口;校验与计划展开全部经 `lithe_core::execute_json` 调 Core 命令,宿主不复制任何清单语义。 +- `packaged` 启动入口解析后必须是包内普通文件:拒绝 reparse point(符号链接与 junction)与规范化后的路径逃逸,这是宿主执行每个启动前的统一闸门。 +- `runtime` 与 `systemCommand` 启动经 PATH 探测(复用 `language_tools` 的系统工具查找),并按 macOS 同一约定导出 `LITHE__PATH`、把 packaged runtime 目录前置到 `PATH`;`minimumVersion` 探测随 M2 安装链一起接入。 +- 语言服务器进程仍由共享 Core 的 `lsp.startServer` 拉起:前端 `resolve-editor-lsp-launch.ts` 在 Worker 扩展清单没有对应服务器时,回落到 `declarative_plugins_language_launch`(契约插件排在 Worker 之后,已由 Worker 声明的语言保持现状,直到 M3 包迁移)。 +- 启动扫描在应用 bootstrap 的 "declarative plugins" 步骤执行一次;扫描结果缓存在 Tauri state,前端经 `declarative_plugins_state` 只读消费。 + +### 与 Worker 扩展模型共存 + +- 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 +- PhpSupport 迁移:M3 用契约包替换 Worker 包,保留用户启用状态后移除 Worker 版本。 +- M2 一并处理 `install_extension_from_url` 死链:补注册命令或删除前端死调用,二选一在 PR 描述里说明。 +- 管理界面按模型分组展示两类扩展并使用不同文案,避免用户把 Worker 扩展当成契约插件。 + +### 实施切分 + +M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘/市场安装链路 + 管理界面 v1;M3 声明式设置 + 运行/测试计划 + PHP 契约包替换;M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 + +## 考虑过的备选方案 + +- 把 Worker 扩展模型整体改造成契约宿主:契约没有主题、AI provider 这些概念,改造成本高且无收益,不采用。 +- 在 Tauri 侧另写一套清单校验:违反"校验只在 Core 实现一次",共享 fixtures 无法复用,双端语义必然漂移,不采用。 +- 把 Worker RPC 桥接成 litheFunctions:两者协议语义不同,会同时存在两套函数协议,等有真实插件需求时再按[插件函数契约](2026-10-20-plugin-function-contract.md)接入。 +- 信任模型一步到位 publisher 签名:Windows 验签载体需要单独评估,v0 以 bundled 加全量审批兜底,签名延后是显式记录的缺口而不是遗漏。 +- 在 `lsp_start` 一类原生命令里拉起插件服务器:现有 LSP 会话全部由 Core 统一管理(会话、取消、事件泵都在 Core),前端只解析可执行路径与参数;另建原生拉起路径会分裂会话所有权,不采用。 + +## 后果 + +- 收益:Windows 用户开始能安装与运行契约 v1 语言插件,语言插件包与 macOS/Lithe Store 同格式;契约验收(同组 fixtures)成为 Windows 持续回归的一部分;后续里程碑只增不改这条链路。 +- 代价:双模型并存是用户可见的复杂度,M2 管理界面必须分组;`minimumVersion` 探测与内容摘要复查尚未接入(分别随 M2 安装链接入),此前被篡改的包只能靠清单校验与路径闸门拦截。 +- 已知风险:签名延后意味着 v0 社区包信任完全依赖审批界面(M2),文案必须明示未认证状态;Windows 的 reparse point/junction 语义与 macOS symlink 不完全相同,包内路径逃逸判断已按 Windows 语义单独测试;WebView2 对自定义 scheme 与 CSP 的实现细节与 WKWebView 有差异,M4 需要实机验证后再声明 `ui:2`。 +- M2–M4 里程碑:磁盘/市场安装链路 + 管理界面;声明式设置 + 运行/测试计划 + PHP 契约包替换;Web 工具窗口。每个里程碑独立 PR,能力声明与矩阵条目随代码同 PR 更新。 + +## 验证 + +- 共享 fixtures 全量验收在 Windows 二进制内完成:`invalid-manifests.json` 全部 154 个用例经宿主校验路径断言同一稳定错误码(不带 hostCapabilities 的契约等价模式),合法样例在不带与带 v0 能力声明两种模式下分别断言通过与 `plugin.api.unsupported` 门控。测试入口:`windows/tauri/src-tauri/src/plugin_host/tests.rs`,运行 `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml plugin_host`。 +- 存储与启动的单测覆盖安装记录缺失/损坏隔离、bundled 最高版本选择、版本比较(含 prerelease)、扩展与文件名匹配(大小写与前导点)、packaged 路径逃逸拒绝、runtime 的 `LITHE__PATH` 导出与 `PATH` 前置。 +- 前端回落链路测试:`windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts` 断言契约插件启动映射与无归属文件返回 null;启动扫描步骤在 bootstrap 中执行。 +- M1 尚属静态与进程内验证:真实插件包安装→启用→语言服务器拉起的全链路实机验收随 M2/M3 完成,期间矩阵 `verificationStatus` 保持 pending。`./scripts/verify-windows-boundaries.sh`、`./scripts/verify-platform-feature-matrix-change.sh HEAD` 随 PR 通过。 + +## 适用范围 + +- `windows/tauri/src-tauri/src/plugin_host/`(存储、扫描、启动接线与 fixtures 验收测试) +- `windows/tauri/src-tauri/src/main.rs`(模块注册与命令注册) +- `windows/tauri/src-tauri/src/language_tools.rs`(复用的系统工具查找) +- `windows/tauri/src/features/plugins/declarative-plugin-launch.ts`(前端宿主 API) +- `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts`(契约插件回落来源) +- `windows/tauri/src/features/bootstrap/bootstrap-async.ts`(启动扫描步骤) +- `shared/platform-feature-matrix/features/declarative-plugin-contract.json`、`plugin-marketplace.json`、`plugin-extension-management.json`、`declarative-plugin-settings.json`、`php-optional-plugin.json`、`plugin-web-tool-windows.json` diff --git a/.agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md deleted file mode 100644 index 4a8eeea11e..0000000000 --- a/.agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md +++ /dev/null @@ -1,76 +0,0 @@ -# Agent 笔记:Windows 声明式插件宿主 - -状态:提议中 - -## 先说结论 - -Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`;真正要建的是宿主侧的包存储、信任审批、启动接线和管理界面。v0 能力声明只开放 `languages:1`、`languageServers:1`、`serverProcess:1`,声明了更高能力的插件包由 Core 自动拒绝。 - -## 问题 - -- 功能矩阵 `declarative-plugin-contract`、`plugin-functions` 对 Windows 记为 missing(2026-10-22 矩阵对齐时确认);[声明式语言插件](../../implemented/architecture/2026-10-12-declarative-language-plugins.md) 也写明"Windows 现有扩展清单暂未改造,接入时必须通过同一组 fixture"。 -- Windows 当前插件生态是渲染进程内的 Worker 扩展:`Plugins/win/Official/PhpSupport` 是 Worker 包格式(`main: "plugin.js"`),与契约包不同,无法复用 Lithe Store 面向 macOS/契约发布的语言插件包。 -- Windows 没有插件信任链与仓库源管理;`install_extension_from_url` 前端已调用但 Rust 宿主从未注册,非语言扩展的落盘安装链路实际不可用。 -- 契约的确定性逻辑(清单校验、启动计划展开、安装审查)按仓库规则只在 `rust/lithe-core/src/plugins/` 实现一次,而 `windows/tauri/src-tauri` 已依赖 `lithe-core`——差的只是宿主侧接线,不是契约本身。 - -## 提案 - -### 双模型边界 - -判定句:扩展需要"宿主代表用户拉起进程"(语言服务器、构建系统、运行/测试提供方)就走契约宿主;扩展需要"在宿主内运行自己的代码来表达 UI 或服务行为"(颜色主题、图标主题、AI provider、数据库 sidecar、ACP agent)就留在 Worker 模型。 - -- 正确做法:新增 Go 语言支持时写契约 `plugin.json` 声明 `languageServers` 和 `executionProviders`,由 Core 展开启动与运行计划。 -- 不要这样做:把主题扩展改写成契约包;为 Worker 扩展桥接一套函数通道冒充 litheFunctions;在宿主里按插件 ID 写 `if` 分支。 - -### 能力声明渐进开放 - -v0 宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1], serverProcess:[1] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 - -### 存储、信任与安装(v0) - -- 安装根为 `app_local_data_dir()/plugins///`,每包一份 `installation.json` 记录来源、版本与内容摘要;文件损坏时拒写、不覆盖既有记录。 -- bundled 包从 `resource_dir()` 只读加载;运行时不写安装目录与资源目录,遵守发行基线只读边界。 -- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入与市场下载一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 -- 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 - -### 与 Worker 扩展模型共存 - -- 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 -- PhpSupport 迁移:M3 用契约包替换 Worker 包,保留用户启用状态后移除 Worker 版本。 -- M2 一并处理 `install_extension_from_url` 死链:补注册命令或删除前端死调用,二选一在 PR 描述里说明。 -- 管理界面按模型分组展示两类扩展并使用不同文案,避免用户把 Worker 扩展当成契约插件。 - -### 实施切分 - -M1 包存储 + 启动校验 + 语言注册 + LSP 启动;M2 磁盘/市场安装链路 + 管理界面 v1;M3 声明式设置 + 运行/测试计划 + PHP 契约包替换;M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 - -## 考虑过的备选方案 - -- 把 Worker 扩展模型整体改造成契约宿主:契约没有主题、AI provider 这些概念,改造成本高且无收益,不采用。 -- 在 Tauri 侧另写一套清单校验:违反"校验只在 Core 实现一次",共享 fixtures 无法复用,双端语义必然漂移,不采用。 -- 把 Worker RPC 桥接成 litheFunctions:两者协议语义不同,会同时存在两套函数协议,等有真实插件需求时再按[插件函数契约](../../implemented/architecture/2026-10-20-plugin-function-contract.md)接入。 -- 信任模型一步到位 publisher 签名:Windows 验签载体需要单独评估,v0 以 bundled 加全量审批兜底,签名延后是显式记录的缺口而不是遗漏。 - -## 验收标准 - -- M1:`shared/contracts/plugins/v1/fixtures/` 全量经 `plugins.declarative.validate` 验收,`invalid-manifests.json` 的每类错误码逐一断言;合法样例包被启动扫描注册并在语言清单中出现;`languageServerLaunchPlan` 的结果能拉起真实进程。`cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml`、`scripts/build-windows.ps1`、`./scripts/verify-windows-boundaries.sh` 通过;`declarative-plugin-contract.json` Windows 状态随代码更新为 partial。 -- M2:磁盘导入完成快照、全量审批、staging 重验、回滚与损坏 `installation.json` 拒写;管理界面按模型分组;`install_extension_from_url` 死链有明确处置。 -- M3:PHP 契约包完成安装、启用、打开 `.php` 文件、Intelephense 启动的全链路;声明设置值代入启动模板;Worker 版 PhpSupport 移除且启用状态迁移。 -- M4:`ui` 2 样例页面在 WebView2 中显示,CSP 拒绝联网与 eval,禁用、更新、卸载和窗口关闭同步销毁页面。 -- 每个里程碑:`./scripts/verify-platform-feature-matrix-change.sh HEAD` 通过;实机验收完成前对应矩阵条目的 `verificationStatus` 保持 pending。 - -## 风险 - -- `lsp.rs` 现以 JDTLS 专用路径为主,启动计划的挂接形状需要开工时先通读探底,可能影响 M1 工作量估计。 -- Windows 的 reparse point/junction 语义与 macOS symlink 不完全相同,包内路径逃逸判断需要单独测试,不能照搬 macOS 用例。 -- 签名延后意味着 v0 社区包的信任完全依赖审批界面,文案必须明示未认证状态,否则用户可能把审批当成签名背书。 -- 双模型并存对用户可见,管理界面若不清晰分组会造成"为什么有两类扩展"的困惑。 -- WebView2 对自定义 scheme 与 CSP 的实现细节与 WKWebView 有差异,M4 需要实机验证后再声明 `ui:2`。 - -## 适用范围 - -- `windows/tauri/src-tauri/src/`(M1 在此新增 `plugin_host/` 模块:存储、扫描、启动接线) -- `windows/tauri/src-tauri/src/lsp.rs`、`language_tools.rs`、`main.rs`(接线与命令注册) -- `windows/tauri/src/extensions/languages/`(契约插件语言来源) -- `windows/tauri/src/features/settings/`(契约插件管理区块) -- `shared/platform-feature-matrix/features/declarative-plugin-contract.json`、`plugin-marketplace.json`、`plugin-extension-management.json`、`declarative-plugin-settings.json`、`php-optional-plugin.json`、`plugin-web-tool-windows.json` diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index 39656a25da..d4e3bc46b1 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -29,12 +29,19 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md", - "Plugins/win/Official/PhpSupport/plugin.json" + "windows/tauri/src-tauri/src/plugin_host/mod.rs", + "windows/tauri/src-tauri/src/plugin_host/store.rs", + "windows/tauri/src-tauri/src/plugin_host/scan.rs", + "windows/tauri/src-tauri/src/plugin_host/launch.rs", + "windows/tauri/src-tauri/src/plugin_host/tests.rs", + "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", + "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", + "shared/contracts/plugins/v1/fixtures/invalid-manifests.json" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:现有扩展清单尚未改为读取该契约,接入时必须通过同一组共享 fixture。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。" + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。尚未实现:Worker 扩展清单向契约迁移、宿主 UI 适配(ui 能力未声明)、设置与运行计划(settings/executionProviders 未声明)、安装链与市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", + "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移与宿主 UI 适配仍未实现,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。" } diff --git a/windows/tauri/src-tauri/src/language_tools.rs b/windows/tauri/src-tauri/src/language_tools.rs index f477c2e243..56c5fe69d6 100644 --- a/windows/tauri/src-tauri/src/language_tools.rs +++ b/windows/tauri/src-tauri/src/language_tools.rs @@ -522,7 +522,7 @@ fn find_bun_executable(path_env: Option<&OsStr>, user_home: Option<&OsStr>) -> O /// Finds a system tool by probing every PATH directory. Only Windows launcher /// extensions are accepted; a bare extensionless file is a POSIX script that /// CreateProcess cannot execute. -fn find_system_tool(path_env: Option<&OsStr>, name: &str) -> Option { +pub(crate) fn find_system_tool(path_env: Option<&OsStr>, name: &str) -> Option { let path_env = path_env?; for directory in std::env::split_paths(path_env) { if let Some(tool) = first_existing(executable_candidates_in(&directory, name)) { diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 9ca4fc281f..27a4ea5200 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -18,6 +18,7 @@ mod lsp; mod maven; mod memory; mod platform; +mod plugin_host; mod profile_migration; mod project_window_registry; mod project_windows; @@ -130,6 +131,7 @@ fn main() { app.manage(window_title::WindowTitles::default()); app.manage(run::RunProcessManager::default()); app.manage(debug::DebugAdapterManager::default()); + app.manage(plugin_host::PluginHostState::default()); run::cleanup_legacy_appdata(app.handle()); maven::clear_dependency_outputs(app.handle()); for config in initial_windows.iter().filter(|config| config.create) { @@ -242,6 +244,9 @@ fn main() { window_title::update_window_title_context, lsp::lsp_resolve_java_launch, lsp::lsp_rebuild_java_index, + plugin_host::declarative_plugins_rescan, + plugin_host::declarative_plugins_state, + plugin_host::launch::declarative_plugins_language_launch, language_tools::get_tool_path, language_tools::install_language_tools, language_tools::check_language_tool_requirements, diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs new file mode 100644 index 0000000000..4fa9d9d390 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -0,0 +1,424 @@ +//! Turns Core launch plans into process command lines for the frontend. +//! +//! Core expands templates and chooses the launch kind; this module resolves +//! the plan against real paths. Packaged entries must be regular files inside +//! the owning package — reparse points and path escapes are rejected so a +//! tampered package can never make the host execute anything outside its own +//! version directory. Runtime and system-command launches resolve through +//! PATH and export the same `LITHE__PATH` convention the macOS host +//! uses; the declared `minimumVersion` probe is enforced from the install +//! milestone on, when the host also owns the checkRuntimeVersion wiring. + +use super::store; +use super::ScannedPackage; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +/// Windows reparse-point attribute; covers symlinks and junctions alike. +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; + +/// A resolved launch, shaped for `resolveEditorLspLaunch` on the frontend. +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedLaunch { + pub plugin_id: String, + pub language_id: String, + pub server_path: String, + pub server_args: Vec, + pub initialization_options: Option, + /// Extra process environment (PATH prepends and LITHE_* exports). + pub environment: BTreeMap, +} + +/// Resolves the launch plan of the enabled declarative plugin that owns +/// `file_path`, or `None` when no accepted plugin claims the file. +#[tauri::command] +pub fn declarative_plugins_language_launch( + app: AppHandle, + file_path: String, + workspace_path: String, +) -> Result, String> { + let Some(package) = owning_package(&app, &file_path) else { + return Ok(None); + }; + let Some(language_id) = owning_language_id(&package, &file_path) else { + return Ok(None); + }; + let state_directory = store::plugin_state_directory(&app, &package.entry.plugin_id)?; + let plan = super::scan::core_execute( + "plugins.declarative.languageServerLaunchPlan", + serde_json::json!({ + "manifest": package.manifest, + "languageID": language_id, + "request": launch_request(&state_directory, &package, &workspace_path), + }), + ) + .map_err(|error| format!("launch plan failed: {error}"))?; + if plan["status"] == serde_json::json!("rejected") { + return Err(format!( + "launch plan rejected: {}", + plan["error"]["message"].as_str().unwrap_or("unknown") + )); + } + resolve_launch(&package, &language_id, &plan).map(Some) +} + +fn owning_package(app: &AppHandle, file_path: &str) -> Option { + app.state::() + .packages() + .into_iter() + .find(|package| package_matches_file(package, file_path)) +} + +fn package_matches_file(package: &ScannedPackage, file_path: &str) -> bool { + package.entry.status == "accepted" + && package.entry.enabled + && owning_language_id(package, file_path).is_some() +} + +/// Matches a file against the manifest's language declarations: extensions +/// compare without their optional leading dot and case-insensitively, file +/// names compare as exact lowercased basenames. +fn owning_language_id(package: &ScannedPackage, file_path: &str) -> Option { + let file_name = Path::new(file_path) + .file_name() + .map(|name| name.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let extension = Path::new(&file_name) + .extension() + .map(|extension| extension.to_string_lossy().to_lowercase()); + package.manifest["languages"] + .as_array()? + .iter() + .find(|language| { + let matches_extension = language["fileExtensions"].as_array().is_some_and(|items| { + items.iter().any(|item| { + item.as_str() + .map(|text| text.trim_start_matches('.').to_lowercase()) + == extension + }) + }); + let matches_name = language["fileNames"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item.as_str().map(str::to_lowercase).as_deref() == Some(&file_name)) + }); + matches_extension || matches_name + }) + .and_then(|language| language["id"].as_str().map(str::to_string)) +} + +/// Builds the Core launch-plan request with host-owned directories and +/// architecture. Settings layers stay empty until the settings milestone; +/// Core then falls back to declared defaults. +fn launch_request(state_directory: &Path, package: &ScannedPackage, workspace_path: &str) -> Value { + let workspace_directory = state_directory.join(store::workspace_state_key(workspace_path)); + serde_json::json!({ + "pluginDirectory": package.root.to_string_lossy(), + "stateDirectory": state_directory.to_string_lossy(), + "workspaceStateDirectory": workspace_directory.to_string_lossy(), + "architecture": super::host_architecture(), + "settings": {}, + "projectSettings": {}, + }) +} + +fn resolve_launch( + package: &ScannedPackage, + language_id: &str, + plan: &Value, +) -> Result { + let launch = &plan["launch"]; + let kind = launch["kind"].as_str().unwrap_or_default(); + let mut environment: BTreeMap = BTreeMap::new(); + let server_path = match kind { + "packaged" => verified_packaged_path( + &package.root, + launch["path"] + .as_str() + .ok_or("packaged launch has no path")?, + )?, + "systemCommand" => { + let commands = launch["commands"] + .as_array() + .ok_or("systemCommand launch has no commands")?; + let path_env = std::env::var_os("PATH"); + commands + .iter() + .filter_map(|command| command.as_str()) + .find_map(|command| { + super::super::language_tools::find_system_tool(path_env.as_deref(), command) + }) + .ok_or_else(|| { + format!("none of the declared system commands is on PATH: {commands:?}") + })? + } + "runtime" => { + let tool = launch["tool"] + .as_str() + .ok_or("runtime launch has no tool")?; + let path = match launch["packagedRuntime"].as_str() { + Some(packaged) => { + let path = verified_packaged_path(&package.root, packaged)?; + prepend_path( + &mut environment, + path.parent().unwrap_or(package.root.as_path()), + ); + path + } + None => { + let path_env = std::env::var_os("PATH"); + super::super::language_tools::find_system_tool(path_env.as_deref(), tool) + .ok_or_else(|| format!("runtime tool {tool} is not on PATH"))? + } + }; + environment.insert( + format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")), + path.to_string_lossy().to_string(), + ); + path + } + other => return Err(format!("unsupported launch kind {other}")), + }; + Ok(ResolvedLaunch { + plugin_id: package.entry.plugin_id.clone(), + language_id: language_id.to_string(), + server_path: server_path.to_string_lossy().to_string(), + server_args: string_arguments(&plan["arguments"]), + initialization_options: plan + .get("initializationOptions") + .filter(|value| value.is_object()) + .cloned(), + environment, + }) +} + +/// Joins a package-relative plan path and proves it stays inside the package +/// as a regular file. Every launch the host executes passes through here. +fn verified_packaged_path(root: &Path, relative: &str) -> Result { + let candidate = root.join(relative); + let metadata = std::fs::symlink_metadata(&candidate).map_err(|error| { + format!( + "packaged entry {} is unreadable: {error}", + candidate.display() + ) + })?; + #[cfg(windows)] + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "packaged entry {} is a reparse point", + candidate.display() + )); + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(format!( + "packaged entry {} is a symbolic link", + candidate.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "packaged entry {} is not a regular file", + candidate.display() + )); + } + let canonical_root = root + .canonicalize() + .map_err(|error| format!("package root {} is unreadable: {error}", root.display()))?; + let canonical = candidate.canonicalize().map_err(|error| { + format!( + "packaged entry {} is unreadable: {error}", + candidate.display() + ) + })?; + if !canonical.starts_with(&canonical_root) { + return Err(format!( + "packaged entry {} escapes the package directory", + candidate.display() + )); + } + // Plan-relative paths use `/`; report the resolved path with the + // platform separator so frontend logs and spawns see one canonical form. + #[cfg(windows)] + let candidate = PathBuf::from(candidate.to_string_lossy().replace('/', "\\")); + Ok(candidate) +} + +fn prepend_path(environment: &mut BTreeMap, directory: &Path) { + let existing = std::env::var("PATH").unwrap_or_default(); + let separator = if cfg!(windows) { ";" } else { ":" }; + environment.insert( + "PATH".to_string(), + format!("{}{separator}{existing}", directory.display()), + ); +} + +fn string_arguments(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unique per-run temp root; mirrors the temp-dir convention used by the + /// other src-tauri tests (std::env::temp_dir + pid + timestamp). + fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-host-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + fn package_with_root(root: PathBuf, manifest: Value) -> ScannedPackage { + ScannedPackage { + entry: super::super::CatalogEntry { + plugin_id: "test.plugin".to_string(), + display_name: "Test".to_string(), + version: "1.0.0".to_string(), + api_version: 1, + source: "installed", + enabled: true, + status: "accepted", + languages: Vec::new(), + rejection: None, + }, + root, + manifest, + } + } + + fn php_like_manifest() -> Value { + serde_json::json!({ + "languages": [ + { "id": "php", "displayName": "PHP", "fileExtensions": [".PHP", "php"] }, + { "id": "ini", "displayName": "INI", "fileNames": ["php.ini"] } + ] + }) + } + + #[test] + fn languages_match_extensions_case_and_dot_insensitively() { + let package = package_with_root(PathBuf::from("unused"), php_like_manifest()); + assert_eq!(owning_language_id(&package, r"C:\site\index.phtml"), None); + assert_eq!( + owning_language_id(&package, r"C:\site\Index.PHP").as_deref(), + Some("php") + ); + assert_eq!(owning_language_id(&package, r"C:\site\ini"), None); + assert_eq!( + owning_language_id(&package, r"C:\site\PHP.INI").as_deref(), + Some("ini") + ); + } + + #[test] + fn version_comparison_is_numeric_per_chunk() { + assert_eq!( + store::compare_versions("1.10.0", "1.9.0"), + std::cmp::Ordering::Greater + ); + assert_eq!( + store::compare_versions("2.0.0", "10.0.0"), + std::cmp::Ordering::Less + ); + assert_eq!( + store::compare_versions("1.0.0-rc1", "1.0.0"), + std::cmp::Ordering::Less + ); + assert_eq!( + store::compare_versions("1.0.0", "1.0.0"), + std::cmp::Ordering::Equal + ); + } + + #[test] + fn workspace_state_keys_are_stable_and_distinct() { + assert_eq!( + store::workspace_state_key(r"D:\work\demo"), + store::workspace_state_key(r"D:\work\demo") + ); + assert_ne!( + store::workspace_state_key(r"D:\work\demo"), + store::workspace_state_key(r"D:\work\demo2") + ); + } + + #[test] + fn packaged_launches_resolve_inside_the_package() { + let temporary = unique_temp_root("launch"); + let package_root = temporary.as_path().join("pkg"); + let server = package_root.join("bin").join("server.exe"); + std::fs::create_dir_all(server.parent().unwrap()).expect("bin dir"); + std::fs::write(&server, b"binary").expect("server file"); + let package = package_with_root(package_root.clone(), serde_json::json!({})); + let plan = serde_json::json!({ + "launch": { "kind": "packaged", "path": "bin/server.exe" }, + "arguments": ["--stdio"] + }); + let resolved = resolve_launch(&package, "php", &plan).expect("resolves"); + assert_eq!(resolved.server_path, server.to_string_lossy()); + assert_eq!(resolved.server_args, vec!["--stdio".to_string()]); + assert_eq!(resolved.initialization_options, None); + + std::fs::write(temporary.as_path().join("outside.exe"), b"x").expect("outside file"); + let escape_plan = serde_json::json!({ + "launch": { "kind": "packaged", "path": "../outside.exe" }, + "arguments": [] + }); + assert!(resolve_launch(&package, "php", &escape_plan).is_err()); + } + + #[test] + fn runtime_launches_export_the_tool_path_and_prepend_the_runtime_directory() { + let temporary = unique_temp_root("launch"); + let package_root = temporary.as_path().join("pkg"); + let runtime = package_root.join("runtime").join("java.exe"); + std::fs::create_dir_all(runtime.parent().unwrap()).expect("runtime dir"); + std::fs::write(&runtime, b"jvm").expect("runtime file"); + let package = package_with_root(package_root, serde_json::json!({})); + let plan = serde_json::json!({ + "launch": { + "kind": "runtime", + "tool": "java", + "packagedRuntime": "runtime/java.exe", + "entry": "server.jar" + }, + "arguments": ["-jar", "server.jar"] + }); + let resolved = resolve_launch(&package, "java", &plan).expect("resolves"); + assert_eq!(resolved.server_path, runtime.to_string_lossy()); + assert_eq!( + resolved.environment.get("LITHE_JAVA_PATH"), + Some(&runtime.to_string_lossy().to_string()) + ); + let path = resolved.environment.get("PATH").expect("PATH export"); + assert!(path.starts_with(&format!( + "{};", + temporary.as_path().join("pkg").join("runtime").display() + ))); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs new file mode 100644 index 0000000000..5ff4f1feda --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -0,0 +1,133 @@ +//! Windows host for contract v1 declarative plugin packages. +//! +//! Package validation, launch-plan expansion, and install review are shared +//! deterministic logic that lives only in `lithe-core`; this module owns the +//! platform side: where packages are installed, which packages the host +//! currently accepts, and how a Core launch plan becomes a process command +//! line. The boundary with the Worker extension model and the staged +//! capability rollout are recorded in +//! .agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md + +pub mod launch; +pub mod scan; +pub mod store; +#[cfg(test)] +mod tests; + +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::PathBuf; +use std::sync::Mutex; +use tauri::{AppHandle, Manager}; + +/// Capability versions this host implements. `plugins.declarative.validate` +/// rejects any package that requires a capability or version beyond this map, +/// so the host never registers a manifest section it cannot run. The set +/// grows per milestone of the declarative plugin host work. +pub fn host_capabilities() -> Value { + json!({ + "languages": [1], + "languageServers": [1], + "serverProcess": [1], + }) +} + +/// Host CPU architecture in the vocabulary Core launch templates expect +/// (`arm64` / `x86_64`); Rust spells aarch64 differently. +pub fn host_architecture() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + other => other, + } +} + +/// Frontend-facing summary of one scanned declarative plugin. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogEntry { + pub plugin_id: String, + pub display_name: String, + pub version: String, + pub api_version: u32, + /// `bundled` packages ship inside the installation directory and load + /// read-only; `installed` packages live under the per-user plugin store. + pub source: &'static str, + pub enabled: bool, + /// `accepted` packages are registered language owners; `rejected` + /// packages are quarantined with Core's stable rejection code. + pub status: &'static str, + pub languages: Vec, + pub rejection: Option, +} + +/// Language declaration of a scanned package. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogLanguage { + pub id: String, + pub display_name: String, + pub file_extensions: Vec, + pub file_names: Vec, +} + +/// Why a package was quarantined. Manifest problems carry Core's stable +/// `plugin.*` code; store problems (missing or invalid installation record) +/// use the host-owned `plugin.store.*` namespace. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogRejection { + pub code: String, + pub message: String, + pub path: Option, +} + +/// One scanned package with everything launch resolution needs but the +/// frontend does not. +#[derive(Debug, Clone)] +pub struct ScannedPackage { + pub entry: CatalogEntry, + /// Directory that directly contains `plugin.json`. + pub root: PathBuf, + /// Raw manifest; plan commands are pure functions and take it every time. + pub manifest: Value, +} + +/// Cached scan result. The frontend rescans at startup and after installs, +/// then reads this state for registration and launch queries. +#[derive(Default)] +pub struct PluginHostState { + packages: Mutex>, +} + +impl PluginHostState { + pub fn set_packages(&self, packages: Vec) { + *self.packages.lock().expect("plugin host catalog mutex") = packages; + } + + pub fn packages(&self) -> Vec { + self.packages + .lock() + .expect("plugin host catalog mutex") + .clone() + } +} + +/// Rescans bundled and installed packages and replaces the cached catalog. +#[tauri::command] +pub fn declarative_plugins_rescan(app: AppHandle) -> Result { + let packages = scan::build_catalog(&app)?; + let count = packages.len(); + app.state::().set_packages(packages); + Ok(count) +} + +/// Returns the cached scan result. Call `declarative_plugins_rescan` first; +/// this command never triggers filesystem work. +#[tauri::command] +pub fn declarative_plugins_state(app: AppHandle) -> Vec { + app.state::() + .packages() + .into_iter() + .map(|package| package.entry) + .collect() +} diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs new file mode 100644 index 0000000000..d720f96513 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -0,0 +1,230 @@ +//! Scans package directories and asks Core to validate every manifest. +//! +//! The host owns package bytes and install records; Core owns every manifest +//! semantic. A package Core rejects is quarantined with Core's stable +//! rejection code and never registered as a language owner. Validation runs +//! through the same `lithe_core::execute_json` envelope the frontend uses, so +//! the shared fixtures acceptance covers the real production path. + +use super::store::{self, DiscoveredPackage}; +use super::{CatalogEntry, CatalogLanguage, CatalogRejection, ScannedPackage}; +use serde_json::{json, Value}; +use tauri::AppHandle; + +pub fn build_catalog(app: &AppHandle) -> Result, String> { + build_catalog_from(&store::bundled_root(app)?, &store::install_root(app)?) +} + +/// Catalog builder over explicit roots so tests can exercise the full scan +/// without a running Tauri app. +pub fn build_catalog_from( + bundled_root: &std::path::Path, + install_root: &std::path::Path, +) -> Result, String> { + let mut discovered = store::discover_bundled_in(bundled_root)?; + discovered.extend(store::discover_installed_in(install_root)?); + Ok(discovered.into_iter().map(scan_package).collect()) +} + +fn scan_package(package: DiscoveredPackage) -> ScannedPackage { + let fallback_id = package + .root + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let manifest = match read_manifest(&package.root) { + Ok(manifest) => manifest, + Err(error) => { + return quarantined( + package, + fallback_id, + "0", + catalog_rejection("plugin.manifest.invalid", error, None), + ); + } + }; + if let Some(problem) = package.record_problem.clone() { + return quarantined( + package, + manifest_id(&manifest).unwrap_or(fallback_id), + &manifest_version(&manifest).unwrap_or_default(), + catalog_rejection("plugin.store.installationRecordInvalid", problem, None), + ); + } + match validate_manifest(&manifest) { + Ok(plugin) => { + let entry = CatalogEntry { + plugin_id: plugin["id"].as_str().unwrap_or(&fallback_id).to_string(), + display_name: plugin["displayName"] + .as_str() + .unwrap_or("plugin") + .to_string(), + version: plugin["version"].as_str().unwrap_or("0").to_string(), + api_version: plugin["pluginAPIVersion"].as_u64().unwrap_or(0) as u32, + source: package.source, + enabled: package.enabled, + status: "accepted", + languages: catalog_languages(&plugin), + rejection: None, + }; + ScannedPackage { + entry, + root: package.root, + manifest, + } + } + Err(rejection) => quarantined( + package, + manifest_id(&manifest).unwrap_or(fallback_id), + &manifest_version(&manifest).unwrap_or_default(), + rejection, + ), + } +} + +fn quarantined( + package: DiscoveredPackage, + plugin_id: String, + version: &str, + rejection: CatalogRejection, +) -> ScannedPackage { + ScannedPackage { + entry: CatalogEntry { + plugin_id, + display_name: plugin_id_display(&package.root), + version: version.to_string(), + api_version: 0, + source: package.source, + enabled: false, + status: "rejected", + languages: Vec::new(), + rejection: Some(rejection), + }, + root: package.root, + manifest: Value::Null, + } +} + +fn plugin_id_display(root: &std::path::Path) -> String { + root.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +fn catalog_rejection(code: &str, message: String, path: Option) -> CatalogRejection { + CatalogRejection { + code: code.to_string(), + message, + path, + } +} + +fn manifest_id(manifest: &Value) -> Option { + manifest["id"].as_str().map(|id| id.to_string()) +} + +fn manifest_version(manifest: &Value) -> Option { + manifest["version"] + .as_str() + .map(|version| version.to_string()) +} + +fn catalog_languages(plugin: &Value) -> Vec { + plugin["languages"] + .as_array() + .map(|languages| { + languages + .iter() + .map(|language| CatalogLanguage { + id: language["id"].as_str().unwrap_or_default().to_string(), + display_name: language["displayName"] + .as_str() + .unwrap_or_default() + .to_string(), + file_extensions: string_list(&language["fileExtensions"]), + file_names: string_list(&language["fileNames"]), + }) + .collect() + }) + .unwrap_or_default() +} + +fn string_list(value: &Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(|text| text.to_string())) + .collect() + }) + .unwrap_or_default() +} + +fn read_manifest(root: &std::path::Path) -> Result { + let text = std::fs::read_to_string(root.join("plugin.json")) + .map_err(|error| format!("plugin.json could not be read: {error}"))?; + serde_json::from_str(&text).map_err(|error| format!("plugin.json is not valid JSON: {error}")) +} + +/// Executes one Core command through the shared JSON envelope. Command-level +/// outcomes (including manifest rejections) arrive as `ok: true` data with a +/// `status` field; only malformed requests or host bugs surface as `Err`. +pub(crate) fn core_execute(command: &str, payload: Value) -> Result { + let request = json!({ "command": command, "payload": payload }); + let response_text = lithe_core::execute_json(&request.to_string()); + let response: Value = serde_json::from_str(&response_text) + .map_err(|error| format!("{command} returned an unreadable response: {error}"))?; + if response["ok"] == json!(true) { + Ok(response["data"].clone()) + } else { + Err(format!( + "{command} failed: {}", + response["error"]["message"] + .as_str() + .unwrap_or("unknown error") + )) + } +} + +/// Validates one manifest with this host's capability declaration. `Ok` +/// carries Core's normalized manifest. `host: None` runs pure contract +/// validation, which is what the shared fixture acceptance asserts. +pub(crate) fn validate_manifest_with( + manifest: &Value, + host: Option, +) -> Result { + let mut payload = json!({ "manifest": manifest }); + if let Some(host) = host { + payload["hostCapabilities"] = host; + } + let outcome = core_execute("plugins.declarative.validate", payload) + .map_err(|error| catalog_rejection("plugin.host.coreFailure", error, None))?; + if outcome["status"] == json!("rejected") { + return Err(CatalogRejection { + code: outcome["error"]["code"] + .as_str() + .unwrap_or("plugin.manifest.invalid") + .to_string(), + message: outcome["error"]["message"] + .as_str() + .unwrap_or("rejected") + .to_string(), + path: outcome["error"]["field"] + .as_str() + .map(|field| field.to_string()), + }); + } + Ok(outcome["plugin"].clone()) +} + +/// Host-scoped validation used by the scan path. +pub(crate) fn validate_manifest(manifest: &Value) -> Result { + validate_manifest_with(manifest, Some(super::host_capabilities())) +} + +/// Test entry point for the per-package scan path. +#[cfg(test)] +pub(crate) fn scan_package_for_tests(package: super::store::DiscoveredPackage) -> ScannedPackage { + scan_package(package) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/store.rs b/windows/tauri/src-tauri/src/plugin_host/store.rs new file mode 100644 index 0000000000..290ab9075e --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/store.rs @@ -0,0 +1,258 @@ +//! Installed and bundled declarative plugin package locations. +//! +//! Installed packages live under the per-user application data directory, +//! never inside the installation directory: installed app resources are the +//! release baseline and must stay byte-identical for updater deltas. Bundled +//! packages are read-only inputs under the installation's resource directory. + +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::path::Path; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +/// Per-user root for installed declarative plugin packages. +pub fn install_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugins")) +} + +/// Read-only root of packages shipped with the application. +pub fn bundled_root(app: &AppHandle) -> Result { + Ok(app + .path() + .resource_dir() + .map_err(|error| error.to_string())? + .join("OfficialPlugins")) +} + +/// Writable per-plugin state root (server caches, workspace state). +pub fn state_root(app: &AppHandle) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-state")) +} + +/// Writable state directory of one plugin's language server. +pub fn plugin_state_directory(app: &AppHandle, plugin_id: &str) -> Result { + // Plugin IDs are reverse-DNS names Core validated before they reach this + // call, so they are safe as a single path component. + state_root(app).map(|root| root.join(plugin_id)) +} + +/// Stable per-workspace subdirectory name inside a plugin's state directory. +pub fn workspace_state_key(workspace_path: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in workspace_path.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +/// Persistent install record of one installed plugin. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallationRecord { + pub schema_version: u32, + pub plugin_id: String, + /// Active version directory name under the plugin directory. + pub version: String, + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +fn default_enabled() -> bool { + true +} + +/// One package directory candidate found on disk. +pub struct DiscoveredPackage { + /// Directory that directly contains `plugin.json`. + pub root: PathBuf, + pub source: &'static str, + pub enabled: bool, + /// Set when the package was found but its install record is missing or + /// invalid; scan quarantines the package with this reason. + pub record_problem: Option, +} + +/// Enumerates installed packages under `install_root`: +/// `/installation.json` names the active `//` +/// directory. +pub fn discover_installed_in(install_root: &Path) -> Result, String> { + let mut packages = Vec::new(); + let entries = match std::fs::read_dir(install_root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(packages), + Err(error) => { + return Err(format!( + "{} could not be read: {error}", + install_root.display() + )) + } + }; + for entry in entries.flatten() { + let plugin_directory = entry.path(); + if !plugin_directory.is_dir() { + continue; + } + let quarantined = |problem: String| DiscoveredPackage { + root: plugin_directory.clone(), + source: "installed", + enabled: false, + record_problem: Some(problem), + }; + let record: InstallationRecord = + match read_json_file(&plugin_directory.join("installation.json")) { + Ok(Some(record)) => record, + Ok(None) => { + packages.push(quarantined( + "installation.json is missing; the package was not installed through Lithe" + .to_string(), + )); + continue; + } + Err(error) => { + packages.push(quarantined(format!( + "installation.json could not be read: {error}" + ))); + continue; + } + }; + if record.schema_version != 1 { + packages.push(quarantined(format!( + "unsupported installation record schema {}", + record.schema_version + ))); + continue; + } + let version_directory = plugin_directory.join(&record.version); + if !version_directory.join("plugin.json").is_file() { + packages.push(quarantined(format!( + "recorded version {} has no plugin.json", + record.version + ))); + continue; + } + packages.push(DiscoveredPackage { + root: version_directory, + source: "installed", + enabled: record.enabled, + record_problem: None, + }); + } + Ok(packages) +} + +/// Enumerates bundled packages under `bundled_root`: either +/// `/plugin.json` or `//plugin.json`, where the highest +/// version wins. +pub fn discover_bundled_in(bundled_root: &Path) -> Result, String> { + let mut packages = Vec::new(); + let entries = match std::fs::read_dir(bundled_root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(packages), + Err(error) => { + return Err(format!( + "{} could not be read: {error}", + bundled_root.display() + )) + } + }; + for entry in entries.flatten() { + let package_directory = entry.path(); + if !package_directory.is_dir() { + continue; + } + if package_directory.join("plugin.json").is_file() { + packages.push(DiscoveredPackage { + root: package_directory, + source: "bundled", + enabled: true, + record_problem: None, + }); + continue; + } + let mut versions: Vec<(String, PathBuf)> = Vec::new(); + if let Ok(version_entries) = std::fs::read_dir(&package_directory) { + for version_entry in version_entries.flatten() { + let version_directory = version_entry.path(); + if version_directory.is_dir() && version_directory.join("plugin.json").is_file() { + versions.push(( + version_directory + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(), + version_directory, + )); + } + } + } + let Some((_, best)) = versions + .into_iter() + .max_by(|(left, _), (right, _)| compare_versions(left, right)) + else { + continue; + }; + packages.push(DiscoveredPackage { + root: best, + source: "bundled", + enabled: true, + record_problem: None, + }); + } + Ok(packages) +} + +/// Dot-separated version comparison with numeric-aware chunks, so `1.10` +/// sorts after `1.9` without pulling in a semver dependency. +pub fn compare_versions(left: &str, right: &str) -> Ordering { + let mut left_chunks = split_version_chunks(left); + let mut right_chunks = split_version_chunks(right); + loop { + match (left_chunks.next(), right_chunks.next()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + // A non-numeric tail is a pre-release suffix (`1.0.0-rc1`), which + // sorts before the plain release. + (Some(remaining), None) => { + return if remaining.1.parse::().is_err() { + Ordering::Less + } else { + Ordering::Greater + }; + } + (Some(left_chunk), Some(right_chunk)) => { + let ordering = left_chunk.cmp(&right_chunk); + if ordering != Ordering::Equal { + return ordering; + } + } + } + } +} + +fn split_version_chunks(version: &str) -> impl Iterator { + version + .split(['.', '-', '+']) + .map(|chunk| (chunk.parse::().unwrap_or(0), chunk)) +} + +/// Reads a JSON file; `Ok(None)` marks a missing file so callers can +/// distinguish "absent" from "present but unreadable". +fn read_json_file Deserialize<'de>>(path: &Path) -> Result, String> { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + serde_json::from_str(&text) + .map(Some) + .map_err(|error| error.to_string()) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs new file mode 100644 index 0000000000..fc912a189d --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -0,0 +1,324 @@ +//! Shared-fixture acceptance and store-layout tests for the plugin host. +//! +//! The fixture tests are the Windows half of the contract acceptance recorded +//! in the declarative-plugin note: every host must reject the same invalid +//! manifests with the same stable codes, and the host capability declaration +//! must gate level 3 sections this host does not implement yet. + +use super::scan::{self, build_catalog_from}; +use super::store::{self, DiscoveredPackage}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +const FIXTURES_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/contracts/plugins/v1/fixtures" +); + +/// Valid manifests that this host's v0 capability declaration accepts. +const ACCEPTED_FIXTURES: [&str; 5] = [ + "go-support.plugin.json", + "hello-lithe.plugin.json", + "java-support.plugin.json", + "node-runtime.plugin.json", + "php-support.plugin.json", +]; + +/// Valid manifests whose capabilities exceed the v0 host declaration; Core +/// rejects them through the capability gate, not through manifest semantics. +const CAPABILITY_GATED_FIXTURES: [&str; 6] = [ + "cargo-build.plugin.json", + "go-build.plugin.json", + "gradle-build.plugin.json", + "gradle-provider.plugin.json", + "maven-build.plugin.json", + "web-panel.plugin.json", +]; + +fn fixture_path(name: &str) -> PathBuf { + PathBuf::from(FIXTURES_DIR).join(name) +} + +fn read_fixture(name: &str) -> Value { + let text = std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|error| panic!("fixture {name} could not be read: {error}")); + serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("fixture {name} is not JSON: {error}")) +} + +/// Applies one JSON-Patch-style fixture edit; mirrors the Core test helper so +/// both platforms exercise identical case material. +fn apply_edit(manifest: &mut Value, edit: &Value) { + let path = edit["path"].as_str().expect("edit path"); + let (parent_path, key) = path.rsplit_once('/').expect("edit path separator"); + let parent = manifest + .pointer_mut(parent_path) + .unwrap_or_else(|| panic!("missing edit parent {parent_path}")); + match edit["op"].as_str().expect("edit op") { + "set" => match parent { + Value::Object(object) => { + object.insert(key.to_string(), edit["value"].clone()); + } + Value::Array(array) => { + array[key.parse::().expect("array index")] = edit["value"].clone() + } + _ => panic!("edit parent {parent_path} is not a container"), + }, + "remove" => { + parent + .as_object_mut() + .expect("remove parent is an object") + .remove(key); + } + other => panic!("unknown edit op {other}"), + } +} + +/// Contract parity: without host capabilities the Windows binary must reject +/// every invalid fixture with the exact code the shared fixture names. +#[test] +fn invalid_manifest_fixtures_keep_their_stable_codes() { + let fixtures: Value = serde_json::from_str( + &std::fs::read_to_string(fixture_path("invalid-manifests.json")) + .expect("invalid-manifests.json is readable"), + ) + .expect("invalid-manifests.json is JSON"); + let cases = fixtures["cases"].as_array().expect("cases array"); + assert!(!cases.is_empty(), "fixture case list is not empty"); + for case in cases { + let base = case["base"].as_str().expect("case base"); + let mut manifest = read_fixture(base); + for edit in case["edits"].as_array().expect("case edits") { + apply_edit(&mut manifest, edit); + } + let outcome = scan::validate_manifest_with(&manifest, None); + let expected = case["expectedCode"].as_str().expect("expected code"); + match outcome { + Err(rejection) => assert_eq!( + rejection.code, expected, + "case {:?} (base {base}) rejected with {} instead", + case["name"], rejection.code + ), + Ok(_) => panic!( + "case {:?} (base {base}) was accepted but must reject with {expected}", + case["name"] + ), + } + } +} + +/// Every valid fixture passes contract validation without host capabilities. +#[test] +fn valid_fixtures_validate_without_host_capabilities() { + let all_fixtures: Vec<&str> = ACCEPTED_FIXTURES + .iter() + .copied() + .chain(CAPABILITY_GATED_FIXTURES.iter().copied()) + .chain(std::iter::once("settings-v2.plugin.json")) + .collect(); + for name in all_fixtures { + let outcome = scan::validate_manifest_with(&read_fixture(name), None); + assert!(outcome.is_ok(), "{name} must validate: {outcome:?}"); + } +} + +/// The v0 host capability declaration accepts the fixtures it can run and +/// gates the rest with `plugin.api.unsupported` naming the missing capability. +#[test] +fn host_v0_capabilities_gate_unimplemented_sections() { + for name in ACCEPTED_FIXTURES { + let outcome = + scan::validate_manifest_with(&read_fixture(name), Some(super::host_capabilities())); + assert!( + outcome.is_ok(), + "{name} must be accepted by the host: {outcome:?}" + ); + } + for name in CAPABILITY_GATED_FIXTURES { + let outcome = + scan::validate_manifest_with(&read_fixture(name), Some(super::host_capabilities())); + let rejection = outcome.expect_err(&format!("{name} must be capability-gated")); + assert_eq!(rejection.code, "plugin.api.unsupported", "{name}"); + assert!(rejection + .path + .as_deref() + .unwrap_or_default() + .starts_with("requires.")); + } + // settings-v2 requires settings 2 and languageServers 2, both undeclared. + let outcome = scan::validate_manifest_with( + &read_fixture("settings-v2.plugin.json"), + Some(super::host_capabilities()), + ); + assert_eq!( + outcome.expect_err("settings-v2 is gated").code, + "plugin.api.unsupported" + ); +} + +/// A scanned package directory produces an accepted entry with normalized +/// languages, and a broken manifest is quarantined instead of registered. +#[test] +fn scan_accepts_valid_packages_and_quarantines_broken_ones() { + let root = unique_temp_root("scan"); + let package_root = root.join("acme.php"); + std::fs::create_dir_all(&package_root).expect("package dir"); + let manifest = read_fixture("php-support.plugin.json"); + std::fs::write( + package_root.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + + let accepted = scan_package_at(&package_root, None); + assert_eq!(accepted.entry.status, "accepted"); + assert_eq!(accepted.entry.plugin_id, manifest["id"]); + assert_eq!(accepted.entry.languages.len(), 1); + assert_eq!( + accepted.entry.languages[0].file_extensions, + vec!["php", "phtml"] + ); + + std::fs::write(package_root.join("plugin.json"), "{ not json").expect("broken manifest"); + let broken = scan_package_at(&package_root, None); + assert_eq!(broken.entry.status, "rejected"); + assert_eq!( + broken.entry.rejection.as_ref().expect("rejection").code, + "plugin.manifest.invalid" + ); + + let _ = std::fs::remove_dir_all(&root); +} + +/// Missing or invalid installation records quarantine the package instead of +/// loading it, even when its manifest is valid. +#[test] +fn store_problems_quarantine_packages_with_valid_manifests() { + let root = unique_temp_root("store"); + let plugin_directory = root.join("acme.php"); + let version_directory = plugin_directory.join("1.0.0"); + std::fs::create_dir_all(&version_directory).expect("version dir"); + std::fs::write( + version_directory.join("plugin.json"), + serde_json::to_string(&read_fixture("php-support.plugin.json")).expect("manifest"), + ) + .expect("manifest file"); + + // No installation.json: the package was never installed through Lithe. + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert_eq!(discovered.len(), 1); + assert!(discovered[0].record_problem.is_some()); + + // A record naming a missing version is a store problem too. + std::fs::write( + plugin_directory.join("installation.json"), + json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "9.9.9"}).to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_some()); + + // Correct record: discovered at the recorded version, enabled by default. + std::fs::write( + plugin_directory.join("installation.json"), + json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0"}).to_string(), + ) + .expect("record file"); + let discovered = store::discover_installed_in(&root).expect("discovery works"); + assert!(discovered[0].record_problem.is_none()); + assert_eq!(discovered[0].root, version_directory); + assert!(discovered[0].enabled); + + let _ = std::fs::remove_dir_all(&root); +} + +/// Bundled discovery prefers the highest version directory of a package. +#[test] +fn bundled_discovery_picks_the_highest_version() { + let root = unique_temp_root("bundled"); + for version in ["1.0.0", "1.10.0", "1.9.0"] { + let directory = root.join("acme.php").join(version); + std::fs::create_dir_all(&directory).expect("version dir"); + std::fs::write(directory.join("plugin.json"), "{}").expect("manifest file"); + } + let discovered = store::discover_bundled_in(&root).expect("discovery works"); + assert_eq!(discovered.len(), 1); + assert_eq!( + discovered[0].root.file_name().unwrap().to_string_lossy(), + "1.10.0" + ); + assert_eq!(discovered[0].source, "bundled"); + let _ = std::fs::remove_dir_all(&root); +} + +/// The full catalog build accepts recorded packages, quarantines store +/// problems, and keeps bundled packages enabled. +#[test] +fn catalog_build_combines_bundled_and_installed_sources() { + let temporary = unique_temp_root("catalog"); + let install_root = temporary.join("plugins"); + let bundled_root = temporary.join("OfficialPlugins"); + let installed_version = install_root.join("acme.php").join("1.0.0"); + std::fs::create_dir_all(&installed_version).expect("installed dir"); + std::fs::write( + installed_version.join("plugin.json"), + serde_json::to_string(&read_fixture("php-support.plugin.json")).expect("manifest"), + ) + .expect("manifest file"); + std::fs::write( + install_root.join("acme.php").join("installation.json"), + json!({"schemaVersion": 1, "pluginId": "acme.php", "version": "1.0.0", "enabled": false}) + .to_string(), + ) + .expect("record file"); + let bundled_directory = bundled_root.join("acme.go").join("2.0.0"); + std::fs::create_dir_all(&bundled_directory).expect("bundled dir"); + std::fs::write( + bundled_directory.join("plugin.json"), + serde_json::to_string(&read_fixture("go-support.plugin.json")).expect("manifest"), + ) + .expect("manifest file"); + + let catalog = build_catalog_from(&bundled_root, &install_root).expect("catalog builds"); + assert_eq!(catalog.len(), 2); + let installed = catalog + .iter() + .find(|package| package.entry.source == "installed") + .expect("installed entry"); + assert_eq!(installed.entry.status, "accepted"); + assert!(!installed.entry.enabled, "recorded enabled: false wins"); + let bundled = catalog + .iter() + .find(|package| package.entry.source == "bundled") + .expect("bundled entry"); + assert_eq!(bundled.entry.plugin_id, "dev.lithe.fixture.go-support"); + assert!(bundled.entry.enabled); + + let _ = std::fs::remove_dir_all(&temporary); +} + +fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-host-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root +} + +/// Scans one package directory through the production scan path. +fn scan_package_at( + root: &std::path::Path, + record_problem: Option, +) -> super::ScannedPackage { + super::scan::scan_package_for_tests(DiscoveredPackage { + root: root.to_path_buf(), + source: "installed", + enabled: true, + record_problem, + }) +} diff --git a/windows/tauri/src/features/bootstrap/bootstrap-async.ts b/windows/tauri/src/features/bootstrap/bootstrap-async.ts index dde79e2ac7..0bb2d6ad2a 100644 --- a/windows/tauri/src/features/bootstrap/bootstrap-async.ts +++ b/windows/tauri/src/features/bootstrap/bootstrap-async.ts @@ -42,6 +42,14 @@ const asyncBootstrapSteps = [ await initializeExtensionStore(); }, }, + { + name: "declarative plugins", + run: async () => { + const { rescanDeclarativePlugins } = + await import("@/features/plugins/declarative-plugin-launch"); + await rescanDeclarativePlugins(); + }, + }, ] as const; export async function runAsyncBootstrapSteps(): Promise { diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts index a9d34c45dd..04ae163752 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts @@ -36,6 +36,7 @@ test("resolves workspace A Maven context while workspace B is active", async () resolveJavaLspLaunch, mavenLaunchContextForWorkspace, ensureWorkspaceGitBootstrap: async () => "skipped", + resolveDeclarativePluginLaunch: async () => null, }, ); @@ -70,6 +71,7 @@ test("forwards the declared Gradle import context to the launch", async () => { })), mavenLaunchContextForWorkspace, ensureWorkspaceGitBootstrap: async () => "skipped", + resolveDeclarativePluginLaunch: async () => null, }, ); @@ -84,8 +86,58 @@ test("omits the Gradle import context for a workspace without one", async () => resolveJavaLspLaunch, mavenLaunchContextForWorkspace, ensureWorkspaceGitBootstrap: async () => "skipped", + resolveDeclarativePluginLaunch: async () => null, }, ); expect(launch?.javaGradle).toBeNull(); }); + +test("falls back to a declarative plugin launch when the registry has no server", async () => { + const resolveDeclarativePluginLaunch = mock(async () => ({ + pluginId: "dev.lithe.plugin.php-support", + languageId: "php", + serverPath: "C:/Users/example/AppData/Local/Lithe/plugins/dev.lithe.plugin.php-support/1.0.0/language-server/bin/intelephense.js", + serverArgs: ["--stdio"], + initializationOptions: { memory: 4096 }, + environment: { LITHE_NODE_PATH: "C:/Lithe/tools/node/node.exe" }, + })); + const launch = await resolveEditorLspLaunch( + "D:/work/src/Index.php", + { workspaceId: "workspace-a", root: "D:/work" }, + { + resolveJavaLspLaunch, + mavenLaunchContextForWorkspace, + ensureWorkspaceGitBootstrap: async () => "skipped", + resolveDeclarativePluginLaunch, + }, + ); + + expect(resolveDeclarativePluginLaunch).toHaveBeenCalledWith( + "D:/work/src/Index.php", + "D:/work", + ); + expect(launch).toEqual({ + providerId: "dev.lithe.plugin.php-support", + languageId: "php", + serverPath: "C:/Users/example/AppData/Local/Lithe/plugins/dev.lithe.plugin.php-support/1.0.0/language-server/bin/intelephense.js", + serverArgs: ["--stdio"], + initializationOptions: { memory: 4096 }, + environment: { LITHE_NODE_PATH: "C:/Lithe/tools/node/node.exe" }, + }); +}); + +test("returns null when neither the registry nor a declarative plugin owns the file", async () => { + const launch = await resolveEditorLspLaunch( + "D:/work/src/notes.txt", + { workspaceId: "workspace-a", root: "D:/work" }, + { + resolveJavaLspLaunch, + mavenLaunchContextForWorkspace, + ensureWorkspaceGitBootstrap: async () => "skipped", + resolveDeclarativePluginLaunch: async () => null, + }, + ); + + expect(launch).toBeNull(); +}); diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts index 6c98870c75..b52effb6f0 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -1,4 +1,5 @@ import type { BackendLanguageToolConfigSet } from "@/extensions/registry/extension-store-runtime"; +import { resolveDeclarativePluginLaunch } from "@/features/plugins/declarative-plugin-launch"; import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in-language-support"; import { resolveJavaLspLaunch, @@ -35,12 +36,14 @@ export interface EditorLspLaunchDependencies { ensureWorkspaceGitBootstrap: typeof ensureWorkspaceGitBootstrap; resolveJavaLspLaunch: typeof resolveJavaLspLaunch; mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; + resolveDeclarativePluginLaunch: typeof resolveDeclarativePluginLaunch; } const defaultDependencies: EditorLspLaunchDependencies = { ensureWorkspaceGitBootstrap, resolveJavaLspLaunch, mavenLaunchContextForWorkspace, + resolveDeclarativePluginLaunch, }; export async function resolveEditorLspLaunch( @@ -88,7 +91,9 @@ export async function resolveEditorLspLaunch( const extension = extensionRegistry.getExtensionForFilePath(filePath); const serverPath = extensionRegistry.getLspServerPath(filePath); const languageId = extensionRegistry.getLanguageId(filePath); - if (!serverPath || !languageId) return null; + if (!serverPath || !languageId) { + return resolveDeclarativeFallback(filePath, workspacePath, dependencies); + } return { providerId: languageId, @@ -99,3 +104,24 @@ export async function resolveEditorLspLaunch( tools: getLanguageToolConfigSet(extension?.manifest), }; } + +/** + * Contract v1 declarative plugins resolve after the Worker extension model so + * a Worker-owned language keeps its server until the package migration lands. + */ +async function resolveDeclarativeFallback( + filePath: string, + workspacePath: string, + dependencies: EditorLspLaunchDependencies, +): Promise { + const launch = await dependencies.resolveDeclarativePluginLaunch(filePath, workspacePath); + if (!launch) return null; + return { + providerId: launch.pluginId, + languageId: launch.languageId, + serverPath: launch.serverPath, + serverArgs: launch.serverArgs, + initializationOptions: launch.initializationOptions ?? undefined, + environment: launch.environment, + }; +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts new file mode 100644 index 0000000000..d751f47a4d --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -0,0 +1,69 @@ +import { invoke } from "@/platform/tauri-core"; + +/** + * Host API for contract v1 declarative plugin packages. The Rust + * `plugin_host` module owns package storage and scanning; these wrappers are + * the only frontend entry points. Decisions about the declarative host and + * its coexistence with the Worker extension model are recorded in + * .agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md + */ + +export interface DeclarativePluginLanguage { + id: string; + displayName: string; + fileExtensions: string[]; + fileNames: string[]; +} + +export interface DeclarativePluginRejection { + code: string; + message: string; + path: string | null; +} + +export interface DeclarativePluginSummary { + pluginId: string; + displayName: string; + version: string; + apiVersion: number; + /** `bundled` packages ship with the app; `installed` live in the user store. */ + source: "bundled" | "installed"; + enabled: boolean; + /** `rejected` packages are quarantined and never own a language. */ + status: "accepted" | "rejected"; + languages: DeclarativePluginLanguage[]; + rejection: DeclarativePluginRejection | null; +} + +export interface DeclarativePluginLaunch { + pluginId: string; + languageId: string; + serverPath: string; + serverArgs: string[]; + initializationOptions: Record | null; + environment: Record; +} + +/** Rescans bundled and installed packages; call at startup and after installs. */ +export function rescanDeclarativePlugins(): Promise { + return invoke("declarative_plugins_rescan"); +} + +/** Returns the cached scan result; call `rescanDeclarativePlugins` first. */ +export function listDeclarativePlugins(): Promise { + return invoke("declarative_plugins_state"); +} + +/** + * Resolves the launch of the enabled plugin that owns `filePath`, or `null` + * when no accepted declarative plugin claims the file. + */ +export function resolveDeclarativePluginLaunch( + filePath: string, + workspacePath: string, +): Promise { + return invoke("declarative_plugins_language_launch", { + filePath, + workspacePath, + }); +} diff --git a/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts b/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts index 10123bacaa..ade67beee4 100644 --- a/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts +++ b/windows/tauri/src/features/workspace/services/workspace-git-bootstrap.test.ts @@ -32,7 +32,12 @@ const resolveJavaLspLaunch = mock(async () => ({ arguments: [], environment: {}, cacheDirectory: "C:/runtime/cache", })); const mavenLaunchContextForWorkspace = mock(async () => null); -const dependencies = { ensureWorkspaceGitBootstrap, resolveJavaLspLaunch, mavenLaunchContextForWorkspace }; +const dependencies = { + ensureWorkspaceGitBootstrap, + resolveJavaLspLaunch, + mavenLaunchContextForWorkspace, + resolveDeclarativePluginLaunch: async () => null, +}; let invokeSpy: ReturnType>; function openRuntime() { From c9ada0d3d809b9e32ebb9dbf67b24c15b61fb176 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 18:17:13 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat(windows):=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E6=8F=92=E4=BB=B6=E5=AE=BF=E4=B8=BB=20M2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=A3=81=E7=9B=98=E5=AF=BC=E5=85=A5=E9=93=BE?= =?UTF-8?q?=E3=80=81=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2=E4=B8=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugin_host/install.rs:ZIP/目录→私有快照(拒绝 reparse point、路径逃逸、 超限文件与恶意 ZIP 条目)→plugins.marketplace.installReview 全量审查→ 确认时摘要比对拒绝快照调包→staging 复验后激活;卸载、回滚、启用/停用 - launch.rs:runtime 启动按 versionArguments 探测实际版本,低于 minimumVersion 拒绝启动;版本 token 解析容忍 v 前缀、引号与 go/java 输出 - 设置新增一级页"声明式插件(契约 v1)":安装审查卡(未认证发布者提示、 权限与完整程序声明展开)、启用/停用/卸载/回滚,与 Worker 扩展分开列出; en-US 与 zh-CN 全套文案 - install_extension_from_url 等 Worker 非语言市场命令注册为显式占位错误 (extensionStore.notAvailable),调用方得到可诊断失败而非命令不存在 - 矩阵 plugin-marketplace/plugin-extension-management/declarative-plugin-contract Windows 证据与 notes 随代码更新;Note 增补 M2 记录 --- ...6-10-22-windows-declarative-plugin-host.md | 15 +- .../features/declarative-plugin-contract.json | 4 +- .../features/plugin-extension-management.json | 6 +- .../features/plugin-marketplace.json | 11 +- .../src/extension_store_placeholder.rs | 52 ++ windows/tauri/src-tauri/src/main.rs | 12 + .../src-tauri/src/plugin_host/install.rs | 557 ++++++++++++++++++ .../src/plugin_host/install_tests.rs | 166 ++++++ .../tauri/src-tauri/src/plugin_host/launch.rs | 90 +++ .../tauri/src-tauri/src/plugin_host/mod.rs | 5 +- .../tauri/src-tauri/src/plugin_host/tests.rs | 5 + .../plugins/declarative-plugin-launch.ts | 54 ++ .../plugins/declarative-plugins-section.tsx | 270 +++++++++ .../components/settings-hierarchy.test.tsx | 13 +- .../settings/components/settings-panels.tsx | 13 + .../settings/lib/settings-navigation.ts | 13 + windows/tauri/src/i18n/locale.ts | 26 + 17 files changed, 1298 insertions(+), 14 deletions(-) create mode 100644 windows/tauri/src-tauri/src/extension_store_placeholder.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/install.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/install_tests.rs create mode 100644 windows/tauri/src/features/plugins/declarative-plugins-section.tsx diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index 3570061b5d..c645ed955c 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -4,7 +4,7 @@ ## 先说结论 -Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;能力声明只开放 `languages:1`、`languageServers:1`、`serverProcess:1`,声明了更高能力的插件包由 Core 自动拒绝。 +Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;M2 已补磁盘导入安装链(快照、`installReview` 全量审批、staging 复验、卸载/回滚/启停)、设置内独立管理页和 runtime 启动的 `minimumVersion` 探测。能力声明只开放 `languages:1`、`languageServers:1`、`serverProcess:1`,声明了更高能力的插件包由 Core 自动拒绝。 ## 问题 @@ -31,7 +31,7 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 - 安装根为 `app_local_data_dir()/plugins///`,`installation.json` 记录版本与启用状态;记录缺失或损坏的包隔离展示,绝不静默加载。 - bundled 包从 `resource_dir()/OfficialPlugins` 只读加载,多版本目录取最高版本;运行时不写安装目录与资源目录,遵守发行基线只读边界。 - 每个插件的语言服务器状态目录在 `app_local_data_dir()/plugin-state//`,由宿主注入启动计划请求。 -- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入与市场下载(M2)一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 +- 信任模型 v0:官方 ID 防冒用由 Core 校验兜底;bundled 包随安装器分发;磁盘导入(已落地)与市场下载(M3+)一律走 `plugins.marketplace.installReview` 全量审批(权限 + 完整程序清单),审批界面明示"未认证发布者"。publisher Ed25519 验签明确延后,这是 v0 的已知缺口,Windows 验签方案(Authenticode 还是包内 Ed25519)单独立项评估。 - 磁盘导入的私有快照、staging 重验、内容摘要复查与回滚语义照抄 macOS 侧规则(见声明式语言插件 Note 的磁盘安装节),Windows 不另造审查语义。 ### 启动接线(M1 落地形状) @@ -46,12 +46,12 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 - 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 - PhpSupport 迁移:M3 用契约包替换 Worker 包,保留用户启用状态后移除 Worker 版本。 -- M2 一并处理 `install_extension_from_url` 死链:补注册命令或删除前端死调用,二选一在 PR 描述里说明。 +- `install_extension_from_url` 等 Worker 非语言市场命令(M2 已处置):在 Rust 宿主注册为显式占位错误(`extensionStore.notAvailable`),调用方得到可诊断的结构化失败而不是"命令不存在";完整落盘后端是独立工作项,不与契约宿主混淆。 - 管理界面按模型分组展示两类扩展并使用不同文案,避免用户把 Worker 扩展当成契约插件。 ### 实施切分 -M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘/市场安装链路 + 管理界面 v1;M3 声明式设置 + 运行/测试计划 + PHP 契约包替换;M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 +M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包替换;M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 ## 考虑过的备选方案 @@ -70,6 +70,10 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 ## 验证 +- M2 磁盘导入链测试(`windows/tauri/src-tauri/src/plugin_host/install_tests.rs`):目录快照隔离(symlink 拒绝)、ZIP 解包与恶意路径(`../` 逃逸条目)拒绝、staging 安装(坏包失败保留现行版本且清空 staging)、回滚版本选择;runtime 启动的版本探测输出解析(node/java/go 真实输出格式)在 `launch.rs` 测试覆盖。 +- 管理界面(`declarative-plugins-section.tsx`)挂在设置的独立一级页,`settings-hierarchy.test.tsx` 的布局断言随导航更新;i18n 提供 en-US 与 zh-CN 全套文案。 + + - 共享 fixtures 全量验收在 Windows 二进制内完成:`invalid-manifests.json` 全部 154 个用例经宿主校验路径断言同一稳定错误码(不带 hostCapabilities 的契约等价模式),合法样例在不带与带 v0 能力声明两种模式下分别断言通过与 `plugin.api.unsupported` 门控。测试入口:`windows/tauri/src-tauri/src/plugin_host/tests.rs`,运行 `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml plugin_host`。 - 存储与启动的单测覆盖安装记录缺失/损坏隔离、bundled 最高版本选择、版本比较(含 prerelease)、扩展与文件名匹配(大小写与前导点)、packaged 路径逃逸拒绝、runtime 的 `LITHE__PATH` 导出与 `PATH` 前置。 - 前端回落链路测试:`windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts` 断言契约插件启动映射与无归属文件返回 null;启动扫描步骤在 bootstrap 中执行。 @@ -80,7 +84,10 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - `windows/tauri/src-tauri/src/plugin_host/`(存储、扫描、启动接线与 fixtures 验收测试) - `windows/tauri/src-tauri/src/main.rs`(模块注册与命令注册) - `windows/tauri/src-tauri/src/language_tools.rs`(复用的系统工具查找) +- `windows/tauri/src-tauri/src/plugin_host/install.rs`(磁盘导入、卸载、回滚、启停) +- `windows/tauri/src-tauri/src/extension_store_placeholder.rs`(Worker 扩展落盘命令的显式占位) - `windows/tauri/src/features/plugins/declarative-plugin-launch.ts`(前端宿主 API) +- `windows/tauri/src/features/plugins/declarative-plugins-section.tsx`(设置内管理页) - `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts`(契约插件回落来源) - `windows/tauri/src/features/bootstrap/bootstrap-async.ts`(启动扫描步骤) - `shared/platform-feature-matrix/features/declarative-plugin-contract.json`、`plugin-marketplace.json`、`plugin-extension-management.json`、`declarative-plugin-settings.json`、`php-optional-plugin.json`、`plugin-web-tool-windows.json` diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index d4e3bc46b1..9b93c80a88 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -34,6 +34,8 @@ "windows/tauri/src-tauri/src/plugin_host/scan.rs", "windows/tauri/src-tauri/src/plugin_host/launch.rs", "windows/tauri/src-tauri/src/plugin_host/tests.rs", + "windows/tauri/src-tauri/src/plugin_host/install.rs", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", "shared/contracts/plugins/v1/fixtures/invalid-manifests.json" @@ -42,6 +44,6 @@ "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。尚未实现:Worker 扩展清单向契约迁移、宿主 UI 适配(ui 能力未声明)、设置与运行计划(settings/executionProviders 未声明)、安装链与市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面(设置独立页,与 Worker 扩展分组展示);runtime 启动的 minimumVersion 探测在每次启动时执行。尚未实现:Worker 扩展清单向契约迁移、宿主 UI 适配(ui 能力未声明)、设置与运行计划(settings/executionProviders 未声明)、多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移与宿主 UI 适配仍未实现,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。" } diff --git a/shared/platform-feature-matrix/features/plugin-extension-management.json b/shared/platform-feature-matrix/features/plugin-extension-management.json index b9da617487..f4b9ad26d0 100644 --- a/shared/platform-feature-matrix/features/plugin-extension-management.json +++ b/shared/platform-feature-matrix/features/plugin-extension-management.json @@ -20,11 +20,13 @@ "windows/tauri/src/extensions", "windows/tauri/src/features/settings", "windows/tauri/src-tauri/src/language_tools.rs", - "windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts" + "windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "windows/tauri/src-tauri/src/plugin_host/install.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "macOS 全新安装时打开插件页,确认“已安装”标签不再显示 PHP 未安装占位项(改由“插件市场”标签提供安装),齿轮菜单可“从磁盘安装插件”选择已签名的插件包;社区插件在“已安装”中显示来源仓库与社区维护提示,可卸载;安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。" + "verification": "macOS 全新安装时打开插件页,确认“已安装”标签不再显示 PHP 未安装占位项(改由“插件市场”标签提供安装),齿轮菜单可“从磁盘安装插件”选择已签名的插件包;社区插件在“已安装”中显示来源仓库与社区维护提示,可卸载;安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。契约 v1 插件在设置的独立“声明式插件”页管理(启用/停用/卸载/回滚,与 Worker 扩展分开列出,隔离原因直接展示),管理命令走 plugin_host 的 Tauri 命令。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。" } diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index 5c65523f6c..a7364be64f 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -43,14 +43,17 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/plugin-index.schema.json", - "shared/contracts/plugins/v1/fixtures/marketplace", - "rust/lithe-core/src/plugins/marketplace.rs" + "windows/tauri/src-tauri/src/plugin_host/install.rs", + "windows/tauri/src-tauri/src/plugin_host/install_tests.rs", + "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "rust/lithe-core/src/plugins/marketplace.rs", + "shared/contracts/plugins/v1/fixtures/marketplace" ], "implementationStatus": "partial", "verificationStatus": "not-applicable" }, "owner": "Extensions", "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 已实现契约插件的磁盘导入链:ZIP/目录→私有快照(拒绝 reparse point、路径逃逸、超限文件与恶意 ZIP 条目)→plugins.marketplace.installReview 全量审查(权限+完整程序清单,界面明示未认证发布者)→确认时摘要比对拒绝快照调包→staging 复验后激活→卸载/回滚/启停。磁盘导入的取消清理、快照篡改拒绝与 staging 失败清理由 Rust 测试覆盖;多仓库源市场 UI 仍缺失(M3+),实机验收前 verificationStatus 保持 not-applicable。Worker 扩展模型的非语言市场安装命令(install_extension_from_url 等)现注册为显式占位错误,等待独立后端。" } diff --git a/windows/tauri/src-tauri/src/extension_store_placeholder.rs b/windows/tauri/src-tauri/src/extension_store_placeholder.rs new file mode 100644 index 0000000000..41f7c083a9 --- /dev/null +++ b/windows/tauri/src-tauri/src/extension_store_placeholder.rs @@ -0,0 +1,52 @@ +//! Explicit placeholders for the Worker extension model's disk-installed +//! package commands. +//! +//! The frontend invokes these for non-language marketplace extensions, but +//! the Rust backend was never registered, so every call failed with Tauri's +//! "command not found" and the extension store stayed silently broken +//! (gated by `platform/tauri-core.ts` as `extensions`). Registering structured +//! errors makes the failure diagnosable; a full disk-install backend for +//! Worker packages is a separate work item and must not be confused with the +//! declarative contract host in `plugin_host`. + +use serde_json::json; + +fn not_yet_available(operation: &str) -> Result { + // Structured payload rather than a plain message so the dispatcher can + // surface a stable code once the backend lands. + Err(json!({ + "code": "extensionStore.notAvailable", + "message": format!("{operation} requires a package backend that is not available yet"), + }) + .to_string()) +} + +#[tauri::command] +pub fn install_extension_from_url( + _extension_id: String, + _url: String, + _checksum: String, + _size: i64, +) -> Result<(), String> { + not_yet_available("install_extension_from_url") +} + +#[tauri::command] +pub fn uninstall_extension_new(_extension_id: String) -> Result<(), String> { + not_yet_available("uninstall_extension_new") +} + +#[tauri::command] +pub fn get_extension_path(_extension_id: String) -> Result { + not_yet_available::("get_extension_path") +} + +#[tauri::command] +pub fn read_extension_entrypoint(_extension_id: String, _entry: String) -> Result { + not_yet_available::("read_extension_entrypoint") +} + +#[tauri::command] +pub fn get_importable_ide_projects() -> Result, String> { + not_yet_available::>("get_importable_ide_projects") +} diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 27a4ea5200..c35186710d 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -7,6 +7,7 @@ mod date_time; mod debug; mod diagnostics; mod document; +mod extension_store_placeholder; mod file_events; mod file_publication; mod fonts; @@ -134,6 +135,7 @@ fn main() { app.manage(plugin_host::PluginHostState::default()); run::cleanup_legacy_appdata(app.handle()); maven::clear_dependency_outputs(app.handle()); + plugin_host::install::cleanup_stale_snapshots(app.handle()); for config in initial_windows.iter().filter(|config| config.create) { tauri::WebviewWindowBuilder::from_config(app.handle(), config)?.build()?; } @@ -247,6 +249,16 @@ fn main() { plugin_host::declarative_plugins_rescan, plugin_host::declarative_plugins_state, plugin_host::launch::declarative_plugins_language_launch, + plugin_host::install::declarative_plugins_prepare_disk_install, + plugin_host::install::declarative_plugins_confirm_disk_install, + plugin_host::install::declarative_plugins_uninstall, + plugin_host::install::declarative_plugins_set_enabled, + plugin_host::install::declarative_plugins_rollback, + extension_store_placeholder::install_extension_from_url, + extension_store_placeholder::uninstall_extension_new, + extension_store_placeholder::get_extension_path, + extension_store_placeholder::read_extension_entrypoint, + extension_store_placeholder::get_importable_ide_projects, language_tools::get_tool_path, language_tools::install_language_tools, language_tools::check_language_tool_requirements, diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs new file mode 100644 index 0000000000..f74063df62 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -0,0 +1,557 @@ +//! Disk-package import, uninstall, enable/disable, and rollback for +//! declarative plugin packages. +//! +//! The install flow follows the macOS disk-import semantics recorded in +//! .agents/notes/implemented/architecture/2026-10-12-declarative-language-plugins.md: +//! the input is copied to a private snapshot first, Core's +//! `plugins.marketplace.installReview` produces the permission and program +//! summary the user confirms, and installation re-validates the staged bytes +//! before they replace the active version. Package trust comes only from this +//! explicit review; there is no signature verification in v0 (recorded gap in +//! the declarative plugin host note). + +use super::scan::{core_execute, validate_manifest}; +use super::store::{self, InstallationRecord}; +use super::{CatalogEntry, PluginHostState}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +/// Maximum decompressed package size (16 MiB per file, 128 MiB total) and +/// entry count; a hostile ZIP cannot exhaust the user's disk before review. +const MAX_PACKAGE_FILE_BYTES: u64 = 16 * 1024 * 1024; +const MAX_PACKAGE_TOTAL_BYTES: u64 = 128 * 1024 * 1024; +const MAX_PACKAGE_ENTRIES: usize = 2_000; +/// Maximum snapshot lifetime is one review session; a session id ties prepare +/// and confirm calls together so abandoned snapshots are cleaned up. +const SNAPSHOT_PREFIX: &str = "lithe-plugin-disk-"; + +/// A prepared disk import awaiting user confirmation. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedInstall { + pub session_id: String, + pub review: InstallReview, + /// SHA-256 of the snapshot `plugin.json`; the confirm call must still see + /// the same package, so review answers cannot be replayed on new bytes. + pub manifest_digest: String, + /// Human-readable display name from the manifest. + pub display_name: String, +} + +/// Core's install review, reshaped for the frontend. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallReview { + pub plugin_id: String, + pub version: String, + pub permissions: Vec, + pub programs: Vec, + pub new_programs: Vec, +} + +/// Prepares a disk import from a ZIP archive or an unpacked directory. The +/// source is copied to a private snapshot under the app's temporary +/// directory; nothing is installed and nothing outside the snapshot is +/// written. +#[tauri::command] +pub fn declarative_plugins_prepare_disk_install( + app: AppHandle, + source_path: String, +) -> Result { + let temporary_root = app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-temp"); + std::fs::create_dir_all(&temporary_root).map_err(|error| error.to_string())?; + let session_id = format!("{}{}", SNAPSHOT_PREFIX, uuid_like_suffix()); + let snapshot = temporary_root.join(&session_id); + let source = PathBuf::from(&source_path); + if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { + extract_zip_snapshot(&source, &snapshot)?; + } else if source.is_dir() { + copy_directory_snapshot(&source, &snapshot)?; + } else { + return Err(format!( + "{} is neither a .zip archive nor an unpacked package directory", + source.display() + )); + } + let manifest = read_snapshot_manifest(&snapshot)?; + let manifest_digest = content_digest(&manifest); + let review = review_snapshot(&manifest, None)?; + std::fs::create_dir_all(&temporary_root).map_err(|error| error.to_string())?; + Ok(PreparedInstall { + session_id, + review, + manifest_digest, + display_name: manifest["displayName"] + .as_str() + .unwrap_or("plugin") + .to_string(), + }) +} + +/// Installs a previously prepared snapshot after the user confirmed the +/// review. The confirm call re-validates the snapshot bytes and re-runs the +/// review, so a snapshot swapped between prepare and confirm is rejected. +#[tauri::command] +pub fn declarative_plugins_confirm_disk_install( + app: AppHandle, + session_id: String, + manifest_digest: String, +) -> Result { + if !session_id.starts_with(SNAPSHOT_PREFIX) + || session_id.contains('/') + || session_id.contains('\\') + || session_id.contains("..") + { + return Err("invalid install session".to_string()); + } + let snapshot = app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-temp") + .join(&session_id); + let manifest = read_snapshot_manifest(&snapshot)?; + if content_digest(&manifest) != manifest_digest { + discard_snapshot(&snapshot); + return Err("the package changed after review; the import was cancelled".to_string()); + } + // Re-review with no previously approved programs: a disk install always + // requires a fresh full confirmation, matching the macOS rule. + let _ = review_snapshot(&manifest, None)?; + + let install_root = store::install_root(&app)?; + let plugin_id = manifest["id"] + .as_str() + .ok_or("manifest has no id")? + .to_string(); + let version = manifest["version"] + .as_str() + .ok_or("manifest has no version")? + .to_string(); + + // Validate the final manifest against this host's capabilities before + // anything is written into the install root. + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + + staging_install(&install_root, &plugin_id, &version, &snapshot)?; + write_installation_record(&install_root, &plugin_id, &version)?; + discard_snapshot(&snapshot); + rescan_after_change(&app); + Ok(catalog_entry_of(&app, &plugin_id) + .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) +} + +/// Removes an installed package. Bundled packages cannot be uninstalled. +#[tauri::command] +pub fn declarative_plugins_uninstall(app: AppHandle, plugin_id: String) -> Result<(), String> { + let install_root = store::install_root(&app)?; + let plugin_directory = install_root.join(&plugin_id); + if !plugin_directory.is_dir() { + return Err(format!("{plugin_id} is not an installed plugin")); + } + std::fs::remove_dir_all(&plugin_directory) + .map_err(|error| format!("{plugin_id} could not be removed: {error}"))?; + let state_directory = store::plugin_state_directory(&app, &plugin_id)?; + if state_directory.is_dir() { + std::fs::remove_dir_all(&state_directory) + .map_err(|error| format!("state directory could not be removed: {error}"))?; + } + rescan_after_change(&app); + Ok(()) +} + +/// Enables or disables an installed plugin; bundled packages are always +/// enabled and reject the call. +#[tauri::command] +pub fn declarative_plugins_set_enabled( + app: AppHandle, + plugin_id: String, + enabled: bool, +) -> Result<(), String> { + let install_root = store::install_root(&app)?; + let record_path = install_root.join(&plugin_id).join("installation.json"); + let mut record: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(&record_path) + .map_err(|error| format!("{plugin_id} has no readable install record: {error}"))?, + ) + .map_err(|error| format!("{plugin_id} has a corrupted install record: {error}"))?; + record.enabled = enabled; + // Write via temp + rename so a crash never leaves a truncated record. + let temp_path = record_path.with_extension("json.tmp"); + std::fs::write( + &temp_path, + serde_json::to_string_pretty(&record).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + std::fs::rename(&temp_path, &record_path).map_err(|error| error.to_string())?; + rescan_after_change(&app); + Ok(()) +} + +/// Restores the previous version directory of an installed package. Rollback +/// requires a retained previous version and always re-enables the package so +/// a broken update cannot leave it disabled and forgotten. +#[tauri::command] +pub fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result<(), String> { + let install_root = store::install_root(&app)?; + let plugin_directory = install_root.join(&plugin_id); + let record: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(plugin_directory.join("installation.json")) + .map_err(|error| format!("{plugin_id} has no readable install record: {error}"))?, + ) + .map_err(|error| error.to_string())?; + let mut versions: Vec = std::fs::read_dir(&plugin_directory) + .map_err(|error| error.to_string())? + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) + }) + .collect(); + versions.sort_by(|left, right| store::compare_versions(left, right)); + versions.reverse(); + let Some(previous) = versions + .into_iter() + .find(|version| *version != record.version) + else { + return Err(format!( + "{plugin_id} has no previous version to roll back to" + )); + }; + staging_install( + &install_root, + &plugin_id, + &previous, + &plugin_directory.join(&previous), + )?; + write_installation_record(&install_root, &plugin_id, &previous)?; + rescan_after_change(&app); + Ok(()) +} + +// MARK: helpers + +fn review_snapshot( + manifest: &serde_json::Value, + approved_programs: Option<&[serde_json::Value]>, +) -> Result { + let outcome = core_execute( + "plugins.marketplace.installReview", + serde_json::json!({ + "manifest": manifest, + "hostCapabilities": super::host_capabilities(), + "approvedPrograms": approved_programs, + }), + )?; + if outcome["status"] == serde_json::json!("rejected") { + return Err(format!( + "install review rejected: {}", + outcome["error"]["message"].as_str().unwrap_or("unknown") + )); + } + let review = &outcome["review"]; + Ok(InstallReview { + plugin_id: review["id"].as_str().unwrap_or_default().to_string(), + version: review["version"].as_str().unwrap_or_default().to_string(), + permissions: string_list(&review["permissions"]), + programs: review["programs"].as_array().cloned().unwrap_or_default(), + new_programs: review["newPrograms"] + .as_array() + .cloned() + .unwrap_or_default(), + }) +} + +/// Copies the snapshot into the install root, then validates the copied +/// manifest. Staging first keeps a failed copy from destroying the current +/// version: the active version directory is only replaced after the staged +/// copy validates. +pub(crate) fn staging_install( + install_root: &Path, + plugin_id: &str, + version: &str, + source: &Path, +) -> Result<(), String> { + let plugin_directory = install_root.join(plugin_id); + std::fs::create_dir_all(&plugin_directory).map_err(|error| error.to_string())?; + let version_directory = plugin_directory.join(version); + let staging_directory = plugin_directory.join(format!(".staging-{version}")); + if staging_directory.exists() { + std::fs::remove_dir_all(&staging_directory).map_err(|error| error.to_string())?; + } + copy_directory_snapshot(source, &staging_directory); + let result = staging_validate_and_activate(&staging_directory, &version_directory); + if result.is_err() { + // A failed install never leaves staging bytes behind. + let _ = std::fs::remove_dir_all(&staging_directory); + } + result +} + +fn staging_validate_and_activate( + staging_directory: &Path, + version_directory: &Path, +) -> Result<(), String> { + let staged_manifest = read_snapshot_manifest(staging_directory)?; + validate_manifest(&staged_manifest).map_err(|rejection| rejection.message)?; + if version_directory.exists() { + std::fs::remove_dir_all(version_directory).map_err(|error| error.to_string())?; + } + std::fs::rename(staging_directory, version_directory) + .map_err(|error| format!("activation failed: {error}"))?; + Ok(()) +} + +pub(crate) fn write_installation_record( + install_root: &Path, + plugin_id: &str, + version: &str, +) -> Result<(), String> { + let record_path = install_root.join(plugin_id).join("installation.json"); + let record = InstallationRecord { + schema_version: 1, + plugin_id: plugin_id.to_string(), + version: version.to_string(), + enabled: true, + }; + std::fs::create_dir_all(record_path.parent().ok_or("record path")?) + .map_err(|error| error.to_string())?; + std::fs::write( + &record_path, + serde_json::to_string_pretty(&record).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn read_snapshot_manifest(snapshot: &Path) -> Result { + let text = std::fs::read_to_string(snapshot.join("plugin.json")) + .map_err(|error| format!("the package has no readable plugin.json: {error}"))?; + serde_json::from_str(&text).map_err(|error| format!("plugin.json is not valid JSON: {error}")) +} + +fn content_digest(manifest: &serde_json::Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(manifest.to_string().as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Copies a directory tree, refusing reparse points and files beyond the +/// package budgets. The copy is the safety boundary: everything reviewed and +/// installed afterwards comes from this snapshot, not the mutable source. +pub(crate) fn copy_directory_snapshot(source: &Path, snapshot: &Path) -> Result<(), String> { + copy_tree_bounded(source, source, snapshot, 0) +} + +fn copy_tree_bounded( + root: &Path, + directory: &Path, + snapshot: &Path, + depth: usize, +) -> Result<(), String> { + if depth > 64 { + return Err("the package directory nesting exceeds 64 levels".to_string()); + } + let relative = directory + .strip_prefix(root) + .map_err(|error| error.to_string())?; + let target = snapshot.join(relative); + std::fs::create_dir_all(&target).map_err(|error| error.to_string())?; + for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let entry_path = entry.path(); + let metadata = std::fs::symlink_metadata(&entry_path) + .map_err(|error| format!("{} is unreadable: {error}", entry_path.display()))?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x0400 != 0 { + return Err(format!( + "{} is a reparse point; package entries must be regular files", + entry_path.display() + )); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(format!( + "{} is a symbolic link; package entries must be regular files", + entry_path.display() + )); + } + if metadata.is_dir() { + copy_tree_bounded(root, &entry_path, snapshot, depth + 1)?; + } else if metadata.is_file() { + if metadata.len() > MAX_PACKAGE_FILE_BYTES { + return Err(format!( + "{} exceeds the {} MiB per-file limit", + entry_path.display(), + MAX_PACKAGE_FILE_BYTES / (1024 * 1024) + )); + } + std::fs::copy(&entry_path, &target.join(entry.file_name())).map_err(|error| { + format!("{} could not be copied: {error}", entry_path.display()) + })?; + } + } + Ok(()) +} + +pub(crate) fn extract_zip_snapshot(source: &Path, snapshot: &Path) -> Result<(), String> { + let file = std::fs::File::open(source) + .map_err(|error| format!("{} could not be opened: {error}", source.display()))?; + let mut archive = zip::ZipArchive::new(file).map_err(|error| { + format!( + "{} is not a readable ZIP archive: {error}", + source.display() + ) + })?; + if archive.len() > MAX_PACKAGE_ENTRIES { + return Err("the archive contains too many entries".to_string()); + } + let mut total_bytes: u64 = 0; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("archive entry {index} is unreadable: {error}"))?; + let Some(name) = entry.enclosed_name().map(|path| path.to_path_buf()) else { + return Err(format!("archive entry {index} has an unsafe path")); + }; + if entry.is_dir() { + std::fs::create_dir_all(snapshot.join(&name)).map_err(|error| error.to_string())?; + continue; + } + if entry.size() > MAX_PACKAGE_FILE_BYTES { + return Err(format!( + "archive entry {} exceeds the per-file size limit", + name.display() + )); + } + total_bytes += entry.size(); + if total_bytes > MAX_PACKAGE_TOTAL_BYTES { + return Err("the archive exceeds the total decompressed size limit".to_string()); + } + let target = snapshot.join(&name); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let mut output = std::fs::File::create(&target).map_err(|error| error.to_string())?; + std::io::copy(&mut entry, &mut output).map_err(|error| error.to_string())?; + } + // A ZIP that unpacked into a single wrapper directory still yields a + // package root containing plugin.json directly. + unwrap_single_directory(snapshot); + read_snapshot_manifest(snapshot)?; + Ok(()) +} + +/// If the archive wrapped everything in one directory, treat that directory +/// as the package root. +fn unwrap_single_directory(snapshot: &Path) { + let Ok(entries) = std::fs::read_dir(snapshot) else { + return; + }; + let entries: Vec<_> = entries.flatten().collect(); + if entries.len() == 1 && entries[0].path().is_dir() { + let inner = entries[0].path(); + if inner.join("plugin.json").is_file() { + let _ = move_directory_contents(&inner, snapshot); + } + } +} + +fn move_directory_contents(from: &Path, to: &Path) -> std::io::Result<()> { + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.path().is_dir() { + if std::fs::rename(entry.path(), &target).is_err() { + copy_directory_snapshot(&entry.path(), &target).map_err(std::io::Error::other)?; + } + } else { + std::fs::rename(entry.path(), &target)?; + } + } + std::fs::remove_dir_all(from) +} + +fn string_list(value: &serde_json::Value) -> Vec { + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn rescan_after_change(app: &AppHandle) { + if let Ok(packages) = super::scan::build_catalog(app) { + app.state::().set_packages(packages); + } +} + +fn catalog_entry_of(app: &AppHandle, plugin_id: &str) -> Option { + app.state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == plugin_id) + .map(|package| package.entry) +} + +fn accepted_entry_fallback(plugin_id: &str, version: &str) -> CatalogEntry { + CatalogEntry { + plugin_id: plugin_id.to_string(), + display_name: plugin_id.to_string(), + version: version.to_string(), + api_version: 0, + source: "installed", + enabled: true, + status: "accepted", + languages: Vec::new(), + rejection: None, + } +} + +fn discard_snapshot(snapshot: &Path) { + let _ = std::fs::remove_dir_all(snapshot); +} + +/// Time- and process-derived suffix; uniqueness across sessions is what the +/// snapshot directory needs, not cryptographic randomness. +fn uuid_like_suffix() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("{}-{:x}", std::process::id(), nanos) +} + +/// Removes snapshots left behind by a crashed review session at startup. +pub fn cleanup_stale_snapshots(app: &AppHandle) { + let Ok(temporary_root) = app + .path() + .app_local_data_dir() + .map(|root| root.join("plugin-temp")) + else { + return; + }; + let Ok(entries) = std::fs::read_dir(&temporary_root) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(SNAPSHOT_PREFIX) { + let _ = std::fs::remove_dir_all(entry.path()); + } + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs new file mode 100644 index 0000000000..0949bcca20 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs @@ -0,0 +1,166 @@ +/// M2 disk-import tests exercise the internal functions directly (the Tauri +/// commands are thin AppHandle wrappers): snapshot isolation, ZIP safety, +/// staging install, and rollback version selection. +mod install_tests { + use crate::plugin_host::install::{ + copy_directory_snapshot, extract_zip_snapshot, staging_install, write_installation_record, + }; + use crate::plugin_host::store; + use crate::plugin_host::store::InstallationRecord; + use crate::plugin_host::tests::read_fixture_for_tests; + use std::path::PathBuf; + + fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-install-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + fn package_directory(root: &std::path::Path, version: &str) -> PathBuf { + let directory = root.join(format!("pkg-{version}")); + std::fs::create_dir_all(&directory).expect("package dir"); + let mut manifest = read_fixture_for_tests("php-support.plugin.json"); + manifest["version"] = serde_json::json!(version); + std::fs::write( + directory.join("plugin.json"), + serde_json::to_string_pretty(&manifest).expect("manifest text"), + ) + .expect("manifest file"); + let server_directory = directory.join("language-server").join("bin"); + std::fs::create_dir_all(&server_directory).expect("server dir"); + std::fs::write(server_directory.join("intelephense.js"), b"entry").expect("entry file"); + directory + } + + #[test] + fn directory_snapshot_copies_contents() { + let root = unique_temp_root("snapshot"); + let package = package_directory(&root, "1.0.0"); + let snapshot = root.join("snapshot"); + copy_directory_snapshot(&package, &snapshot).expect("snapshot copies"); + assert!(snapshot.join("plugin.json").is_file()); + assert!(snapshot + .join("language-server/bin/intelephense.js") + .is_file()); + + // A symlink inside the source must abort the copy when the platform + // lets the test create one. + #[cfg(unix)] + { + let link = package.join("linked.js"); + if std::os::unix::fs::symlink(package.join("plugin.json"), &link).is_ok() { + let hostile = root.join("snapshot-hostile"); + assert!(copy_directory_snapshot(&package, &hostile).is_err()); + } + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn zip_snapshot_extracts_and_rejects_unsafe_paths() { + let root = unique_temp_root("zip"); + let package = package_directory(&root, "1.0.0"); + let zip_path = root.join("pkg.zip"); + let zip_file = std::fs::File::create(&zip_path).expect("zip file"); + let mut writer = zip::ZipWriter::new(zip_file); + let options: zip::write::SimpleFileOptions = Default::default(); + writer + .add_directory("wrapper/", options) + .expect("wrapper dir"); + for relative in ["plugin.json", "language-server/bin/intelephense.js"] { + let source = package.join(relative); + let text = std::fs::read(&source).expect("entry bytes"); + writer + .start_file(format!("wrapper/{relative}"), options) + .expect("entry"); + std::io::Write::write_all(&mut writer, &text).expect("entry bytes"); + } + writer.finish().expect("zip finish"); + let snapshot = root.join("snapshot"); + extract_zip_snapshot(&zip_path, &snapshot).expect("extracts"); + assert!(snapshot.join("plugin.json").is_file()); + + // A traversal entry name is refused before anything is written. + let hostile_zip = root.join("hostile.zip"); + let hostile_file = std::fs::File::create(&hostile_zip).expect("hostile zip"); + let mut hostile_writer = zip::ZipWriter::new(hostile_file); + hostile_writer + .start_file("../escape.txt", options) + .expect("hostile entry"); + std::io::Write::write_all(&mut hostile_writer, b"nope").expect("hostile bytes"); + hostile_writer.finish().expect("zip finish"); + let hostile_snapshot = root.join("snapshot-hostile"); + assert!(extract_zip_snapshot(&hostile_zip, &hostile_snapshot).is_err()); + assert!(!root.join("escape.txt").exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn staging_install_replaces_version_and_rejects_broken_manifests() { + let root = unique_temp_root("staging"); + let install_root = root.join("plugins"); + let good = package_directory(&root, "1.0.0"); + staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); + assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + write_installation_record(&install_root, "acme.php", "1.0.0").expect("record"); + + // A broken package fails validation and leaves the active version. + let broken = root.join("pkg-broken"); + std::fs::create_dir_all(&broken).expect("broken dir"); + std::fs::write(broken.join("plugin.json"), "{ not json").expect("broken manifest"); + assert!(staging_install(&install_root, "acme.php", "2.0.0", &broken).is_err()); + assert!(!install_root.join("acme.php/2.0.0").exists()); + assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + // The staging directory is cleaned up on failure. + assert!(!install_root.join("acme.php/.staging-2.0.0").exists()); + + // A successful upgrade keeps both versions for rollback. + let upgrade = package_directory(&root, "2.0.0"); + staging_install(&install_root, "acme.php", "2.0.0", &upgrade).expect("upgrade"); + assert!(install_root.join("acme.php/1.0.0/plugin.json").is_file()); + assert!(install_root.join("acme.php/2.0.0/plugin.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rollback_picks_the_highest_previous_version() { + // Mirrors the selection logic of declarative_plugins_rollback without + // an AppHandle: enumerate versions, pick highest != current. + let root = unique_temp_root("rollback"); + let install_root = root.join("plugins"); + for version in ["1.0.0", "1.5.0", "2.0.0"] { + let package = package_directory(&root, version); + staging_install(&install_root, "acme.php", version, &package).expect("installs"); + } + write_installation_record(&install_root, "acme.php", "2.0.0").expect("record"); + let plugin_directory = install_root.join("acme.php"); + let mut versions: Vec = std::fs::read_dir(&plugin_directory) + .expect("plugin dir") + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + (path.is_dir() && path.join("plugin.json").is_file()).then_some(name) + }) + .collect(); + versions.sort_by(|left, right| store::compare_versions(left, right)); + versions.reverse(); + let record: InstallationRecord = serde_json::from_str( + &std::fs::read_to_string(plugin_directory.join("installation.json")).expect("record"), + ) + .expect("record parses"); + let previous = versions + .into_iter() + .find(|version| *version != record.version) + .expect("previous version"); + assert_eq!(previous, "1.5.0"); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index 4fa9d9d390..66bf099106 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -184,6 +184,7 @@ fn resolve_launch( format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")), path.to_string_lossy().to_string(), ); + verify_runtime_version(&path, tool, launch)?; path } other => return Err(format!("unsupported launch kind {other}")), @@ -201,6 +202,75 @@ fn resolve_launch( }) } +/// Runs the tool's version probe and rejects a runtime older than the plan's +/// declared `minimumVersion`. Probes run at every launch because the PATH +/// candidate can change between sessions; a failing or unreadable probe is a +/// rejection, not a fallback. +fn verify_runtime_version(path: &Path, tool: &str, launch: &Value) -> Result<(), String> { + let Some(minimum) = launch["minimumVersion"].as_str() else { + return Ok(()); + }; + let version_arguments: Vec = launch["versionArguments"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .ok_or_else(|| { + format!("runtime launch declares minimumVersion without versionArguments") + })?; + let output = std::process::Command::new(path) + .args(&version_arguments) + .output() + .map_err(|error| format!("runtime tool {tool} could not be executed: {error}"))?; + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let reported = first_version_token(&text) + .ok_or_else(|| format!("runtime tool {tool} did not report a parsable version"))?; + if store::compare_versions(&reported, minimum) == std::cmp::Ordering::Less { + return Err(format!( + "runtime tool {tool} reports {reported}, but the plugin requires at least {minimum}" + )); + } + Ok(()) +} + +/// First `N(.N)*` token in a version output line; tolerates the `v` prefix +/// node uses and the multi-line `java -version` output. +fn first_version_token(text: &str) -> Option { + for line in text.lines() { + for token in line.split_whitespace() { + // Strip quotes and any non-digit prefix (`v20.11.0`, + // `go1.21.5`, `"17.0.2"`). + let token = token + .trim_matches(['"', '\'']) + .trim_start_matches(|character: char| !character.is_ascii_digit()); + let mut candidate = String::new(); + for character in token.chars() { + if character.is_ascii_digit() || character == '.' { + candidate.push(character); + } else { + break; + } + } + // At least two chunks with a digit each make a version. + if candidate.matches('.').count() >= 1 + && candidate + .split('.') + .any(|chunk| chunk.parse::().is_ok()) + { + return Some(candidate); + } + } + } + None +} + /// Joins a package-relative plan path and proves it stays inside the package /// as a regular file. Every launch the host executes passes through here. fn verified_packaged_path(root: &Path, relative: &str) -> Result { @@ -335,6 +405,26 @@ mod tests { ); } + #[test] + fn first_version_token_parses_real_tool_outputs() { + assert_eq!( + first_version_token( + "v20.11.0 +" + ), + Some("20.11.0".to_string()) + ); + assert_eq!( + first_version_token("openjdk version \"17.0.2\" 2022-01-18"), + Some("17.0.2".to_string()) + ); + assert_eq!( + first_version_token("go version go1.21.5 windows/amd64"), + Some("1.21.5".to_string()) + ); + assert_eq!(first_version_token("no version here"), None); + } + #[test] fn version_comparison_is_numeric_per_chunk() { assert_eq!( diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 5ff4f1feda..5b5bac0d23 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -6,8 +6,11 @@ //! currently accepts, and how a Core launch plan becomes a process command //! line. The boundary with the Worker extension model and the staged //! capability rollout are recorded in -//! .agents/notes/proposed/architecture/2026-10-22-windows-declarative-plugin-host.md +//! .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +pub mod install; +#[cfg(test)] +mod install_tests; pub mod launch; pub mod scan; pub mod store; diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs index fc912a189d..956573843a 100644 --- a/windows/tauri/src-tauri/src/plugin_host/tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -322,3 +322,8 @@ fn scan_package_at( record_problem, }) } + +/// Fixture reader shared with the install test module. +pub(crate) fn read_fixture_for_tests(name: &str) -> serde_json::Value { + read_fixture(name) +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts index d751f47a4d..126227b2ff 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -67,3 +67,57 @@ export function resolveDeclarativePluginLaunch( workspacePath, }); } + +/** Core's install review for one prepared package. */ +export interface DeclarativePluginInstallReview { + pluginId: string; + version: string; + permissions: string[]; + /** Full canonical program declarations, including arguments and templates. */ + programs: unknown[]; + newPrograms: unknown[]; +} + +/** A prepared disk import awaiting user confirmation. */ +export interface PreparedDeclarativeInstall { + sessionId: string; + review: DeclarativePluginInstallReview; + /** Digest the confirm call must present to prove the package is unchanged. */ + manifestDigest: string; + displayName: string; +} + +/** Prepares a disk import from a .zip archive or unpacked package directory. */ +export function prepareDeclarativeDiskInstall( + sourcePath: string, +): Promise { + return invoke("declarative_plugins_prepare_disk_install", { + sourcePath, + }); +} + +/** Installs a confirmed snapshot; rejects when the package changed after review. */ +export function confirmDeclarativeDiskInstall( + sessionId: string, + manifestDigest: string, +): Promise { + return invoke("declarative_plugins_confirm_disk_install", { + sessionId, + manifestDigest, + }); +} + +/** Removes an installed package and its state directory. */ +export function uninstallDeclarativePlugin(pluginId: string): Promise { + return invoke("declarative_plugins_uninstall", { pluginId }); +} + +/** Enables or disables an installed package; bundled packages reject this. */ +export function setDeclarativePluginEnabled(pluginId: string, enabled: boolean): Promise { + return invoke("declarative_plugins_set_enabled", { pluginId, enabled }); +} + +/** Restores the highest retained previous version of an installed package. */ +export function rollbackDeclarativePlugin(pluginId: string): Promise { + return invoke("declarative_plugins_rollback", { pluginId }); +} diff --git a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx new file mode 100644 index 0000000000..b84cdc5af4 --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx @@ -0,0 +1,270 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import Switch from "@/ui/switch"; +import { + confirmDeclarativeDiskInstall, + listDeclarativePlugins, + prepareDeclarativeDiskInstall, + rescanDeclarativePlugins, + rollbackDeclarativePlugin, + setDeclarativePluginEnabled, + uninstallDeclarativePlugin, + type DeclarativePluginSummary, + type PreparedDeclarativeInstall, +} from "./declarative-plugin-launch"; + +/** + * Management surface for contract v1 declarative plugins. This block is + * intentionally separate from the Worker extension browser: the two models + * have different trust properties (contract packages run only host-launched + * processes reviewed through Core's install review), so mixing them in one + * list would hide that difference from the user. + */ +export function DeclarativePluginsSection() { + const { t } = useTranslation(); + const [plugins, setPlugins] = useState([]); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + await rescanDeclarativePlugins(); + setPlugins(await listDeclarativePlugins()); + }, []); + + useEffect(() => { + refresh().catch((reason: unknown) => setError(String(reason))); + }, [refresh]); + + const importPackage = async () => { + const selection = await import("@tauri-apps/plugin-dialog").then(({ open }) => + open({ + multiple: false, + filters: [ + { name: "Lithe plugin package", extensions: ["zip"] }, + ], + }), + ); + if (typeof selection !== "string") return; + setBusy(true); + setError(null); + try { + setPending(await prepareDeclarativeDiskInstall(selection)); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + }; + + const confirmInstall = async () => { + if (!pending) return; + setBusy(true); + setError(null); + try { + await confirmDeclarativeDiskInstall(pending.sessionId, pending.manifestDigest); + setPending(null); + await refresh(); + } catch (reason) { + setError(String(reason)); + setPending(null); + } finally { + setBusy(false); + } + }; + + return ( +
+ void importPackage()}> + {t("settings.declarativePlugins.import")} + + } + /> + {error && ( +

+ {error} +

+ )} + {pending && ( + void confirmInstall()} busy={busy} /> + )} +
    + {plugins.map((plugin) => ( + void refresh()} + /> + ))} + {!plugins.length && ( +
  • + {t("settings.declarativePlugins.empty")} +
  • + )} +
+
+ ); +} + +function InstallReviewCard({ + pending, + onConfirm, + busy, +}: { + pending: PreparedDeclarativeInstall; + onConfirm: () => void; + busy: boolean; +}) { + const { t } = useTranslation(); + return ( +
+

+ {t("settings.declarativePlugins.reviewTitle", { + name: pending.displayName, + version: pending.review.version, + })} +

+

+ {t("settings.declarativePlugins.unverified")} +

+ {pending.review.permissions.length > 0 && ( +
    + {pending.review.permissions.map((permission) => ( +
  • {permission}
  • + ))} +
+ )} + {pending.review.programs.length > 0 && ( + + )} +
+ +
+
+ ); +} + +function ProgramDeclarationList({ programs }: { programs: unknown[] }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + return ( +
+ + {expanded && ( +
+          {JSON.stringify(programs, null, 2)}
+        
+ )} +
+ ); +} + +function DeclarativePluginRow({ + plugin, + onChanged, +}: { + plugin: DeclarativePluginSummary; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const rejected = plugin.status === "rejected"; + + const run = async (action: () => Promise) => { + setBusy(true); + setError(null); + try { + await action(); + onChanged(); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + }; + + return ( +
  • +
    +

    + {plugin.displayName}{" "} + + {plugin.version} · {t(`settings.declarativePlugins.source.${plugin.source}`)} + +

    + {rejected && plugin.rejection && ( +

    + {plugin.rejection.code}: {plugin.rejection.message} +

    + )} + {error &&

    {error}

    } +
    + {!rejected && plugin.source === "installed" && ( + <> + + void run(() => setDeclarativePluginEnabled(plugin.pluginId, checked)) + } + aria-label={t("settings.declarativePlugins.toggle", { name: plugin.displayName })} + /> + + + + )} +
  • + ); +} + +function SettingsSectionGroup({ + title, + description, + action, +}: { + title: string; + description: string; + action?: React.ReactNode; +}) { + return ( +
    +
    +

    {title}

    +

    {description}

    +
    + {action} +
    + ); +} diff --git a/windows/tauri/src/features/settings/components/settings-hierarchy.test.tsx b/windows/tauri/src/features/settings/components/settings-hierarchy.test.tsx index 1b7a7a823a..35cb54ab7e 100644 --- a/windows/tauri/src/features/settings/components/settings-hierarchy.test.tsx +++ b/windows/tauri/src/features/settings/components/settings-hierarchy.test.tsx @@ -75,10 +75,19 @@ test("every settings page belongs to exactly one group or is a top-level page, i ]); expect( settingsSidebarLayout.flatMap((entry) => (entry.type === "page" ? [entry.id] : [])), - ).toEqual(["plugins"]); + ).toEqual(["plugins", "declarative-plugins"]); expect( settingsSidebarLayout.map((entry) => (entry.type === "group" ? entry.group.id : entry.id)), - ).toEqual(["appearance", "plugins", "version-control", "build", "languages", "tools", "ai"]); + ).toEqual([ + "appearance", + "plugins", + "declarative-plugins", + "version-control", + "build", + "languages", + "tools", + "ai", + ]); for (const language of ["en-US", "zh-CN"] as const) { const t = createTranslator(language); expect(settingsGroups.map(({ labelKey }) => t(labelKey))).toEqual( diff --git a/windows/tauri/src/features/settings/components/settings-panels.tsx b/windows/tauri/src/features/settings/components/settings-panels.tsx index 2120a669b5..5a8c22c8b4 100644 --- a/windows/tauri/src/features/settings/components/settings-panels.tsx +++ b/windows/tauri/src/features/settings/components/settings-panels.tsx @@ -57,6 +57,7 @@ export type SettingsCategory = | "editor" | "keyboard" | "plugins" + | "declarative-plugins" | "terminal" | "lsp" | "maven" @@ -71,6 +72,12 @@ const PluginsPanel = lazy(() => })), ); +const DeclarativePluginsPanel = lazy(() => + import("@/features/plugins/declarative-plugins-section").then( + ({ DeclarativePluginsSection }) => ({ default: DeclarativePluginsSection }), + ), +); + function GeneralPanel() { const { t } = useTranslation(); const settings = useSettingsStore((state) => state.settings); @@ -614,6 +621,12 @@ export function SettingsPanel({ ); + case "declarative-plugins": + return ( + + + + ); case "terminal": return ; case "lsp": diff --git a/windows/tauri/src/features/settings/lib/settings-navigation.ts b/windows/tauri/src/features/settings/lib/settings-navigation.ts index 1d70c977b3..e343b6fb4e 100644 --- a/windows/tauri/src/features/settings/lib/settings-navigation.ts +++ b/windows/tauri/src/features/settings/lib/settings-navigation.ts @@ -61,6 +61,7 @@ export const settingsSidebarLayout: ReadonlyArray< > = [ { type: "group", group: appearanceGroup }, { type: "page", id: "plugins" }, + { type: "page", id: "declarative-plugins" }, { type: "group", group: versionControlGroup }, { type: "group", group: buildGroup }, { type: "group", group: languagesGroup }, @@ -141,6 +142,18 @@ export const settingsCategories: Array<{ "settings.mac.layout", ], }, + { + id: "declarative-plugins", + labelKey: "settings.declarativePlugins.title", + terms: [ + "Declarative plugins", + "Contract v1", + "插件包", + "声明式插件", + "settings.declarativePlugins.title", + "settings.declarativePlugins.import", + ], + }, { id: "plugins", labelKey: "settings.tabs.plugins", diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index a22d0f452f..ca72df52f2 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -2082,6 +2082,19 @@ const catalogs = { "settings.groups.ai": "AI", "settings.tabs.plugins": "Plugins", "settings.noResults": "No settings found", + "settings.declarativePlugins.title": "Declarative plugins (contract v1)", + "settings.declarativePlugins.description": "Packages that declare processes; the host launches them after an install review. Themes and Worker extensions live in the Extensions browser.", + "settings.declarativePlugins.import": "Import package…", + "settings.declarativePlugins.reviewTitle": "Install {{name}} {{version}}?", + "settings.declarativePlugins.unverified": "Unverified publisher: this package has no signature. Its permissions and programs are shown below.", + "settings.declarativePlugins.confirmInstall": "Install", + "settings.declarativePlugins.programDeclaration": "Program declaration ({{count}})", + "settings.declarativePlugins.empty": "No declarative plugins installed.", + "settings.declarativePlugins.toggle": "Enable or disable {{name}}", + "settings.declarativePlugins.rollback": "Roll back", + "settings.declarativePlugins.uninstall": "Uninstall", + "settings.declarativePlugins.source.bundled": "bundled", + "settings.declarativePlugins.source.installed": "installed", "settings.resizeCategories": "Resize settings categories", "settings.resizePlugins": "Resize plugin list", "settings.noMatching": "No settings found", @@ -6912,6 +6925,19 @@ const catalogs = { "settings.groups.ai": "AI", "settings.tabs.plugins": "插件", "settings.noResults": "未找到设置", + "settings.declarativePlugins.title": "声明式插件(契约 v1)", + "settings.declarativePlugins.description": "以声明方式提供进程的插件包,宿主在安装审查后代为拉起。主题与 Worker 扩展请在扩展浏览器中管理。", + "settings.declarativePlugins.import": "导入包…", + "settings.declarativePlugins.reviewTitle": "安装 {{name}} {{version}}?", + "settings.declarativePlugins.unverified": "未认证发布者:该包没有签名。其权限与程序声明如下。", + "settings.declarativePlugins.confirmInstall": "安装", + "settings.declarativePlugins.programDeclaration": "程序声明({{count}} 项)", + "settings.declarativePlugins.empty": "尚未安装声明式插件。", + "settings.declarativePlugins.toggle": "启用或停用 {{name}}", + "settings.declarativePlugins.rollback": "回滚", + "settings.declarativePlugins.uninstall": "卸载", + "settings.declarativePlugins.source.bundled": "内置", + "settings.declarativePlugins.source.installed": "已安装", "settings.resizeCategories": "调整设置分类栏宽度", "settings.resizePlugins": "调整插件列表宽度", "settings.noMatching": "未找到设置", From 3217d7c6c3b62da70da62749768dae2bb893de2b Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 18:55:51 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat(windows):=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E6=8F=92=E4=BB=B6=E5=AE=BF=E4=B8=BB=20M3=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=A3=B0=E6=98=8E=E5=BC=8F=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E3=80=81=E8=BF=90=E8=A1=8C=E8=AE=A1=E5=88=92=E4=B8=8E=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=20PHP=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 能力声明提升:languageServers [1,2]、settings [1,2],settings-v2 fixture 由能力门控改为接受 - plugin_host/settings.rs:全局值(plugin-settings/.json,默认值遗忘规则) 与项目覆盖(工作区 .lithe/plugin-settings.json,空项清理、损坏拒写)双层 存储;resolveSettings / storeSetting / workspaceConfiguration 三个命令; launch 请求现携带已存设置 - plugin_host/run_plans.rs:runPlan/testPlan 命令,展开 Core 模板后走共享 run 链路(resolveRunLaunch → startRunProcess),进程归属复用 Worker 扩展 的 ExtensionProcessOwner - 设置编辑 UI:按声明 scope/effect 渲染控件,显示值来源徽标,immediate 效果经 workspaceConfiguration 下推;show/hide 折叠进插件行 - Plugins/win/Official/PhpSupportContract:契约版 PHP bundled 包(API 2, launch kind runtime),随安装器资源分发;端到端测试覆盖 validate、 runPlan、testPlan、resolveSettings。Worker 版 PHP 保留:其 composer scripts 动态运行动作在契约 v1 无等价能力,删除会造成功能退化,完整 迁移推迟(Note 已记录) - 矩阵 declarative-plugin-settings Windows partial、declarative-plugin-contract 证据与 notes 更新;Note 增补 M3 记录 --- ...6-10-22-windows-declarative-plugin-host.md | 17 +- .../win/Official/PhpSupportContract/README.md | 11 + .../language-server/bin/intelephense.js | 1 + .../Official/PhpSupportContract/plugin.json | 94 ++++++++ .../features/declarative-plugin-contract.json | 5 +- .../features/declarative-plugin-settings.json | 7 +- windows/tauri/src-tauri/src/main.rs | 5 + .../src-tauri/src/plugin_host/install.rs | 2 + .../tauri/src-tauri/src/plugin_host/launch.rs | 29 ++- .../tauri/src-tauri/src/plugin_host/mod.rs | 13 +- .../src-tauri/src/plugin_host/run_plans.rs | 111 +++++++++ .../tauri/src-tauri/src/plugin_host/scan.rs | 8 + .../src-tauri/src/plugin_host/settings.rs | 214 ++++++++++++++++++ .../tauri/src-tauri/src/plugin_host/tests.rs | 165 +++++++++++++- windows/tauri/src-tauri/tauri.conf.json | 1 + .../plugins/declarative-plugin-launch.ts | 114 ++++++++++ .../declarative-plugin-process-owner.ts | 8 + .../plugins/declarative-plugin-run.ts | 151 ++++++++++++ .../declarative-plugin-settings-editor.tsx | 184 +++++++++++++++ .../plugins/declarative-plugins-section.tsx | 95 +++++--- windows/tauri/src/i18n/locale.ts | 26 +++ 21 files changed, 1211 insertions(+), 50 deletions(-) create mode 100644 Plugins/win/Official/PhpSupportContract/README.md create mode 100644 Plugins/win/Official/PhpSupportContract/language-server/bin/intelephense.js create mode 100644 Plugins/win/Official/PhpSupportContract/plugin.json create mode 100644 windows/tauri/src-tauri/src/plugin_host/run_plans.rs create mode 100644 windows/tauri/src-tauri/src/plugin_host/settings.rs create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-process-owner.ts create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-run.ts create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index c645ed955c..1a6391dd84 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -4,7 +4,7 @@ ## 先说结论 -Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;M2 已补磁盘导入安装链(快照、`installReview` 全量审批、staging 复验、卸载/回滚/启停)、设置内独立管理页和 runtime 启动的 `minimumVersion` 探测。能力声明只开放 `languages:1`、`languageServers:1`、`serverProcess:1`,声明了更高能力的插件包由 Core 自动拒绝。 +Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;M2 已补磁盘导入安装链、设置内独立管理页和 runtime 启动的 `minimumVersion` 探测;M3 已补声明式设置(`languageServers` 2 / `settings` 2 能力声明、双层存储、`immediate` 效果下推)、运行/测试计划命令与契约版 PHP bundled 包。能力声明开放 `languages:1`、`languageServers:[1,2]`、`serverProcess:1`、`settings:[1,2]`,其余能力(`executionProviders`、`buildProviders`、`buildSystems`、`ui`)仍由 Core 自动拒绝。 ## 问题 @@ -24,7 +24,7 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 ### 能力声明渐进开放 -宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1], serverProcess:[1] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 +宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1,2], serverProcess:[1], settings:[1,2] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 ### 存储、信任与安装(v0) @@ -45,13 +45,13 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 ### 与 Worker 扩展模型共存 - 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 -- PhpSupport 迁移:M3 用契约包替换 Worker 包,保留用户启用状态后移除 Worker 版本。 +- PhpSupport 迁移(M3 调整):契约版 PHP 包(`Plugins/win/Official/PhpSupportContract`,`sameTeamAsHost` bundled)随安装器分发并完成 validate/launch/runPlan 端到端测试,但 Worker 版**保留不移除**。原因:Worker 版提供 composer scripts 的动态运行动作发现(读取工作区 composer.json 生成动作列表),契约 v1 的 `executionProviders` 是静态声明,没有等价能力;删除 Worker 版会丢失用户可见功能。完整迁移等契约补齐动态运行动作后再做。两包并存时 Worker 版优先(Worker 扩展清单先解析),契约包不重复注册语言。 - `install_extension_from_url` 等 Worker 非语言市场命令(M2 已处置):在 Rust 宿主注册为显式占位错误(`extensionStore.notAvailable`),调用方得到可诊断的结构化失败而不是"命令不存在";完整落盘后端是独立工作项,不与契约宿主混淆。 - 管理界面按模型分组展示两类扩展并使用不同文案,避免用户把 Worker 扩展当成契约插件。 ### 实施切分 -M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包替换;M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 +M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 ## 考虑过的备选方案 @@ -70,6 +70,10 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 ## 验证 +- M3 设置链路:`settings_tests`(`plugin_host/tests.rs`)覆盖项目覆盖往返、其他插件条目保留、损坏 store 报错不重置;能力门控测试随声明提升更新(settings-v2 fixture 被接受)。设置编辑 UI(`declarative-plugin-settings-editor.tsx`)按声明的 scope/effect 渲染并显示值来源徽标。 +- M3 运行计划:契约 PHP 包端到端测试(`bundled_php_contract_package_expands_plans`)走真实 Core 命令验证 validate、runPlan(`${file}`/`${userArguments}` 展开)、testPlan(workspace scope + 项目标记)、resolveSettings。 + + - M2 磁盘导入链测试(`windows/tauri/src-tauri/src/plugin_host/install_tests.rs`):目录快照隔离(symlink 拒绝)、ZIP 解包与恶意路径(`../` 逃逸条目)拒绝、staging 安装(坏包失败保留现行版本且清空 staging)、回滚版本选择;runtime 启动的版本探测输出解析(node/java/go 真实输出格式)在 `launch.rs` 测试覆盖。 - 管理界面(`declarative-plugins-section.tsx`)挂在设置的独立一级页,`settings-hierarchy.test.tsx` 的布局断言随导航更新;i18n 提供 en-US 与 zh-CN 全套文案。 @@ -87,6 +91,11 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - `windows/tauri/src-tauri/src/plugin_host/install.rs`(磁盘导入、卸载、回滚、启停) - `windows/tauri/src-tauri/src/extension_store_placeholder.rs`(Worker 扩展落盘命令的显式占位) - `windows/tauri/src/features/plugins/declarative-plugin-launch.ts`(前端宿主 API) +- `windows/tauri/src-tauri/src/plugin_host/settings.rs`(设置双层存储与命令) +- `windows/tauri/src-tauri/src/plugin_host/run_plans.rs`(运行/测试计划命令) +- `windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx`(设置编辑) +- `windows/tauri/src/features/plugins/declarative-plugin-run.ts`(契约插件运行入口) +- `Plugins/win/Official/PhpSupportContract/`(契约版 PHP bundled 包) - `windows/tauri/src/features/plugins/declarative-plugins-section.tsx`(设置内管理页) - `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts`(契约插件回落来源) - `windows/tauri/src/features/bootstrap/bootstrap-async.ts`(启动扫描步骤) diff --git a/Plugins/win/Official/PhpSupportContract/README.md b/Plugins/win/Official/PhpSupportContract/README.md new file mode 100644 index 0000000000..5b88d8b49f --- /dev/null +++ b/Plugins/win/Official/PhpSupportContract/README.md @@ -0,0 +1,11 @@ +# PhpSupportContract + +契约 v1 形态的 PHP 支持包(bundled 技术验收样本)。它验证 Windows 声明式插件宿主的 +validate → launch plan → run plan 全链路在真实包上可用。 + +与 Worker 版 PhpSupport(`Plugins/win/Official/PhpSupport`)的关系:两包并存, +Worker 版优先(语言注册与 composer scripts 动态运行动作仍由它提供)。契约 v1 尚无 +动态运行动作的等价能力,删除 Worker 版会造成功能退化,完整迁移推迟到契约补齐该能力, +原因记录在 Windows 声明式插件宿主 Note。 + +`language-server/bin/intelephense.js` 是占位入口;真实分发打包 Intelephense 时替换。 diff --git a/Plugins/win/Official/PhpSupportContract/language-server/bin/intelephense.js b/Plugins/win/Official/PhpSupportContract/language-server/bin/intelephense.js new file mode 100644 index 0000000000..a355017465 --- /dev/null +++ b/Plugins/win/Official/PhpSupportContract/language-server/bin/intelephense.js @@ -0,0 +1 @@ +// Bundled Intelephense entry point; see Plugins/win/README.md. diff --git a/Plugins/win/Official/PhpSupportContract/plugin.json b/Plugins/win/Official/PhpSupportContract/plugin.json new file mode 100644 index 0000000000..e4ff37bcfc --- /dev/null +++ b/Plugins/win/Official/PhpSupportContract/plugin.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.plugin.php-support", + "displayName": "PHP Support (contract)", + "version": "1.0.0", + "pluginAPIVersion": 2, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "sameTeamAsHost" + }, + "permissions": [ + "languageServer.launch", + "process.run", + "process.test" + ], + "activationEvents": [ + "onLanguage:php", + "onRun:php", + "onTest:php" + ], + "languages": [ + { + "id": "php", + "displayName": "PHP", + "fileExtensions": [ + "php", + "phtml" + ], + "projectFileNames": [ + "composer.json" + ] + } + ], + "languageServers": [ + { + "languageID": "php", + "languageIdentifier": "php", + "launch": { + "kind": "runtime", + "tool": "node", + "entry": "language-server/bin/intelephense.js" + }, + "arguments": [ + "--stdio" + ] + } + ], + "executionProviders": [ + { + "languageID": "php", + "run": { + "executable": { + "command": "php" + }, + "arguments": [ + "${file}", + "${userArguments}" + ] + }, + "test": { + "frameworkID": "phpunit", + "workspaceLabel": "All PHP Tests", + "projectMarkers": [ + "composer.json", + "phpunit.xml", + "phpunit.xml.dist" + ], + "testFilePatterns": [ + "*test.php" + ], + "executable": { + "command": "php" + }, + "workspaceArguments": [ + "vendor/bin/phpunit" + ], + "fileArguments": [ + "vendor/bin/phpunit", + "${file}" + ], + "testCaseArguments": [ + "vendor/bin/phpunit", + "--filter", + "\b${testName:regex}\b", + { + "value": "${file}", + "when": "fileSelected" + } + ] + } + } + ] +} diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index 9b93c80a88..5283ded035 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -35,6 +35,9 @@ "windows/tauri/src-tauri/src/plugin_host/launch.rs", "windows/tauri/src-tauri/src/plugin_host/tests.rs", "windows/tauri/src-tauri/src/plugin_host/install.rs", + "windows/tauri/src-tauri/src/plugin_host/settings.rs", + "windows/tauri/src-tauri/src/plugin_host/run_plans.rs", + "Plugins/win/Official/PhpSupportContract/plugin.json", "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", @@ -44,6 +47,6 @@ "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面(设置独立页,与 Worker 扩展分组展示);runtime 启动的 minimumVersion 探测在每次启动时执行。尚未实现:Worker 扩展清单向契约迁移、宿主 UI 适配(ui 能力未声明)、设置与运行计划(settings/executionProviders 未声明)、多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、宿主 UI 适配(ui 能力未声明)、多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移与宿主 UI 适配仍未实现,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。" } diff --git a/shared/platform-feature-matrix/features/declarative-plugin-settings.json b/shared/platform-feature-matrix/features/declarative-plugin-settings.json index 5b32e00571..4701c8a9fc 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-settings.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-settings.json @@ -21,9 +21,12 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md" + "windows/tauri/src-tauri/src/plugin_host/settings.rs", + "windows/tauri/src-tauri/src/plugin_host/tests.rs", + "windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx", + "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json" ], - "implementationStatus": "missing", + "implementationStatus": "partial", "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index c35186710d..b09cbcd3ce 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -254,6 +254,11 @@ fn main() { plugin_host::install::declarative_plugins_uninstall, plugin_host::install::declarative_plugins_set_enabled, plugin_host::install::declarative_plugins_rollback, + plugin_host::settings::declarative_plugins_resolve_settings, + plugin_host::settings::declarative_plugins_store_setting, + plugin_host::settings::declarative_plugins_workspace_configuration, + plugin_host::run_plans::declarative_plugins_run_plan, + plugin_host::run_plans::declarative_plugins_test_plan, extension_store_placeholder::install_extension_from_url, extension_store_placeholder::uninstall_extension_new, extension_store_placeholder::get_extension_path, diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index f74063df62..1d9421267b 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -518,6 +518,8 @@ fn accepted_entry_fallback(plugin_id: &str, version: &str) -> CatalogEntry { enabled: true, status: "accepted", languages: Vec::new(), + declared_settings: Vec::new(), + primary_language_id: None, rejection: None, } } diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index 66bf099106..c2f335b36f 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -53,12 +53,14 @@ pub fn declarative_plugins_language_launch( return Ok(None); }; let state_directory = store::plugin_state_directory(&app, &package.entry.plugin_id)?; + let layers = super::settings::load_layers(&app, &package.entry.plugin_id, &workspace_path) + .map_err(|error| format!("plugin settings could not be read: {error}"))?; let plan = super::scan::core_execute( "plugins.declarative.languageServerLaunchPlan", serde_json::json!({ "manifest": package.manifest, "languageID": language_id, - "request": launch_request(&state_directory, &package, &workspace_path), + "request": launch_request(&state_directory, &package, &workspace_path, layers), }), ) .map_err(|error| format!("launch plan failed: {error}"))?; @@ -71,6 +73,18 @@ pub fn declarative_plugins_language_launch( resolve_launch(&package, &language_id, &plan).map(Some) } +/// Finds an accepted, enabled scanned package by plugin ID. +pub(crate) fn package_by_id(app: &AppHandle, plugin_id: &str) -> Option { + app.state::() + .packages() + .into_iter() + .find(|package| { + package.entry.status == "accepted" + && package.entry.enabled + && package.entry.plugin_id == plugin_id + }) +} + fn owning_package(app: &AppHandle, file_path: &str) -> Option { app.state::() .packages() @@ -119,15 +133,20 @@ fn owning_language_id(package: &ScannedPackage, file_path: &str) -> Option Value { +fn launch_request( + state_directory: &Path, + package: &ScannedPackage, + workspace_path: &str, + layers: super::settings::SettingLayers, +) -> Value { let workspace_directory = state_directory.join(store::workspace_state_key(workspace_path)); serde_json::json!({ "pluginDirectory": package.root.to_string_lossy(), "stateDirectory": state_directory.to_string_lossy(), "workspaceStateDirectory": workspace_directory.to_string_lossy(), "architecture": super::host_architecture(), - "settings": {}, - "projectSettings": {}, + "settings": layers.global, + "projectSettings": layers.project, }) } @@ -374,6 +393,8 @@ mod tests { enabled: true, status: "accepted", languages: Vec::new(), + declared_settings: Vec::new(), + primary_language_id: None, rejection: None, }, root, diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 5b5bac0d23..07be1efecf 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -12,7 +12,9 @@ pub mod install; #[cfg(test)] mod install_tests; pub mod launch; +pub mod run_plans; pub mod scan; +pub mod settings; pub mod store; #[cfg(test)] mod tests; @@ -26,12 +28,15 @@ use tauri::{AppHandle, Manager}; /// Capability versions this host implements. `plugins.declarative.validate` /// rejects any package that requires a capability or version beyond this map, /// so the host never registers a manifest section it cannot run. The set -/// grows per milestone of the declarative plugin host work. +/// grows per milestone of the declarative plugin host work. `languageServers` +/// 2 and `settings` 2 add workspace configuration push and scoped settings +/// (M3); `executionProviders` and `ui` remain unhosted. pub fn host_capabilities() -> Value { json!({ "languages": [1], - "languageServers": [1], + "languageServers": [1, 2], "serverProcess": [1], + "settings": [1, 2], }) } @@ -60,6 +65,10 @@ pub struct CatalogEntry { /// packages are quarantined with Core's stable rejection code. pub status: &'static str, pub languages: Vec, + /// Declared settings (settings capability), as Core normalizes them. + pub declared_settings: Vec, + /// First language with a language server, for workspace configuration. + pub primary_language_id: Option, pub rejection: Option, } diff --git a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs new file mode 100644 index 0000000000..db75c16758 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs @@ -0,0 +1,111 @@ +//! Run and test plans for declarative plugins. +//! +//! Core expands the declared templates into an executable and arguments; the +//! host resolves the executable like any other launch (`run_resolve_launch`) +//! and starts the process through the shared run pipeline. Process ownership +//! is tracked per workspace and plugin on the frontend, exactly like Worker +//! extension run actions. + +use super::scan::core_execute; +use serde::Serialize; +use serde_json::Value; +use tauri::AppHandle; + +/// An expanded run or test plan ready for the shared run pipeline. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeclarativeRunPlan { + pub plugin_id: String, + pub language_id: String, + /// Executable command as declared (e.g. `php`); the frontend resolves it + /// through `run_resolve_launch` like any extension run action. + pub executable: Value, + pub arguments: Vec, + pub working_directory: String, +} + +/// Expands the `run` template for one file of a language a plugin owns. +#[tauri::command] +pub fn declarative_plugins_run_plan( + app: AppHandle, + plugin_id: String, + language_id: String, + relative_file_path: String, + user_arguments: Vec, +) -> Result { + let package = super::launch::package_by_id(&app, &plugin_id) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + expand_plan( + &package.manifest, + &plugin_id, + &language_id, + "plugins.declarative.runPlan", + serde_json::json!({ + "relativeFilePath": relative_file_path, + "arguments": user_arguments, + }), + ) +} + +/// Expands the workspace test template for a language a plugin owns. +/// `relative_project_file_paths` is the workspace's file list; the template's +/// project markers decide whether tests can run at all. +#[tauri::command] +pub fn declarative_plugins_test_plan( + app: AppHandle, + plugin_id: String, + language_id: String, + relative_project_file_paths: Vec, +) -> Result { + let package = super::launch::package_by_id(&app, &plugin_id) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + expand_plan( + &package.manifest, + &plugin_id, + &language_id, + "plugins.declarative.testPlan", + serde_json::json!({ + "scope": { "kind": "workspace" }, + "relativeProjectFilePaths": relative_project_file_paths, + }), + ) +} + +fn expand_plan( + manifest: &Value, + plugin_id: &str, + language_id: &str, + command: &str, + request: Value, +) -> Result { + let plan = core_execute( + command, + serde_json::json!({ + "manifest": manifest, + "languageID": language_id, + "request": request, + }), + )?; + if plan["status"] == serde_json::json!("rejected") { + return Err(format!( + "{command} rejected: {}", + plan["error"]["message"].as_str().unwrap_or("unknown") + )); + } + let body = &plan["plan"]; + Ok(DeclarativeRunPlan { + plugin_id: plugin_id.to_string(), + language_id: language_id.to_string(), + executable: body["executable"].clone(), + arguments: body["arguments"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + working_directory: body["workingDirectory"].as_str().unwrap_or(".").to_string(), + }) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs index d720f96513..9602fe6da8 100644 --- a/windows/tauri/src-tauri/src/plugin_host/scan.rs +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -65,6 +65,12 @@ fn scan_package(package: DiscoveredPackage) -> ScannedPackage { enabled: package.enabled, status: "accepted", languages: catalog_languages(&plugin), + declared_settings: plugin["settings"].as_array().cloned().unwrap_or_default(), + primary_language_id: plugin["languageServers"] + .as_array() + .and_then(|servers| servers.first()) + .and_then(|server| server["languageID"].as_str()) + .map(str::to_string), rejection: None, }; ScannedPackage { @@ -98,6 +104,8 @@ fn quarantined( enabled: false, status: "rejected", languages: Vec::new(), + declared_settings: Vec::new(), + primary_language_id: None, rejection: Some(rejection), }, root: package.root, diff --git a/windows/tauri/src-tauri/src/plugin_host/settings.rs b/windows/tauri/src-tauri/src/plugin_host/settings.rs new file mode 100644 index 0000000000..94df0f2002 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/settings.rs @@ -0,0 +1,214 @@ +//! Stored user values for declarative plugin settings. +//! +//! Core owns every setting semantic (validation, defaults, layering, effect +//! kinds); this module only persists the two value layers the host owns: the +//! user's global value per plugin, and per-workspace overrides of +//! project-scoped settings (`settings` 2, written to the workspace's +//! `.lithe/plugin-settings.json`, matching the macOS storage locations). + +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +/// Global values of one plugin, persisted as +/// `app_local_data_dir()/plugin-settings/.json`. +fn global_store_path(app: &AppHandle, plugin_id: &str) -> Result { + Ok(app + .path() + .app_local_data_dir() + .map_err(|error| error.to_string())? + .join("plugin-settings") + .join(format!("{plugin_id}.json"))) +} + +/// Project overrides live in the workspace's `.lithe/plugin-settings.json`. +pub fn project_store_path(workspace_path: &str) -> PathBuf { + PathBuf::from(workspace_path) + .join(".lithe") + .join("plugin-settings.json") +} + +/// Reads a JSON object store; missing files read as empty. Corrupted files +/// surface as errors so a broken write is never silently reset. +fn read_object(path: &Path) -> Result, String> { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()), + Err(error) => return Err(error.to_string()), + }; + let value: Value = serde_json::from_str(&text) + .map_err(|error| format!("{} is not valid JSON: {error}", path.display()))?; + match value { + Value::Object(map) => Ok(map), + _ => Err(format!("{} is not a JSON object", path.display())), + } +} + +/// Writes a JSON object store atomically via temp file + rename. +fn write_object(path: &Path, map: &Map) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let temp = path.with_extension("json.tmp"); + std::fs::write( + &temp, + serde_json::to_string_pretty(&Value::Object(map.clone())) + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + std::fs::rename(&temp, path).map_err(|error| error.to_string()) +} + +/// Both stored layers for one plugin. +pub struct SettingLayers { + pub global: BTreeMap, + pub project: BTreeMap, +} + +/// Loads the stored values of one plugin for one workspace. Only entries +/// under the plugin's ID in the project store belong to it. +pub fn load_layers( + app: &AppHandle, + plugin_id: &str, + workspace_path: &str, +) -> Result { + let global: BTreeMap = read_object(&global_store_path(app, plugin_id)?)? + .into_iter() + .collect(); + let project_file = read_object(&project_store_path(workspace_path))?; + let project: BTreeMap = project_file + .get(plugin_id) + .and_then(Value::as_object) + .map(|values| { + values + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + Ok(SettingLayers { global, project }) +} + +/// Stores the user's global value of one setting. A value equal to the +/// declared default is forgotten so default changes flow through (macOS +/// rule); `None` erases the stored value. +pub fn store_global( + app: &AppHandle, + plugin_id: &str, + setting_id: &str, + value: Option, +) -> Result<(), String> { + let path = global_store_path(app, plugin_id)?; + let mut store = read_object(&path)?; + match value { + Some(value) => { + store.insert(setting_id.to_string(), value); + } + None => { + store.remove(setting_id); + } + } + write_object(&path, &store) +} + +/// Stores the workspace override of a project-scoped setting, or erases it +/// with `None`. When the last override of a plugin disappears from the +/// project store, the empty plugin entry is removed; an empty store file is +/// deleted entirely. +pub fn store_project( + workspace_path: &str, + plugin_id: &str, + setting_id: &str, + value: Option, +) -> Result<(), String> { + let path = project_store_path(workspace_path); + let mut store = read_object(&path)?; + let entry = store + .entry(plugin_id.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let values = entry + .as_object_mut() + .ok_or_else(|| format!("{} has a corrupted plugin-settings entry", path.display()))?; + match value { + Some(value) => { + values.insert(setting_id.to_string(), value); + } + None => { + values.remove(setting_id); + } + } + if values.is_empty() { + store.remove(plugin_id); + } + if store.is_empty() { + match std::fs::remove_file(&path) { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.to_string()), + } + } + write_object(&path, &store) +} + +/// Tauri command: effective values of one plugin's settings. +#[tauri::command] +pub fn declarative_plugins_resolve_settings( + app: AppHandle, + manifest: Value, + workspace_path: String, +) -> Result { + let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { + return Err("manifest has no id".to_string()); + }; + let layers = load_layers(&app, &plugin_id, &workspace_path)?; + super::scan::core_execute( + "plugins.declarative.resolveSettings", + serde_json::json!({ + "manifest": manifest, + "settings": layers.global, + "projectSettings": layers.project, + }), + ) +} + +/// Tauri command: stores a setting value in the requested layer. +#[tauri::command] +pub fn declarative_plugins_store_setting( + app: AppHandle, + plugin_id: String, + setting_id: String, + scope: String, + workspace_path: String, + value: Option, +) -> Result<(), String> { + match scope.as_str() { + "global" => store_global(&app, &plugin_id, &setting_id, value), + "project" => store_project(&workspace_path, &plugin_id, &setting_id, value), + other => Err(format!("unknown setting scope {other}")), + } +} + +/// Tauri command: a running language server's `workspaceConfiguration` +/// expanded with the current values, for `immediate` effect changes. +#[tauri::command] +pub fn declarative_plugins_workspace_configuration( + app: AppHandle, + manifest: Value, + language_id: String, + workspace_path: String, +) -> Result { + let Some(plugin_id) = manifest["id"].as_str().map(str::to_string) else { + return Err("manifest has no id".to_string()); + }; + let layers = load_layers(&app, &plugin_id, &workspace_path)?; + super::scan::core_execute( + "plugins.declarative.workspaceConfiguration", + serde_json::json!({ + "manifest": manifest, + "languageID": language_id, + "settings": layers.global, + "projectSettings": layers.project, + }), + ) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs index 956573843a..32e3cfc94a 100644 --- a/windows/tauri/src-tauri/src/plugin_host/tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -15,7 +15,7 @@ const FIXTURES_DIR: &str = concat!( "/../../../shared/contracts/plugins/v1/fixtures" ); -/// Valid manifests that this host's v0 capability declaration accepts. +/// Valid manifests that this host's capability declaration accepts. const ACCEPTED_FIXTURES: [&str; 5] = [ "go-support.plugin.json", "hello-lithe.plugin.json", @@ -24,7 +24,7 @@ const ACCEPTED_FIXTURES: [&str; 5] = [ "php-support.plugin.json", ]; -/// Valid manifests whose capabilities exceed the v0 host declaration; Core +/// Valid manifests whose capabilities exceed the host declaration; Core /// rejects them through the capability gate, not through manifest semantics. const CAPABILITY_GATED_FIXTURES: [&str; 6] = [ "cargo-build.plugin.json", @@ -327,3 +327,164 @@ fn scan_package_at( pub(crate) fn read_fixture_for_tests(name: &str) -> serde_json::Value { read_fixture(name) } + +/// M3 setting-store tests exercise the two persistence layers directly: the +/// global per-plugin file and the workspace's `.lithe/plugin-settings.json`. +mod settings_tests { + use crate::plugin_host::settings::{project_store_path, store_global, store_project}; + + fn unique_temp_root(label: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-settings-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + #[test] + fn project_overrides_round_trip_and_clean_up_empty_entries() { + let workspace = unique_temp_root("project"); + let workspace_path = workspace.to_string_lossy().to_string(); + + store_project( + &workspace_path, + "acme.php", + "heapMaxMB", + Some(serde_json::json!(4096)), + ) + .expect("stores override"); + let store = + std::fs::read_to_string(project_store_path(&workspace_path)).expect("store file"); + let parsed: serde_json::Value = serde_json::from_str(&store).expect("store JSON"); + assert_eq!(parsed["acme.php"]["heapMaxMB"], 4096); + + // Erasing the last override removes the plugin entry, then the file. + store_project(&workspace_path, "acme.php", "heapMaxMB", None).expect("erases override"); + assert!(!project_store_path(&workspace_path).exists()); + let _ = std::fs::remove_dir_all(&workspace); + } + + #[test] + fn project_store_keeps_other_plugins_entries() { + let workspace = unique_temp_root("project-multi"); + let workspace_path = workspace.to_string_lossy().to_string(); + store_project(&workspace_path, "acme.php", "a", Some(serde_json::json!(1))) + .expect("stores a"); + store_project( + &workspace_path, + "other.plugin", + "b", + Some(serde_json::json!(2)), + ) + .expect("stores b"); + store_project(&workspace_path, "acme.php", "a", None).expect("erases a"); + + let store: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(project_store_path(&workspace_path)).expect("store"), + ) + .expect("store JSON"); + assert!(store["acme.php"].is_null()); + assert_eq!(store["other.plugin"]["b"], 2); + let _ = std::fs::remove_dir_all(&workspace); + } + + #[test] + fn corrupted_project_store_is_an_error_not_a_reset() { + let workspace = unique_temp_root("project-corrupt"); + let path = project_store_path(&workspace.to_string_lossy()); + std::fs::create_dir_all(path.parent().unwrap()).expect(".lithe dir"); + std::fs::write(&path, "{ not json").expect("corrupt store"); + + assert!(store_project( + &workspace.to_string_lossy(), + "acme.php", + "a", + Some(serde_json::json!(1)) + ) + .is_err()); + // The corrupted bytes are preserved for diagnosis. + assert_eq!(std::fs::read_to_string(&path).expect("store"), "{ not json"); + let _ = std::fs::remove_dir_all(&workspace); + } + + // store_global needs an AppHandle; its logic is covered through the + // project variant (same read/write helpers) and the live command test. + #[allow(unused_imports)] + fn witness() { + let _ = store_global; + } +} + +/// The bundled PHP contract package validates, expands run and test plans, +/// and exposes settings resolution through the real Core commands — the +/// end-to-end acceptance of the contract package shipped with the app. +#[test] +fn bundled_php_contract_package_expands_plans() { + let text = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../Plugins/win/Official/PhpSupportContract/plugin.json" + )) + .expect("contract package manifest is readable"); + let manifest: serde_json::Value = serde_json::from_str(&text).expect("manifest is JSON"); + + let validated = scan::validate_manifest_with(&manifest, Some(super::host_capabilities())); + let plugin = validated.expect("contract package is accepted by the host"); + assert_eq!(plugin["id"], "dev.lithe.plugin.php-support"); + assert_eq!(plugin["pluginAPIVersion"], 2); + + // Run plan expansion. + let run = scan::core_execute( + "plugins.declarative.runPlan", + serde_json::json!({ + "manifest": manifest, + "languageID": "php", + "request": { + "relativeFilePath": "src/Item.php", + "arguments": ["--verbose"] + }, + }), + ) + .expect("run plan executes"); + assert_eq!(run["status"], "ok", "{run}"); + assert_eq!(run["plan"]["executable"]["command"], "php"); + assert_eq!( + run["plan"]["arguments"], + serde_json::json!(["src/Item.php", "--verbose"]) + ); + + // Workspace test plan expansion. + let test = scan::core_execute( + "plugins.declarative.testPlan", + serde_json::json!({ + "manifest": manifest, + "languageID": "php", + "request": { + "scope": { "kind": "workspace" }, + "relativeProjectFilePaths": ["composer.json", "tests/ItemTest.php"], + }, + }), + ) + .expect("test plan executes"); + assert_eq!(test["status"], "ok", "{test}"); + assert_eq!( + test["plan"]["arguments"], + serde_json::json!(["vendor/bin/phpunit"]) + ); + + // Settings resolution with an empty store (defaults). + let settings = scan::core_execute( + "plugins.declarative.resolveSettings", + serde_json::json!({ + "manifest": manifest, + "settings": {}, + "projectSettings": {}, + }), + ) + .expect("resolve settings executes"); + assert_eq!(settings["status"], "ok", "{settings}"); +} diff --git a/windows/tauri/src-tauri/tauri.conf.json b/windows/tauri/src-tauri/tauri.conf.json index e778f7a5ba..11525a00e3 100644 --- a/windows/tauri/src-tauri/tauri.conf.json +++ b/windows/tauri/src-tauri/tauri.conf.json @@ -23,6 +23,7 @@ "targets": ["nsis", "msi"], "resources": { "../src/extensions/bundled/**/*": "extensions/bundled/", + "../../../Plugins/win/Official/PhpSupportContract/**/*": "OfficialPlugins/PhpSupportContract/", "helpers/": "helpers/" }, "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"] diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts index 126227b2ff..0e026ac0c4 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -32,9 +32,25 @@ export interface DeclarativePluginSummary { /** `rejected` packages are quarantined and never own a language. */ status: "accepted" | "rejected"; languages: DeclarativePluginLanguage[]; + declaredSettings: DeclarativePluginSetting[]; + primaryLanguageId: string | null; rejection: DeclarativePluginRejection | null; } +export interface DeclarativePluginSetting { + id: string; + type: "integer" | "enum" | "boolean" | "multiSelect" | "text" | "file" | "directory"; + title: string; + description?: string | null; + default: unknown; + minimum?: number | null; + maximum?: number | null; + unit?: string | null; + options?: Array<{ value: string; label: string }> | null; + scope?: "global" | "project" | null; + effect?: "restart" | "immediate" | "reload" | "nextRun" | null; +} + export interface DeclarativePluginLaunch { pluginId: string; languageId: string; @@ -121,3 +137,101 @@ export function setDeclarativePluginEnabled(pluginId: string, enabled: boolean): export function rollbackDeclarativePlugin(pluginId: string): Promise { return invoke("declarative_plugins_rollback", { pluginId }); } + +/** One declared setting, as Core normalizes it (settings capability v2). */ +export interface DeclarativePluginSetting { + id: string; + type: "integer" | "enum" | "boolean" | "multiSelect" | "text" | "file" | "directory"; + title: string; + description?: string | null; + default: unknown; + minimum?: number | null; + maximum?: number | null; + unit?: string | null; + options?: Array<{ value: string; label: string }> | null; + scope?: "global" | "project" | null; + effect?: "restart" | "immediate" | "reload" | "nextRun" | null; + section?: { languageID?: string; buildSystemID?: string } | null; +} + +/** Effective value of one setting and the layer it came from. */ +export interface ResolvedDeclarativeSetting { + id: string; + value: unknown; + source: "default" | "global" | "project"; +} + +/** Effective values of one plugin's settings for one workspace. */ +export function resolveDeclarativeSettings( + manifest: Record, + workspacePath: string, +): Promise<{ status: string; settings?: ResolvedDeclarativeSetting[] }> { + return invoke("declarative_plugins_resolve_settings", { manifest, workspacePath }); +} + +/** Stores a setting value in the requested layer; `null` erases it. */ +export function storeDeclarativeSetting( + pluginId: string, + settingId: string, + scope: "global" | "project", + workspacePath: string, + value: unknown, +): Promise { + return invoke("declarative_plugins_store_setting", { + pluginId, + settingId, + scope, + workspacePath, + value, + }); +} + +/** A running server's workspace configuration expanded with current values. */ +export function declarativeWorkspaceConfiguration( + manifest: Record, + languageId: string, + workspacePath: string, +): Promise<{ status: string; configuration?: unknown }> { + return invoke("declarative_plugins_workspace_configuration", { + manifest, + languageId, + workspacePath, + }); +} + +/** An expanded run or test plan ready for the shared run pipeline. */ +export interface DeclarativeRunPlan { + pluginId: string; + languageId: string; + executable: { command?: string | null; tool?: string | null }; + arguments: string[]; + workingDirectory: string; +} + +/** Expands the run template for one file of a plugin-owned language. */ +export function resolveDeclarativeRunPlan( + pluginId: string, + languageId: string, + relativeFilePath: string, + userArguments: string[] = [], +): Promise { + return invoke("declarative_plugins_run_plan", { + pluginId, + languageId, + relativeFilePath, + userArguments, + }); +} + +/** Expands the workspace test template for a plugin-owned language. */ +export function resolveDeclarativeTestPlan( + pluginId: string, + languageId: string, + relativeProjectFilePaths: string[], +): Promise { + return invoke("declarative_plugins_test_plan", { + pluginId, + languageId, + relativeProjectFilePaths, + }); +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-process-owner.ts b/windows/tauri/src/features/plugins/declarative-plugin-process-owner.ts new file mode 100644 index 0000000000..8d369331ca --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-plugin-process-owner.ts @@ -0,0 +1,8 @@ +import { ExtensionProcessOwner } from "@/extensions/run/extension-process-owner"; + +/** + * Process ownership for contract v1 plugin runs. The lifecycle rules are the + * Worker extension model's (stop on disable/uninstall/workspace close), so + * the same owner class is reused with a plugin-ID key. + */ +export const declarativePluginProcessOwner = new ExtensionProcessOwner(); diff --git a/windows/tauri/src/features/plugins/declarative-plugin-run.ts b/windows/tauri/src/features/plugins/declarative-plugin-run.ts new file mode 100644 index 0000000000..f4de30140a --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-plugin-run.ts @@ -0,0 +1,151 @@ +import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; +import { ensureRunProcessListeners } from "@/features/run/hooks/use-run-process-events"; +import { resolveRunLaunch, startRunProcess, stopRunProcess } from "@/features/run/api/run-host-api"; +import { + bindRunSessionWorkspace, + releaseRunSessionWorkspace, + useRunStore, +} from "@/features/run/stores/run.store"; +import { useUIState } from "@/features/window/stores/ui-state.store"; +import { + listDeclarativePlugins, + resolveDeclarativeRunPlan, + type DeclarativeRunPlan, +} from "./declarative-plugin-launch"; +import { declarativePluginProcessOwner } from "./declarative-plugin-process-owner"; + +/** + * Run actions for contract v1 plugins, mirroring the Worker extension run + * path: Core expands the declared template, the shared run pipeline resolves + * and starts the process, and the process owner tracks it per workspace and + * plugin so disabling or uninstalling stops its sessions. + */ + +export interface DeclarativeRunTarget { + /** Run session ID, shaped `declarative::`. */ + id: string; + title: string; +} + +export function isDeclarativeRunEnabled(pluginId: string): boolean { + return findAcceptedPlugin(pluginId)?.enabled === true; +} + +function findAcceptedPlugin(pluginId: string) { + // Synchronous accessors are not available on the cached Tauri state; the + // frontend keeps the scanned catalog in this module after each rescan. + return declarativeCatalog.find((plugin) => plugin.pluginId === pluginId); +} + +let declarativeCatalog: Awaited> = []; + +/** Refreshes the cached catalog; call after rescans and installs. */ +export function refreshDeclarativeCatalog(): void { + void listDeclarativePlugins().then((plugins) => { + declarativeCatalog = plugins; + }); +} + +export function declarativePluginForFile( + filePath: string, +): { pluginId: string; languageId: string } | null { + const lower = filePath.toLowerCase(); + const dot = lower.lastIndexOf("."); + const extension = dot === -1 ? "" : lower.slice(dot + 1); + for (const plugin of declarativeCatalog) { + if (plugin.status !== "accepted" || !plugin.enabled) continue; + for (const language of plugin.languages) { + if (language.fileExtensions.some((declared) => declared.toLowerCase() === extension)) { + return { pluginId: plugin.pluginId, languageId: language.id }; + } + } + } + return null; +} + +export async function runDeclarativePluginFile( + filePath: string, + workspaceId: string, + root: string, + userArguments: string[] = [], +): Promise { + const owner = declarativePluginForFile(filePath); + if (!owner) throw new Error("No enabled declarative plugin owns this file."); + const relative = toWorkspaceRelative(filePath, root); + const plan = await resolveDeclarativeRunPlan( + owner.pluginId, + owner.languageId, + relative, + userArguments, + ); + await startDeclarativePlan(plan, workspaceId, root, relative); +} + +async function startDeclarativePlan( + plan: DeclarativeRunPlan, + workspaceId: string, + root: string, + title: string, +): Promise { + const id = `declarative:${plan.pluginId}:${crypto.randomUUID()}`; + const store = useRunStore.getStore(workspaceId); + await declarativePluginProcessOwner.start( + id, + workspaceId, + plan.pluginId, + async () => { + await ensureRunProcessListeners(); + await saveWorkspaceBeforeLaunch(workspaceId); + const resolved = await resolveRunLaunch({ + root, + executable: plan.executable, + workingDirectory: plan.workingDirectory, + }); + if (!isDeclarativeRunEnabled(plan.pluginId)) return; + bindRunSessionWorkspace(id, workspaceId); + store.setState((state) => ({ + sessions: [ + ...state.sessions, + { + id, + configurationId: id, + title: `${title} (declarative)`, + output: "", + isRunning: true, + exitCode: null, + }, + ], + selectedSessionId: id, + })); + const ui = useUIState.getState(); + ui.setBottomPaneActiveTab("run"); + ui.setIsBottomPaneVisible(true); + try { + await startRunProcess({ + sessionId: id, + executionId: id, + ...resolved, + arguments: plan.arguments, + }); + } catch (error) { + store.getState().actions.finishProcess(id, 1); + releaseRunSessionWorkspace(id); + throw error; + } + }, + async () => { + await stopRunProcess(id, id); + store.getState().actions.finishProcess(id, -1); + releaseRunSessionWorkspace(id); + }, + ); +} + +function toWorkspaceRelative(filePath: string, root: string): string { + const normalized = filePath.replace(/\\/g, "/"); + const normalizedRoot = root.replace(/\\/g, "/").replace(/\/$/, ""); + if (normalized.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`)) { + return normalized.slice(normalizedRoot.length + 1); + } + return normalized; +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx new file mode 100644 index 0000000000..d4d9cf9346 --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import Switch from "@/ui/switch"; +import NumberInput from "@/ui/number-input"; +import SettingsSelect from "@/ui/settings-select"; +import { + resolveDeclarativeSettings, + storeDeclarativeSetting, + declarativeWorkspaceConfiguration, + type DeclarativePluginSetting, + type ResolvedDeclarativeSetting, +} from "./declarative-plugin-launch"; + +/** + * Editor for one accepted plugin's declared settings. Value semantics live in + * Core (`plugins.declarative.resolveSettings`); this view only renders + * controls, applies the declared scope/effect, and reports "immediate" + * changes to a running server through `workspaceConfiguration`. + * + * The workspace path arrives when a workspace feature mounts the editor; + * without one, project-scoped settings fall back to the global layer and the + * scope badge says so. + */ +export function DeclarativePluginSettingsEditor({ + pluginId, + settings, + workspacePath, + languageId, +}: { + pluginId: string; + settings: DeclarativePluginSetting[]; + workspacePath: string | null; + languageId: string | null; +}) { + const { t } = useTranslation(); + const [values, setValues] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + setError(null); + try { + const outcome = await resolveDeclarativeSettings({ id: pluginId }, workspacePath ?? ""); + if (outcome.status !== "ok") { + setError(t("settings.declarativePlugins.settingsUnavailable")); + return; + } + setValues(outcome.settings ?? []); + } catch (reason) { + setError(String(reason)); + } + }, [pluginId, workspacePath, t]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const apply = async (setting: DeclarativePluginSetting, value: unknown) => { + setBusy(true); + setError(null); + try { + const scope = setting.scope === "project" && workspacePath ? "project" : "global"; + // The default is forgotten so later default changes flow through. + const stored = value === setting.default ? null : value; + await storeDeclarativeSetting(pluginId, setting.id, scope, workspacePath ?? "", stored); + await refresh(); + if (setting.effect === "immediate" && languageId) { + await declarativeWorkspaceConfiguration( + { id: pluginId }, + languageId, + workspacePath ?? "", + ); + } + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + }; + + if (!values.length) return null; + return ( +
    +

    {t("settings.declarativePlugins.settings")}

    + {error && ( +

    + {error} +

    + )} + {settings.map((setting) => { + const resolved = values.find((value) => value.id === setting.id); + if (!resolved) return null; + return ( + void apply(setting, value)} + /> + ); + })} +
    + ); +} + +function SettingControl({ + setting, + resolved, + disabled, + projectAvailable, + onApply, +}: { + setting: DeclarativePluginSetting; + resolved: ResolvedDeclarativeSetting; + disabled: boolean; + projectAvailable: boolean; + onApply: (value: unknown) => void; +}) { + const { t } = useTranslation(); + const sourceLabel = t(`settings.declarativePlugins.settingSource.${resolved.source}`); + const effectLabel = setting.effect + ? t(`settings.declarativePlugins.settingEffect.${setting.effect}`) + : t("settings.declarativePlugins.settingEffect.restart"); + const scopeLabel = projectAvailable + ? t("settings.declarativePlugins.settingScope.project") + : t("settings.declarativePlugins.settingScope.global"); + + return ( +
    +
    +

    + {setting.title} + + {" "} + · {sourceLabel} · {scopeLabel} · {effectLabel} + +

    + {setting.description && ( +

    {setting.description}

    + )} +
    + {setting.type === "boolean" ? ( + + ) : setting.type === "integer" ? ( + onApply(value)} + aria-label={setting.title} + /> + ) : setting.type === "enum" ? ( + ({ + value: option.value, + label: option.label, + }))} + onChange={onApply} + /> + ) : ( + onApply(event.target.value)} + aria-label={setting.title} + /> + )} +
    + ); +} + +export type { DeclarativePluginSetting, ResolvedDeclarativeSetting }; diff --git a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx index b84cdc5af4..098fde524e 100644 --- a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx @@ -13,6 +13,7 @@ import { type DeclarativePluginSummary, type PreparedDeclarativeInstall, } from "./declarative-plugin-launch"; +import { DeclarativePluginSettingsEditor } from "./declarative-plugin-settings-editor"; /** * Management surface for contract v1 declarative plugins. This block is @@ -186,7 +187,11 @@ function DeclarativePluginRow({ const { t } = useTranslation(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [showSettings, setShowSettings] = useState(false); const rejected = plugin.status === "rejected"; + const declaredSettings = + !rejected && Array.isArray(plugin.declaredSettings) ? plugin.declaredSettings : []; + const hasSettings = declaredSettings.length > 0; const run = async (action: () => Promise) => { setBusy(true); @@ -202,48 +207,68 @@ function DeclarativePluginRow({ }; return ( -
  • -
    -

    - {plugin.displayName}{" "} - - {plugin.version} · {t(`settings.declarativePlugins.source.${plugin.source}`)} - -

    - {rejected && plugin.rejection && ( -

    - {plugin.rejection.code}: {plugin.rejection.message} +

  • +
    +
    +

    + {plugin.displayName}{" "} + + {plugin.version} · {t(`settings.declarativePlugins.source.${plugin.source}`)} +

    - )} - {error &&

    {error}

    } -
    - {!rejected && plugin.source === "installed" && ( - <> - - void run(() => setDeclarativePluginEnabled(plugin.pluginId, checked)) - } - aria-label={t("settings.declarativePlugins.toggle", { name: plugin.displayName })} - /> + {rejected && plugin.rejection && ( +

    + {plugin.rejection.code}: {plugin.rejection.message} +

    + )} + {error &&

    {error}

    } +
    + {!rejected && hasSettings && ( - - + )} + {!rejected && plugin.source === "installed" && ( + <> + + void run(() => setDeclarativePluginEnabled(plugin.pluginId, checked)) + } + aria-label={t("settings.declarativePlugins.toggle", { name: plugin.displayName })} + /> + + + + )} + + {!rejected && showSettings && hasSettings && ( + )}
  • ); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index ca72df52f2..c01a8c1276 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -2095,6 +2095,19 @@ const catalogs = { "settings.declarativePlugins.uninstall": "Uninstall", "settings.declarativePlugins.source.bundled": "bundled", "settings.declarativePlugins.source.installed": "installed", + "settings.declarativePlugins.settings": "Settings", + "settings.declarativePlugins.showSettings": "Settings…", + "settings.declarativePlugins.hideSettings": "Hide settings", + "settings.declarativePlugins.settingsUnavailable": "This plugin's settings could not be resolved.", + "settings.declarativePlugins.settingSource.default": "default", + "settings.declarativePlugins.settingSource.global": "user value", + "settings.declarativePlugins.settingSource.project": "project override", + "settings.declarativePlugins.settingScope.global": "all workspaces", + "settings.declarativePlugins.settingScope.project": "this workspace", + "settings.declarativePlugins.settingEffect.restart": "takes effect after the language server restarts", + "settings.declarativePlugins.settingEffect.immediate": "applied to the running language server", + "settings.declarativePlugins.settingEffect.reload": "takes effect when the plugin model reloads", + "settings.declarativePlugins.settingEffect.nextRun": "takes effect on the next run", "settings.resizeCategories": "Resize settings categories", "settings.resizePlugins": "Resize plugin list", "settings.noMatching": "No settings found", @@ -6938,6 +6951,19 @@ const catalogs = { "settings.declarativePlugins.uninstall": "卸载", "settings.declarativePlugins.source.bundled": "内置", "settings.declarativePlugins.source.installed": "已安装", + "settings.declarativePlugins.settings": "设置", + "settings.declarativePlugins.showSettings": "设置…", + "settings.declarativePlugins.hideSettings": "收起设置", + "settings.declarativePlugins.settingsUnavailable": "无法解析该插件的设置。", + "settings.declarativePlugins.settingSource.default": "默认", + "settings.declarativePlugins.settingSource.global": "用户值", + "settings.declarativePlugins.settingSource.project": "项目覆盖", + "settings.declarativePlugins.settingScope.global": "所有工作区", + "settings.declarativePlugins.settingScope.project": "当前工作区", + "settings.declarativePlugins.settingEffect.restart": "语言服务器重启后生效", + "settings.declarativePlugins.settingEffect.immediate": "立即应用到运行中的语言服务器", + "settings.declarativePlugins.settingEffect.reload": "插件模型重新加载后生效", + "settings.declarativePlugins.settingEffect.nextRun": "下次运行生效", "settings.resizeCategories": "调整设置分类栏宽度", "settings.resizePlugins": "调整插件列表宽度", "settings.noMatching": "未找到设置", From e47ebc60b5c4b2c7f647097834f1675bc297f89e Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 20:05:24 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(windows):=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E6=8F=92=E4=BB=B6=E5=AE=BF=E4=B8=BB=20M4=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20Web=20=E5=B7=A5=E5=85=B7=E7=AA=97=E5=8F=A3=EF=BC=88?= =?UTF-8?q?ui=202=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugin_host/web.rs:lithe-plugin:// 自定义 scheme 只服务启用包内普通文件 (拒 reparse point、路径逃逸、超 16MiB、非白名单扩展名),响应带 CSP(default-src 'none'、拒 eval 与联网、frame-ancestors 'none')、 nosniff、no-store、CORP - window.lithe 桥 v0:document-start 注入冻结桥;context、 ui.getState/setState(宿主持有状态,64KiB 上限)、workspace.openFile、 事件泵;build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod - 页面生命周期命令:open(去重聚焦)/close;禁用卸载清状态 (forget_plugin_state) - 能力声明 ui 提升为 [1,2],web-panel fixture 转为被接受(门控测试同步) - 管理区块新增 Web 工具窗口列表(打开/关闭),en-US/zh-CN 文案 - 矩阵 plugin-web-tool-windows Windows partial、declarative-plugin-contract evidence/notes 更新;Note 增补 M4 记录 --- ...6-10-22-windows-declarative-plugin-host.md | 10 +- .../features/declarative-plugin-contract.json | 3 +- .../features/plugin-web-tool-windows.json | 7 +- windows/tauri/src-tauri/Cargo.lock | 1 + windows/tauri/src-tauri/Cargo.toml | 1 + windows/tauri/src-tauri/src/main.rs | 8 + .../src-tauri/src/plugin_host/install.rs | 2 +- .../tauri/src-tauri/src/plugin_host/mod.rs | 5 +- .../tauri/src-tauri/src/plugin_host/scan.rs | 3 +- .../tauri/src-tauri/src/plugin_host/tests.rs | 6 +- .../tauri/src-tauri/src/plugin_host/web.rs | 579 ++++++++++++++++++ .../plugins/declarative-plugin-launch.ts | 25 + .../plugins/declarative-plugins-section.tsx | 51 ++ windows/tauri/src/i18n/locale.ts | 12 + 14 files changed, 701 insertions(+), 12 deletions(-) create mode 100644 windows/tauri/src-tauri/src/plugin_host/web.rs diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index 1a6391dd84..c62ed8163a 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -4,7 +4,7 @@ ## 先说结论 -Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;M2 已补磁盘导入安装链、设置内独立管理页和 runtime 启动的 `minimumVersion` 探测;M3 已补声明式设置(`languageServers` 2 / `settings` 2 能力声明、双层存储、`immediate` 效果下推)、运行/测试计划命令与契约版 PHP bundled 包。能力声明开放 `languages:1`、`languageServers:[1,2]`、`serverProcess:1`、`settings:[1,2]`,其余能力(`executionProviders`、`buildProviders`、`buildSystems`、`ui`)仍由 Core 自动拒绝。 +Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模型长期并行:需要宿主代用户拉起进程的语言与构建插件走契约宿主,宿主内运行 UI 与服务行为的扩展(主题、AI provider 等)留在 Worker 模型。契约引擎不需要新写——`lithe-core` 已经编译进 Windows 宿主,经 `core_execute` 就能调用 `plugins.declarative.*` 与 `plugins.marketplace.*`。M1 已落地:包存储、启动扫描与隔离、语言服务器启动计划解析,以及共享 fixtures 的全量验收;M2 已补磁盘导入安装链、设置内独立管理页和 runtime 启动的 `minimumVersion` 探测;M3 已补声明式设置、运行/测试计划命令与契约版 PHP bundled 包;M4 已补 Web 工具窗口(`lithe-plugin://` scheme、CSP、`window.lithe` 桥 v0)。能力声明开放 `languages:1`、`languageServers:[1,2]`、`serverProcess:1`、`settings:[1,2]`、`ui:[1,2]`,其余能力(`executionProviders`、`buildProviders`、`buildSystems`)仍由 Core 自动拒绝。 ## 问题 @@ -24,7 +24,7 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 ### 能力声明渐进开放 -宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1,2], serverProcess:[1], settings:[1,2] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 +宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1,2], serverProcess:[1], settings:[1,2], ui:[1,2] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 ### 存储、信任与安装(v0) @@ -51,7 +51,7 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 ### 实施切分 -M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(`ui` 2,WebView2 自定义 scheme)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 +M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁盘安装链路 + 管理界面 v1 + 死链处置(已落地);M3 声明式设置 + 运行/测试计划 + PHP 契约包(已落地,Worker 版保留见上文);M4 Web 工具窗口(已落地,桥 v0 只含 state/openFile/主题,build.* 与 functions.* 等 Windows 构建系统能力接入后开放)。每个里程碑独立 PR,矩阵条目随代码同 PR 更新。总体方向遵循[公共扩展契约与 Provider 接入方向](../../proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md),本 Note 只落地其中 Windows 契约宿主的第一阶段。 ## 考虑过的备选方案 @@ -70,6 +70,9 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 ## 验证 +- M4 Web 工具窗口:`plugin_host/web.rs` 测试覆盖包路径规范化(`..`、编码分隔符、空段拒绝)、CSP 内容断言(default-src none、无 wasm-unsafe-eval、frame-ancestors none)、桥脚本冻结与作用域、状态键隔离;web-panel fixture 随 `ui:[1,2]` 声明提升转为被宿主接受(能力门控测试同步)。桥 v0 的 build.*/functions.* 返回 `plugin.web.unknownMethod`,等构建系统能力接入。 + + - M3 设置链路:`settings_tests`(`plugin_host/tests.rs`)覆盖项目覆盖往返、其他插件条目保留、损坏 store 报错不重置;能力门控测试随声明提升更新(settings-v2 fixture 被接受)。设置编辑 UI(`declarative-plugin-settings-editor.tsx`)按声明的 scope/effect 渲染并显示值来源徽标。 - M3 运行计划:契约 PHP 包端到端测试(`bundled_php_contract_package_expands_plans`)走真实 Core 命令验证 validate、runPlan(`${file}`/`${userArguments}` 展开)、testPlan(workspace scope + 项目标记)、resolveSettings。 @@ -95,6 +98,7 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 - `windows/tauri/src-tauri/src/plugin_host/run_plans.rs`(运行/测试计划命令) - `windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx`(设置编辑) - `windows/tauri/src/features/plugins/declarative-plugin-run.ts`(契约插件运行入口) +- `windows/tauri/src-tauri/src/plugin_host/web.rs`(Web 工具窗口 scheme、桥与状态) - `Plugins/win/Official/PhpSupportContract/`(契约版 PHP bundled 包) - `windows/tauri/src/features/plugins/declarative-plugins-section.tsx`(设置内管理页) - `windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts`(契约插件回落来源) diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index 5283ded035..1074be2e49 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -38,6 +38,7 @@ "windows/tauri/src-tauri/src/plugin_host/settings.rs", "windows/tauri/src-tauri/src/plugin_host/run_plans.rs", "Plugins/win/Official/PhpSupportContract/plugin.json", + "windows/tauri/src-tauri/src/plugin_host/web.rs", "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", "windows/tauri/src/features/plugins/declarative-plugin-launch.ts", "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", @@ -47,6 +48,6 @@ "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、宿主 UI 适配(ui 能力未声明)、多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移与宿主 UI 适配仍未实现,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。" } diff --git a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json index e2c105d1ab..0ff5dc3e39 100644 --- a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json +++ b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json @@ -34,9 +34,12 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/README.md" + "windows/tauri/src-tauri/src/plugin_host/web.rs", + "windows/tauri/src-tauri/src/plugin_host/tests.rs", + "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", + "shared/contracts/plugins/v1/fixtures/web-panel.plugin.json" ], - "implementationStatus": "missing", + "implementationStatus": "partial", "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index bc8b48e9c6..5ce33b5fcd 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2961,6 +2961,7 @@ version = "0.3.0" dependencies = [ "base64 0.22.1", "chrono", + "http", "keyring", "lithe-agent-host", "lithe-core", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index b142666b8b..4c57b1a29a 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -39,6 +39,7 @@ regex = "1" quick-xml = "0.37" same-file = "1.0.6" sha2 = "0.10" +http = "1" sysinfo = "0.32" zip = { version = "2.4", default-features = false, features = ["deflate"] } tauri = { version = "2", features = ["common-controls-v6", "protocol-asset"] } diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index b09cbcd3ce..f37e62915b 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -60,6 +60,9 @@ fn main() { window.create = false; } let application = tauri::Builder::default() + .register_uri_scheme_protocol("lithe-plugin", |context, request| { + plugin_host::web::handle_scheme_request(context.app_handle(), request) + }) .plugin(tauri_plugin_single_instance::init(|app, arguments, _| { host::enqueue_cli_arguments(app, arguments); })) @@ -133,6 +136,7 @@ fn main() { app.manage(run::RunProcessManager::default()); app.manage(debug::DebugAdapterManager::default()); app.manage(plugin_host::PluginHostState::default()); + app.manage(plugin_host::web::WebPageHostState::default()); run::cleanup_legacy_appdata(app.handle()); maven::clear_dependency_outputs(app.handle()); plugin_host::install::cleanup_stale_snapshots(app.handle()); @@ -259,6 +263,10 @@ fn main() { plugin_host::settings::declarative_plugins_workspace_configuration, plugin_host::run_plans::declarative_plugins_run_plan, plugin_host::run_plans::declarative_plugins_test_plan, + plugin_host::web::declarative_plugins_web_windows, + plugin_host::web::declarative_plugins_open_web_window, + plugin_host::web::declarative_plugins_close_web_window, + plugin_host::web::declarative_plugins_web_bridge_request, extension_store_placeholder::install_extension_from_url, extension_store_placeholder::uninstall_extension_new, extension_store_placeholder::get_extension_path, diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index 1d9421267b..b01928c247 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -286,7 +286,7 @@ pub(crate) fn staging_install( if staging_directory.exists() { std::fs::remove_dir_all(&staging_directory).map_err(|error| error.to_string())?; } - copy_directory_snapshot(source, &staging_directory); + copy_directory_snapshot(source, &staging_directory)?; let result = staging_validate_and_activate(&staging_directory, &version_directory); if result.is_err() { // A failed install never leaves staging bytes behind. diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 07be1efecf..3de61a17c0 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -18,6 +18,7 @@ pub mod settings; pub mod store; #[cfg(test)] mod tests; +pub mod web; use serde::Serialize; use serde_json::{json, Value}; @@ -30,13 +31,15 @@ use tauri::{AppHandle, Manager}; /// so the host never registers a manifest section it cannot run. The set /// grows per milestone of the declarative plugin host work. `languageServers` /// 2 and `settings` 2 add workspace configuration push and scoped settings -/// (M3); `executionProviders` and `ui` remain unhosted. +/// (M3); `ui` 2 adds package web pages in host windows (M4); +/// `executionProviders`, `buildProviders` and `buildSystems` remain unhosted. pub fn host_capabilities() -> Value { json!({ "languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], + "ui": [1, 2], }) } diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs index 9602fe6da8..b4b909d22d 100644 --- a/windows/tauri/src-tauri/src/plugin_host/scan.rs +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -7,7 +7,8 @@ //! the shared fixtures acceptance covers the real production path. use super::store::{self, DiscoveredPackage}; -use super::{CatalogEntry, CatalogLanguage, CatalogRejection, ScannedPackage}; +pub(crate) use super::ScannedPackage; +use super::{CatalogEntry, CatalogLanguage, CatalogRejection}; use serde_json::{json, Value}; use tauri::AppHandle; diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs index 32e3cfc94a..bb8efa81f3 100644 --- a/windows/tauri/src-tauri/src/plugin_host/tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -16,23 +16,23 @@ const FIXTURES_DIR: &str = concat!( ); /// Valid manifests that this host's capability declaration accepts. -const ACCEPTED_FIXTURES: [&str; 5] = [ +const ACCEPTED_FIXTURES: [&str; 6] = [ "go-support.plugin.json", "hello-lithe.plugin.json", "java-support.plugin.json", "node-runtime.plugin.json", "php-support.plugin.json", + "web-panel.plugin.json", ]; /// Valid manifests whose capabilities exceed the host declaration; Core /// rejects them through the capability gate, not through manifest semantics. -const CAPABILITY_GATED_FIXTURES: [&str; 6] = [ +const CAPABILITY_GATED_FIXTURES: [&str; 5] = [ "cargo-build.plugin.json", "go-build.plugin.json", "gradle-build.plugin.json", "gradle-provider.plugin.json", "maven-build.plugin.json", - "web-panel.plugin.json", ]; fn fixture_path(name: &str) -> PathBuf { diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs new file mode 100644 index 0000000000..3b5a4189f1 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -0,0 +1,579 @@ +//! Web tool windows (ui capability 2): package pages in a WebView2 shell. +//! +//! The contract rules (shared/contracts/plugins/v1/README.md, "Web content +//! (ui 2)") put the security boundary in the file service: only files of the +//! owning installed package are served, read-only, from a plugin-private +//! origin, with a CSP that refuses network access, remote resources, frames, +//! inline scripts and eval. The window shell (dock, title, order, lifecycle) +//! is host-owned; the page fills the body only. Build-system bridge methods +//! wait for the Windows build-system plugin capability and are not part of +//! this module. + +use super::scan::ScannedPackage; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// Largest package file the scheme serves; a page cannot map the whole +/// package into memory through one request. +const MAX_PAGE_FILE_BYTES: u64 = 16 * 1024 * 1024; + +/// CSP applied to every page response. `wasm-unsafe-eval` is absent: pages +/// compile no WebAssembly. `tauri:` IPC stays available because the bridge +/// runs over it; every other source is refused. +const PAGE_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src ipc: http://ipc.localhost; frame-ancestors 'none'; form-action 'none'; base-uri 'none'"; + +/// Per-window page state (`ui.getState` / `ui.setState`), kept in the host so +/// web storage can stay non-persistent. Bounded per contract. +const MAX_PAGE_STATE_BYTES: usize = 64 * 1024; + +#[derive(Default)] +pub struct WebPageHostState { + states: Mutex>, +} + +/// State key of one page: plugin ID and tool window ID. +fn state_key(plugin_id: &str, window_id: &str) -> String { + format!("{plugin_id}\u{0}{window_id}") +} + +/// One declared web tool window of a scanned package. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebToolWindow { + pub window_id: String, + pub plugin_id: String, + pub title: String, + pub placement: String, + pub order: Option, + /// Package-relative entry HTML path. + pub entry: String, +} + +/// Declared web tool windows of all accepted, enabled packages. +#[tauri::command] +pub fn declarative_plugins_web_windows(app: AppHandle) -> Vec { + let mut windows = Vec::new(); + for package in app.state::().packages() { + if package.entry.status != "accepted" || !package.entry.enabled { + continue; + } + collect_web_windows(&package, &mut windows); + } + windows.sort_by(|left, right| { + (left.order.unwrap_or(u32::MAX), left.window_id.as_str()) + .cmp(&(right.order.unwrap_or(u32::MAX), right.window_id.as_str())) + }); + windows +} + +fn collect_web_windows(package: &ScannedPackage, windows: &mut Vec) { + let Some(tool_windows) = package.manifest["ui"]["toolWindows"].as_array() else { + return; + }; + for window in tool_windows { + let Some(entry) = window["web"]["entry"].as_str() else { + continue; + }; + windows.push(WebToolWindow { + window_id: window["id"].as_str().unwrap_or_default().to_string(), + plugin_id: package.entry.plugin_id.clone(), + title: window["title"].as_str().unwrap_or_default().to_string(), + placement: window["placement"].as_str().unwrap_or("bottom").to_string(), + order: window["order"].as_u64().map(|order| order as u32), + entry: entry.to_string(), + }); + } +} + +/// Opens (or focuses) the web view of one tool window. The URL is the +/// plugin-private scheme origin with the declared entry path. +#[tauri::command] +pub fn declarative_plugins_open_web_window( + app: AppHandle, + window_id: String, +) -> Result<(), String> { + let Some(window) = declarative_plugins_web_windows(app.clone()) + .into_iter() + .find(|window| window.window_id == window_id) + else { + return Err(format!("{window_id} is not a declared web tool window")); + }; + let label = format!("plugin-web-{}", window_id.replace(['.', ':'], "-")); + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.set_focus(); + return Ok(()); + } + let url = format!( + "lithe-plugin://{}/{}", + urlencoding_encode(&window.plugin_id), + window.entry + ); + // Initialisation script defines the frozen `window.lithe` bridge before + // any page script runs. + let bridge = bridge_script(&window.plugin_id, &window.window_id); + WebviewWindowBuilder::new( + &app, + &label, + WebviewUrl::CustomProtocol(url.parse().map_err(|error| format!("{error}"))?), + ) + .title(window.title) + .initialization_script(&bridge) + .build() + .map(|_| ()) + .map_err(|error| format!("web window could not open: {error}")) +} + +/// Destroys the web view of one tool window; used on plugin disable, +/// uninstall, and window teardown. +#[tauri::command] +pub fn declarative_plugins_close_web_window( + app: AppHandle, + window_id: String, +) -> Result<(), String> { + let label = format!("plugin-web-{}", window_id.replace(['.', ':'], "-")); + if let Some(existing) = app.get_webview_window(&label) { + existing + .close() + .map_err(|error| format!("web window could not close: {error}"))?; + } + Ok(()) +} + +/// `ui.getState`: the host-stored state of one page, or `null`. +#[tauri::command] +pub fn declarative_plugins_web_get_state( + app: AppHandle, + plugin_id: String, + window_id: String, +) -> Value { + app.state::() + .states + .lock() + .expect("web page state mutex") + .get(&state_key(&plugin_id, &window_id)) + .cloned() + .unwrap_or(Value::Null) +} + +/// `ui.setState`: stores page state; over the contract's 64 KiB budget the +/// call fails with the bridge error shape instead of truncating. +#[tauri::command] +pub fn declarative_plugins_web_set_state( + app: AppHandle, + plugin_id: String, + window_id: String, + state: Value, +) -> Result<(), String> { + let serialized = serde_json::to_string(&state).map_err(|error| error.to_string())?; + if serialized.len() > MAX_PAGE_STATE_BYTES { + return Err( + json!({ "code": "plugin.web.invalidRequest", "message": "state exceeds 64 KiB" }) + .to_string(), + ); + } + app.state::() + .states + .lock() + .expect("web page state mutex") + .insert(state_key(&plugin_id, &window_id), state); + Ok(()) +} + +/// Drops state of one plugin (disable/uninstall). +pub fn forget_plugin_state(app: &AppHandle, plugin_id: &str) { + let states = app.state::(); + let mut guard = states.states.lock().expect("web page state mutex"); + let prefix = format!("{plugin_id}\u{0}"); + guard.retain(|key, _| !key.starts_with(&prefix)); +} + +/// The document-start script that defines the frozen `window.lithe` bridge. +/// v0 methods: context, request (state + openFile), on; build.* and +/// functions.* resolve to `plugin.web.unknownMethod` until their Windows +/// capabilities land. +fn bridge_script(plugin_id: &str, window_id: &str) -> String { + let context = json!({ + "pluginID": plugin_id, + "windowID": window_id, + "platform": "windows", + }); + const_bridge_script() + .replace("__LITHE_CONTEXT__", &context.to_string()) + .replace("__LITHE_PLUGIN__", &json!(plugin_id).to_string()) + .replace("__LITHE_WINDOW__", &json!(window_id).to_string()) +} + +/// Bridge bootstrap as a plain string; placeholders are replaced with JSON +/// literals so no brace escaping is involved. +fn const_bridge_script() -> &'static str { + r#"(() => { + "use strict"; + const context = Object.freeze(__LITHE_CONTEXT__); + const listeners = new Map(); + const invoke = (method, params) => window.__TAURI_INTERNALS__.invoke( + "declarative_plugins_web_bridge_request", + { pluginId: __LITHE_PLUGIN__, windowId: __LITHE_WINDOW__, method, params }, + ); + const bridge = { + context, + request(method, params) { + if (typeof method !== "string" || !method) { + return Promise.reject({ code: "plugin.web.invalidRequest", message: "method must be a non-empty string" }); + } + return invoke(method, params ?? null); + }, + on(event, listener) { + if (typeof event !== "string" || typeof listener !== "function") { + throw new TypeError("on(event, listener) requires a string and a function"); + } + const set = listeners.get(event) ?? new Set(); + set.add(listener); + listeners.set(event, set); + return () => set.delete(listener); + }, + }; + Object.freeze(bridge); + Object.defineProperty(window, "lithe", { value: Object.freeze(bridge), configurable: false, writable: false }); + window.__litheDeliverEvent = (event, payload) => { + const set = listeners.get(event); + if (set) for (const listener of [...set]) { + try { listener(payload); } catch { /* a failing listener must not break the pump */ } + } + }; +})();"# +} + +/// The single Tauri command behind `lithe.request`. Every method is validated +/// here; the page can only reach its own plugin's state and workspace. +#[tauri::command] +pub fn declarative_plugins_web_bridge_request( + app: AppHandle, + plugin_id: String, + window_id: String, + method: String, + params: Value, +) -> Result { + match method.as_str() { + "ui.getState" => Ok(declarative_plugins_web_get_state(app, plugin_id, window_id)), + "ui.setState" => { + let Some(state) = params.get("state") else { + return Err(bridge_error( + "plugin.web.invalidRequest", + "setState requires state", + )); + }; + declarative_plugins_web_set_state(app, plugin_id, window_id, state.clone())?; + Ok(json!({})) + } + "workspace.openFile" => { + let Some(path) = params["path"].as_str() else { + return Err(bridge_error( + "plugin.web.invalidRequest", + "openFile requires path", + )); + }; + use tauri::Emitter; + app.emit("plugin-web-open-file", json!({ "path": path })) + .map_err(|error| error.to_string())?; + Ok(json!({})) + } + "build.instances" | "build.refresh" | "build.runTask" | "build.runTests" + | "build.cancel" | "build.dependencies" | "build.setOptions" | "functions.list" + | "functions.invoke" | "functions.cancel" => Err(bridge_error( + "plugin.web.unknownMethod", + format!("{method} needs the Windows build-system capability"), + )), + other => Err(bridge_error( + "plugin.web.unknownMethod", + format!("unknown method {other}"), + )), + } +} + +fn bridge_error(code: &str, message: impl Into) -> String { + json!({ "code": code, "message": message.into() }).to_string() +} + +// MARK: scheme file service + +/// Serves one package file for the `lithe-plugin:///` origin. +/// Returns the response or a bridge-error string; the caller maps errors to +/// an HTTP status. +pub fn serve_package_file( + app: &AppHandle, + plugin_id: &str, + raw_path: &str, +) -> Result<(&'static str, Vec), String> { + let package = app + .state::() + .packages() + .into_iter() + .find(|package| package.entry.status == "accepted" && package.entry.plugin_id == plugin_id) + .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let relative = sanitize_package_path(raw_path)?; + if relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("html") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("css") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("js") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("svg") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("png") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("json") + && relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("woff2") + { + return Err(format!( + "unsupported page asset type: {}", + relative.display() + )); + } + let target = package.root.join(&relative); + let metadata = std::fs::symlink_metadata(&target) + .map_err(|error| format!("{} is unreadable: {error}", target.display()))?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x0400 != 0 { + return Err(format!("{} is a reparse point", target.display())); + } + } + #[cfg(not(windows))] + if metadata.file_type().is_symlink() { + return Err(format!("{} is a symbolic link", target.display())); + } + if !metadata.is_file() || metadata.len() > MAX_PAGE_FILE_BYTES { + return Err(format!("{} is not a servable file", target.display())); + } + let canonical_root = package + .root + .canonicalize() + .map_err(|error| format!("package root is unreadable: {error}"))?; + let canonical = target + .canonicalize() + .map_err(|error| format!("{} is unreadable: {error}", target.display()))?; + if !canonical.starts_with(&canonical_root) { + return Err(format!("{} escapes the package", target.display())); + } + let bytes = std::fs::read(&target).map_err(|error| error.to_string())?; + Ok((content_type(&relative), bytes)) +} + +/// Normalizes a URL path to a safe package-relative path: no empty segments, +/// `.`/`..`, encoded separators, or absolute forms. +fn sanitize_package_path(raw: &str) -> Result { + let decoded = percent_decode(raw); + let mut segments: Vec<&str> = Vec::new(); + for segment in decoded.split('/') { + match segment { + "" | "." => {} + ".." => return Err("path traversal is refused".to_string()), + other => segments.push(other), + } + } + if segments.is_empty() { + return Err("empty page path".to_string()); + } + let mut path = PathBuf::new(); + for segment in segments { + path.push(segment); + } + Ok(path) +} + +fn percent_decode(raw: &str) -> String { + let bytes = raw.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""); + if let Ok(byte) = u8::from_str_radix(hex, 16) { + output.push(byte); + index += 3; + continue; + } + } + output.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&output).to_string() +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("json") => "application/json", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} + +fn urlencoding_encode(value: &str) -> String { + // Plugin IDs are reverse-DNS ASCII after Core validation; only the + // characters the URL parser cares about need escaping. + value.replace('.', ".") +} + +/// Tauri scheme handler for `lithe-plugin:///`. Windows +/// routes custom schemes through `http://lithe-plugin./`, so the +/// plugin ID arrives in the URI host and the path in the request path. +pub fn handle_scheme_request( + app: &AppHandle, + request: http::Request>, +) -> http::Response> { + let ok = |content_type: &'static str, body: Vec| { + http::Response::builder() + .status(200) + .header("Content-Type", content_type) + .header("Content-Security-Policy", PAGE_CSP) + .header("X-Content-Type-Options", "nosniff") + .header("Cache-Control", "no-store") + .header("Cross-Origin-Resource-Policy", "same-origin") + .body(body) + .expect("response builds") + }; + let refused = |message: &str| { + http::Response::builder() + .status(404) + .header("Content-Type", "text/plain; charset=utf-8") + .header("Content-Security-Policy", PAGE_CSP) + .body(message.as_bytes().to_vec()) + .expect("response builds") + }; + // Windows routes custom schemes as `http://./`, so + // the URL host is `.lithe-plugin` (scheme suffix, dots kept). + let host = request.uri().host().unwrap_or_default().to_string(); + let plugin_id = restore_plugin_id(&host); + let path = request.uri().path().trim_start_matches('/').to_string(); + match serve_package_file(app, &plugin_id, &path) { + Ok((content_type, body)) => ok(content_type, body), + Err(message) => refused(&message), + } +} + +/// Restores the reverse-DNS plugin ID from the URL host form: WebView2 +/// routes `lithe-plugin://id/path` to the host `id.lithe-plugin`, so the +/// scheme suffix is stripped and dots are kept as-is. +fn restore_plugin_id(host: &str) -> String { + host.strip_suffix(".lithe-plugin") + .unwrap_or(host) + .to_string() +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageRequest { + pub plugin_id: String, + pub window_id: String, + pub method: String, + pub params: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_paths_reject_traversal_and_empty() { + assert!(sanitize_package_path("ui/index.html").is_ok()); + assert_eq!( + sanitize_package_path("ui/./index.html").expect("normalizes"), + PathBuf::from("ui").join("index.html") + ); + assert!(sanitize_package_path("../outside.html").is_err()); + assert!(sanitize_package_path("ui/../../outside.html").is_err()); + assert!(sanitize_package_path("").is_err()); + assert!(sanitize_package_path("%2e%2e/escape.html").is_err()); + assert_eq!( + sanitize_package_path("ui%2Findex.html").expect("decodes"), + PathBuf::from("ui").join("index.html") + ); + } + + #[test] + fn percent_decode_handles_encoded_and_plain() { + assert_eq!(percent_decode("a%20b"), "a b"); + assert_eq!(percent_decode("plain.html"), "plain.html"); + assert_eq!(percent_decode("bad%zz"), "bad%zz"); + } + + #[test] + fn bridge_script_is_frozen_and_scoped() { + let script = bridge_script("acme.php", "window-id"); + assert!(script.contains(r#""pluginID":"acme.php""#)); + assert!(script.contains("Object.freeze(bridge)")); + assert!(script.contains("writable: false")); + } + + #[test] + fn state_keys_separate_plugins_and_windows() { + assert_ne!(state_key("a.plugin", "w1"), state_key("a.plugin", "w2")); + assert_ne!(state_key("a.plugin", "w1"), state_key("b.plugin", "w1")); + } +} + +#[cfg(test)] +mod serve_tests { + use super::*; + + fn unique_temp_root(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-plugin-web-{label}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp root"); + root + } + + #[test] + fn serve_paths_cover_assets_and_refuse_escapes() { + // sanitize + content-type behavior without an app handle. + assert_eq!( + content_type(&sanitize_package_path("ui/index.html").expect("path")), + "text/html; charset=utf-8" + ); + assert_eq!( + content_type(&sanitize_package_path("style.css").expect("path")), + "text/css; charset=utf-8" + ); + assert!(sanitize_package_path("ui/../../secret.txt").is_err()); + } + + #[test] + fn csp_refuses_network_and_eval() { + assert!(PAGE_CSP.contains("default-src 'none'")); + assert!(PAGE_CSP.contains("script-src 'self'")); + assert!(!PAGE_CSP.contains("wasm-unsafe-eval")); + assert!(PAGE_CSP.contains("frame-ancestors 'none'")); + } +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts index 0e026ac0c4..758a31114a 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -235,3 +235,28 @@ export function resolveDeclarativeTestPlan( relativeProjectFilePaths, }); } + +/** One declared web tool window (ui capability 2). */ +export interface DeclarativeWebToolWindow { + windowId: string; + pluginId: string; + title: string; + placement: string; + order: number | null; + entry: string; +} + +/** Declared web tool windows of all accepted, enabled packages. */ +export function listDeclarativeWebWindows(): Promise { + return invoke("declarative_plugins_web_windows"); +} + +/** Opens (or focuses) the web view of one tool window. */ +export function openDeclarativeWebWindow(windowId: string): Promise { + return invoke("declarative_plugins_open_web_window", { windowId }); +} + +/** Destroys the web view of one tool window. */ +export function closeDeclarativeWebWindow(windowId: string): Promise { + return invoke("declarative_plugins_close_web_window", { windowId }); +} diff --git a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx index 098fde524e..df2fcc177e 100644 --- a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx @@ -3,9 +3,13 @@ import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Switch from "@/ui/switch"; import { + closeDeclarativeWebWindow, confirmDeclarativeDiskInstall, listDeclarativePlugins, + listDeclarativeWebWindows, + openDeclarativeWebWindow, prepareDeclarativeDiskInstall, + type DeclarativeWebToolWindow, rescanDeclarativePlugins, rollbackDeclarativePlugin, setDeclarativePluginEnabled, @@ -25,6 +29,7 @@ import { DeclarativePluginSettingsEditor } from "./declarative-plugin-settings-e export function DeclarativePluginsSection() { const { t } = useTranslation(); const [plugins, setPlugins] = useState([]); + const [webWindows, setWebWindows] = useState([]); const [pending, setPending] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -32,6 +37,7 @@ export function DeclarativePluginsSection() { const refresh = useCallback(async () => { await rescanDeclarativePlugins(); setPlugins(await listDeclarativePlugins()); + setWebWindows(await listDeclarativeWebWindows()); }, []); useEffect(() => { @@ -108,10 +114,55 @@ export function DeclarativePluginsSection() { )} + {webWindows.length > 0 && ( +
    +

    + {t("settings.declarativePlugins.webWindows")} +

    +
      + {webWindows.map((window) => ( + + ))} +
    +
    + )} ); } +function WebWindowRow({ window }: { window: DeclarativeWebToolWindow }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( +
  • +
    +

    + {window.title} + + {" "} + · {t(`settings.declarativePlugins.placement.${window.placement}`)} + +

    +
    + +
  • + ); +} + function InstallReviewCard({ pending, onConfirm, diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index c01a8c1276..7e71302543 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -2096,6 +2096,12 @@ const catalogs = { "settings.declarativePlugins.source.bundled": "bundled", "settings.declarativePlugins.source.installed": "installed", "settings.declarativePlugins.settings": "Settings", + "settings.declarativePlugins.webWindows": "Web tool windows", + "settings.declarativePlugins.openWindow": "Open", + "settings.declarativePlugins.closeWindow": "Close", + "settings.declarativePlugins.placement.left": "left", + "settings.declarativePlugins.placement.right": "right", + "settings.declarativePlugins.placement.bottom": "bottom", "settings.declarativePlugins.showSettings": "Settings…", "settings.declarativePlugins.hideSettings": "Hide settings", "settings.declarativePlugins.settingsUnavailable": "This plugin's settings could not be resolved.", @@ -6952,6 +6958,12 @@ const catalogs = { "settings.declarativePlugins.source.bundled": "内置", "settings.declarativePlugins.source.installed": "已安装", "settings.declarativePlugins.settings": "设置", + "settings.declarativePlugins.webWindows": "Web 工具窗口", + "settings.declarativePlugins.openWindow": "打开", + "settings.declarativePlugins.closeWindow": "关闭", + "settings.declarativePlugins.placement.left": "左侧", + "settings.declarativePlugins.placement.right": "右侧", + "settings.declarativePlugins.placement.bottom": "底部", "settings.declarativePlugins.showSettings": "设置…", "settings.declarativePlugins.hideSettings": "收起设置", "settings.declarativePlugins.settingsUnavailable": "无法解析该插件的设置。", From 2d9860a6a3eb0afea82067b046e9c4dc83727e7f Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Thu, 22 Oct 2026 22:53:03 +0800 Subject: [PATCH 06/13] =?UTF-8?q?chore(windows):=20=E6=B8=85=E7=90=86=20pl?= =?UTF-8?q?ugin=5Fhost/web.rs=20=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20Pag?= =?UTF-8?q?eRequest=20=E7=BB=93=E6=9E=84=E4=B8=8E=20Deserialize=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- windows/tauri/src-tauri/src/plugin_host/web.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index 3b5a4189f1..1c50144ec1 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -10,7 +10,7 @@ //! this module. use super::scan::ScannedPackage; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::{json, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -486,15 +486,6 @@ fn restore_plugin_id(host: &str) -> String { .to_string() } -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageRequest { - pub plugin_id: String, - pub window_id: String, - pub method: String, - pub params: Option, -} - #[cfg(test)] mod tests { use super::*; From e7130f96ff9cbe6ba32037c5351323e1a0c11983 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 00:22:49 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=AE=BF=E4=B8=BB=E5=AE=A1=E6=9F=A5=E6=84=8F?= =?UTF-8?q?=E8=A7=81=20=E2=80=94=E2=80=94=20=E6=9D=83=E9=99=90=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E3=80=81=E5=90=AF=E5=8A=A8=E4=BF=A1=E5=B0=81=E8=A7=A3?= =?UTF-8?q?=E5=8C=85=E4=B8=8E=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1(两项): - 插件 WebView 脱离主窗口通配 capability:main.json 收窄为 main/workspace-*, 新增 plugin-web capability(core:event 最小集,无 fs/http/shell/dialog); 桥身份改由调用 webview label 反解(bridge_identity),核对插件启用、 窗口声明与归属,伪造参数/跨插件/非插件窗口一律 plugin.web.forbidden - launch 解包 Core 响应信封后再解析(outcome["plan"]),新增走真实 Core 命令的回归测试(此前单测绕过了信封导致该路径必然失败) P2: - 设置编辑器按 ID 从宿主 catalog 取完整 manifest(新增 declarative_plugins_manifest 命令),resolve/workspaceConfiguration 不再 传 ID 占位清单;加载与解析错误保持可见 - 运行入口接通:新增 declarative_plugins_language_owner 归属查询,run 动作发现接入 useRunActionDiscovery(source: declarative),运行菜单 直接分发;删除会漂移的前端目录缓存 - 停用/卸载/回滚前关闭插件全部 Web 窗口并清状态;scheme 文件服务只服务 enabled 包 - workspace.openFile 接前端监听(plugin-web-open-file.ts):限工作区根内 路径,走 handleFileSelect(带 line/column) - 版本探测宿主只跑 versionArguments,判断委托 Core checkRuntimeVersion(共享 runtime-versions 语义),删除自建解析器 --- .../tauri/src-tauri/capabilities/main.json | 4 +- .../src-tauri/capabilities/plugin-web.json | 10 ++ windows/tauri/src-tauri/src/main.rs | 2 + .../src-tauri/src/plugin_host/install.rs | 15 ++ .../tauri/src-tauri/src/plugin_host/launch.rs | 170 ++++++++++++------ .../tauri/src-tauri/src/plugin_host/mod.rs | 16 ++ .../tauri/src-tauri/src/plugin_host/web.rs | 151 ++++++++++++++-- .../plugins/declarative-plugin-launch.ts | 16 ++ .../plugins/declarative-plugin-run.ts | 76 +++----- .../declarative-plugin-settings-editor.tsx | 36 +++- .../features/plugins/plugin-web-open-file.ts | 51 ++++++ .../components/run-actions-button.tsx | 26 ++- .../hooks/use-run-action-discovery.ts | 17 ++ .../run-actions/types/run-action.types.ts | 5 +- .../run-actions/utils/run-action-discovery.ts | 1 + windows/tauri/src/i18n/locale.ts | 2 + 16 files changed, 469 insertions(+), 129 deletions(-) create mode 100644 windows/tauri/src-tauri/capabilities/plugin-web.json create mode 100644 windows/tauri/src/features/plugins/plugin-web-open-file.ts diff --git a/windows/tauri/src-tauri/capabilities/main.json b/windows/tauri/src-tauri/capabilities/main.json index 37b11fc0de..3b190f0d00 100644 --- a/windows/tauri/src-tauri/capabilities/main.json +++ b/windows/tauri/src-tauri/capabilities/main.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2/capability", "identifier": "main-capability", - "description": "Capability for all windows", - "windows": ["*"], + "description": "Capability for the product's own windows (main and workspace views). Plugin web pages are excluded by label and receive the minimal plugin-web capability instead.", + "windows": ["main", "workspace-*"], "permissions": [ "core:default", "core:window:default", diff --git a/windows/tauri/src-tauri/capabilities/plugin-web.json b/windows/tauri/src-tauri/capabilities/plugin-web.json new file mode 100644 index 0000000000..4c92d4d468 --- /dev/null +++ b/windows/tauri/src-tauri/capabilities/plugin-web.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2/capability", + "identifier": "plugin-web-capability", + "description": "Minimal capability for declarative plugin web pages. Plugin windows (labels `plugin-web-*`) get only the core event plumbing; they deliberately receive none of the main capability's filesystem, HTTP, shell, dialog, or store permissions, so a package page cannot reach Tauri plugin APIs. Application commands (the bridge) are app-owned and gated in Rust by window label and plugin identity, not by this capability.", + "windows": ["plugin-web-*"], + "permissions": [ + "core:default", + "core:event:default" + ] +} diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index f37e62915b..c9f2ffc6ca 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -252,7 +252,9 @@ fn main() { lsp::lsp_rebuild_java_index, plugin_host::declarative_plugins_rescan, plugin_host::declarative_plugins_state, + plugin_host::declarative_plugins_manifest, plugin_host::launch::declarative_plugins_language_launch, + plugin_host::launch::declarative_plugins_language_owner, plugin_host::install::declarative_plugins_prepare_disk_install, plugin_host::install::declarative_plugins_confirm_disk_install, plugin_host::install::declarative_plugins_uninstall, diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index b01928c247..f549adeb93 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -147,6 +147,17 @@ pub fn declarative_plugins_confirm_disk_install( .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) } +/// Closes every web window of one plugin and drops its page state. Called +/// before an install-record change takes effect so a disabled, uninstalled, +/// or replaced package never keeps live pages. +fn close_plugin_web_windows(app: &AppHandle, plugin_id: &str) { + let windows = crate::plugin_host::web::web_windows_of_plugin(app, plugin_id); + for window_id in windows { + let _ = crate::plugin_host::web::close_web_window_by_id(app, &window_id); + } + crate::plugin_host::web::forget_plugin_state(app, plugin_id); +} + /// Removes an installed package. Bundled packages cannot be uninstalled. #[tauri::command] pub fn declarative_plugins_uninstall(app: AppHandle, plugin_id: String) -> Result<(), String> { @@ -162,6 +173,7 @@ pub fn declarative_plugins_uninstall(app: AppHandle, plugin_id: String) -> Resul std::fs::remove_dir_all(&state_directory) .map_err(|error| format!("state directory could not be removed: {error}"))?; } + close_plugin_web_windows(&app, &plugin_id); rescan_after_change(&app); Ok(()) } @@ -190,6 +202,9 @@ pub fn declarative_plugins_set_enabled( ) .map_err(|error| error.to_string())?; std::fs::rename(&temp_path, &record_path).map_err(|error| error.to_string())?; + if !enabled { + close_plugin_web_windows(&app, &plugin_id); + } rescan_after_change(&app); Ok(()) } diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index c2f335b36f..43fd2273ff 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -70,7 +70,8 @@ pub fn declarative_plugins_language_launch( plan["error"]["message"].as_str().unwrap_or("unknown") )); } - resolve_launch(&package, &language_id, &plan).map(Some) + // Core wraps the plan in `{status, plan}`; resolve the inner value. + resolve_launch(&package, &language_id, &plan["plan"]).map(Some) } /// Finds an accepted, enabled scanned package by plugin ID. @@ -85,6 +86,28 @@ pub(crate) fn package_by_id(app: &AppHandle, plugin_id: &str) -> Option Option { + let package = owning_package(&app, &file_path)?; + Some(OwnerInfo { + plugin_id: package.entry.plugin_id.clone(), + display_name: package.entry.display_name.clone(), + language_id: owning_language_id(&package, &file_path)?, + }) +} + +/// Ownership answer for run/test entry points. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OwnerInfo { + pub plugin_id: String, + pub display_name: String, + pub language_id: String, +} + fn owning_package(app: &AppHandle, file_path: &str) -> Option { app.state::() .packages() @@ -249,45 +272,35 @@ fn verify_runtime_version(path: &Path, tool: &str, launch: &Value) -> Result<(), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - let reported = first_version_token(&text) - .ok_or_else(|| format!("runtime tool {tool} did not report a parsable version"))?; - if store::compare_versions(&reported, minimum) == std::cmp::Ordering::Less { + // Version parsing and comparison are Core semantics (shared + // runtime-versions fixtures); the host only runs the probe and forwards + // the output. + let check = super::scan::core_execute( + "plugins.declarative.checkRuntimeVersion", + serde_json::json!({ + "tool": tool, + "minimumVersion": minimum, + "output": text, + }), + ) + .map_err(|error| format!("runtime version check failed: {error}"))?; + if check["status"] == serde_json::json!("rejected") { return Err(format!( - "runtime tool {tool} reports {reported}, but the plugin requires at least {minimum}" + "runtime tool {tool}: {}", + check["error"]["message"] + .as_str() + .unwrap_or("version check failed") )); } - Ok(()) -} - -/// First `N(.N)*` token in a version output line; tolerates the `v` prefix -/// node uses and the multi-line `java -version` output. -fn first_version_token(text: &str) -> Option { - for line in text.lines() { - for token in line.split_whitespace() { - // Strip quotes and any non-digit prefix (`v20.11.0`, - // `go1.21.5`, `"17.0.2"`). - let token = token - .trim_matches(['"', '\'']) - .trim_start_matches(|character: char| !character.is_ascii_digit()); - let mut candidate = String::new(); - for character in token.chars() { - if character.is_ascii_digit() || character == '.' { - candidate.push(character); - } else { - break; - } - } - // At least two chunks with a digit each make a version. - if candidate.matches('.').count() >= 1 - && candidate - .split('.') - .any(|chunk| chunk.parse::().is_ok()) - { - return Some(candidate); - } - } + if check["check"]["satisfied"] == serde_json::json!(false) { + return Err(format!( + "runtime tool {tool}: {}", + check["check"]["reason"] + .as_str() + .unwrap_or("the runtime does not satisfy the required minimum version") + )); } - None + Ok(()) } /// Joins a package-relative plan path and proves it stays inside the package @@ -426,24 +439,34 @@ mod tests { ); } + /// Version judgment goes through Core's checkRuntimeVersion: outputs the + /// shared fixtures accept (a `java -version` banner line before the + /// version, an `openjdk 25` two-part version) are judged by Core, not by + /// host parsing. #[test] - fn first_version_token_parses_real_tool_outputs() { - assert_eq!( - first_version_token( - "v20.11.0 -" - ), - Some("20.11.0".to_string()) - ); - assert_eq!( - first_version_token("openjdk version \"17.0.2\" 2022-01-18"), - Some("17.0.2".to_string()) - ); - assert_eq!( - first_version_token("go version go1.21.5 windows/amd64"), - Some("1.21.5".to_string()) - ); - assert_eq!(first_version_token("no version here"), None); + fn version_check_delegates_to_core_semantics() { + let check = super::super::scan::core_execute( + "plugins.declarative.checkRuntimeVersion", + serde_json::json!({ + "tool": "java", + "minimumVersion": "21", + "output": "Picked up JAVA_TOOL_OPTIONS: -Dapp.version=99.0\nopenjdk version \"25\" 2026-09-16", + }), + ) + .expect("core check executes"); + assert_eq!(check["status"], "ok", "{check}"); + assert_eq!(check["check"]["satisfied"], true); + + let stale = super::super::scan::core_execute( + "plugins.declarative.checkRuntimeVersion", + serde_json::json!({ + "tool": "java", + "minimumVersion": "21", + "output": "openjdk version \"17.0.2\" 2022-01-18", + }), + ) + .expect("core check executes"); + assert_eq!(stale["check"]["satisfied"], false); } #[test] @@ -478,6 +501,47 @@ mod tests { ); } + #[test] + fn resolve_launch_consumes_the_real_core_response_envelope() { + let temporary = unique_temp_root("launch-envelope"); + let package_root = temporary.as_path().join("pkg"); + let server = package_root.join("bin").join("server.exe"); + std::fs::create_dir_all(server.parent().unwrap()).expect("bin dir"); + std::fs::write(&server, b"binary").expect("server file"); + // The bundled fixture is API 1 (no settings requirement) with a + // packaged launch, so a real plan request succeeds. + let mut manifest = + crate::plugin_host::tests::read_fixture_for_tests("php-support.plugin.json"); + manifest["languageServers"][0]["launch"] = serde_json::json!({ + "kind": "packaged", + "path": "bin/server.exe" + }); + let package = package_with_root(package_root, manifest); + let response = super::super::scan::core_execute( + "plugins.declarative.languageServerLaunchPlan", + serde_json::json!({ + "manifest": package.manifest, + "languageID": "php", + "request": launch_request( + &temporary.as_path().join("state"), + &package, + &temporary.as_path().to_string_lossy(), + crate::plugin_host::settings::SettingLayers { + global: Default::default(), + project: Default::default(), + }, + ), + }), + ) + .expect("real Core command executes"); + assert_eq!(response["status"], "ok", "{response}"); + // The production path passes the wrapped outcome; only the inner + // plan carries the launch. + let resolved = resolve_launch(&package, "php", &response["plan"]).expect("resolves"); + assert_eq!(resolved.server_path, server.to_string_lossy()); + let _ = std::fs::remove_dir_all(&temporary); + } + #[test] fn packaged_launches_resolve_inside_the_package() { let temporary = unique_temp_root("launch"); diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 3de61a17c0..aa2604bd73 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -146,3 +146,19 @@ pub fn declarative_plugins_state(app: AppHandle) -> Vec { .map(|package| package.entry) .collect() } + +/// Returns the raw manifest of one accepted, enabled plugin; settings pages +/// must pass the full manifest to Core's resolve commands, which re-validate +/// it. `None` when the ID does not name an accepted, enabled plugin. +#[tauri::command] +pub fn declarative_plugins_manifest(app: AppHandle, plugin_id: String) -> Option { + app.state::() + .packages() + .into_iter() + .find(|package| { + package.entry.status == "accepted" + && package.entry.enabled + && package.entry.plugin_id == plugin_id + }) + .map(|package| package.manifest) +} diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index 1c50144ec1..522786cc7b 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -102,7 +102,7 @@ pub fn declarative_plugins_open_web_window( else { return Err(format!("{window_id} is not a declared web tool window")); }; - let label = format!("plugin-web-{}", window_id.replace(['.', ':'], "-")); + let label = format!("plugin-web-{}", web_window_label(&window.window_id)); if let Some(existing) = app.get_webview_window(&label) { let _ = existing.set_focus(); return Ok(()); @@ -134,7 +134,7 @@ pub fn declarative_plugins_close_web_window( app: AppHandle, window_id: String, ) -> Result<(), String> { - let label = format!("plugin-web-{}", window_id.replace(['.', ':'], "-")); + let label = format!("plugin-web-{}", web_window_label(&window_id)); if let Some(existing) = app.get_webview_window(&label) { existing .close() @@ -216,7 +216,7 @@ fn const_bridge_script() -> &'static str { const listeners = new Map(); const invoke = (method, params) => window.__TAURI_INTERNALS__.invoke( "declarative_plugins_web_bridge_request", - { pluginId: __LITHE_PLUGIN__, windowId: __LITHE_WINDOW__, method, params }, + { method, params }, ); const bridge = { context, @@ -247,18 +247,26 @@ fn const_bridge_script() -> &'static str { })();"# } -/// The single Tauri command behind `lithe.request`. Every method is validated -/// here; the page can only reach its own plugin's state and workspace. +/// The single Tauri command behind `lithe.request`. The calling page's +/// identity is derived from the invoking webview's label +/// (`plugin-web-`), never from arguments the page supplies; the +/// plugin is then resolved from the accepted catalog and must currently +/// declare this window and be enabled. A page can only reach its own +/// plugin's state and the workspace layer. #[tauri::command] pub fn declarative_plugins_web_bridge_request( app: AppHandle, - plugin_id: String, - window_id: String, + webview: tauri::Webview, method: String, params: Value, ) -> Result { + let identity = bridge_identity(&app, webview.label())?; match method.as_str() { - "ui.getState" => Ok(declarative_plugins_web_get_state(app, plugin_id, window_id)), + "ui.getState" => Ok(declarative_plugins_web_get_state( + app, + identity.plugin_id, + identity.window_id, + )), "ui.setState" => { let Some(state) = params.get("state") else { return Err(bridge_error( @@ -266,7 +274,12 @@ pub fn declarative_plugins_web_bridge_request( "setState requires state", )); }; - declarative_plugins_web_set_state(app, plugin_id, window_id, state.clone())?; + declarative_plugins_web_set_state( + app, + identity.plugin_id, + identity.window_id, + state.clone(), + )?; Ok(json!({})) } "workspace.openFile" => { @@ -277,8 +290,16 @@ pub fn declarative_plugins_web_bridge_request( )); }; use tauri::Emitter; - app.emit("plugin-web-open-file", json!({ "path": path })) - .map_err(|error| error.to_string())?; + app.emit( + "plugin-web-open-file", + json!({ + "pluginId": identity.plugin_id, + "path": path, + "line": params["line"].as_u64(), + "column": params["column"].as_u64(), + }), + ) + .map_err(|error| error.to_string())?; Ok(json!({})) } "build.instances" | "build.refresh" | "build.runTask" | "build.runTests" @@ -294,10 +315,111 @@ pub fn declarative_plugins_web_bridge_request( } } +/// Identity of a bridge caller, derived from its window label. +#[derive(Debug, Clone, PartialEq)] +pub struct BridgeIdentity { + pub plugin_id: String, + pub window_id: String, +} + +/// Resolves and authorizes a bridge caller from its webview label. The label +/// is `plugin-web-` (see `open_web_window`); the window ID +/// is the label-safe form of a declared tool-window ID, so authorization +/// re-derives it from the owning package's current catalog entry. Anything +/// that does not round-trip — a label from another window kind, a window the +/// plugin no longer declares, a disabled or uninstalled plugin, or a forged +/// label pointing at another plugin's window — is refused with +/// `plugin.web.forbidden`. +fn bridge_identity( + app: &AppHandle, + label: &str, +) -> Result { + const LABEL_PREFIX: &str = "plugin-web-"; + let Some(remainder) = label.strip_prefix(LABEL_PREFIX) else { + return Err(bridge_error( + "plugin.web.forbidden", + "bridge requests are only accepted from plugin pages", + )); + }; + let packages = app.state::().packages(); + for package in &packages { + if package.entry.status != "accepted" || !package.entry.enabled { + continue; + } + let Some(tool_windows) = package.manifest["ui"]["toolWindows"].as_array() else { + continue; + }; + for window in tool_windows { + let Some(window_id) = window["id"].as_str() else { + continue; + }; + if window["web"]["entry"].is_null() { + continue; + } + if web_window_label(window_id) == remainder { + return Ok(BridgeIdentity { + plugin_id: package.entry.plugin_id.clone(), + window_id: window_id.to_string(), + }); + } + } + } + Err(bridge_error( + "plugin.web.forbidden", + "the calling window does not belong to an enabled plugin page", + )) +} + +/// The webview label of one tool window: a sanitized form of the declared +/// window ID, safe for Tauri labels. +pub(crate) fn web_window_label(window_id: &str) -> String { + window_id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' || character == '_' { + character + } else { + '-' + } + }) + .collect() +} + fn bridge_error(code: &str, message: impl Into) -> String { json!({ "code": code, "message": message.into() }).to_string() } +/// Declared web window IDs of one plugin from the current catalog. +pub fn web_windows_of_plugin( + app: &AppHandle, + plugin_id: &str, +) -> Vec { + app.state::() + .packages() + .into_iter() + .filter(|package| package.entry.plugin_id == plugin_id) + .flat_map(|package| { + package.manifest["ui"]["toolWindows"] + .as_array() + .map(|windows| { + windows + .iter() + .filter_map(|window| window["id"].as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default() + }) + .collect() +} + +/// Closes one web window by declared window ID, tolerating absence. +pub fn close_web_window_by_id(app: &AppHandle, window_id: &str) { + let label = format!("plugin-web-{}", web_window_label(window_id)); + if let Some(existing) = app.get_webview_window(&label) { + let _ = existing.close(); + } +} + // MARK: scheme file service /// Serves one package file for the `lithe-plugin:///` origin. @@ -522,6 +644,13 @@ mod tests { assert!(script.contains("writable: false")); } + #[test] + fn web_window_labels_are_stable_and_sanitized() { + // Declared IDs are reverse-DNS with dots; labels replace them. + assert_eq!(web_window_label("dev.lithe.window"), "dev-lithe-window"); + assert_eq!(web_window_label("acme:panel"), "acme-panel"); + assert_eq!(web_window_label("acme-panel"), "acme-panel"); + } #[test] fn state_keys_separate_plugins_and_windows() { assert_ne!(state_key("a.plugin", "w1"), state_key("a.plugin", "w2")); diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts index 758a31114a..99bf404252 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -260,3 +260,19 @@ export function openDeclarativeWebWindow(windowId: string): Promise { export function closeDeclarativeWebWindow(windowId: string): Promise { return invoke("declarative_plugins_close_web_window", { windowId }); } + +/** Returns the raw manifest of one accepted declarative plugin. */ +export function getDeclarativePluginManifest( + pluginId: string, +): Promise | null> { + return invoke | null>("declarative_plugins_manifest", { pluginId }); +} + +/** The enabled declarative plugin that owns a file, or null. */ +export function invokeDeclarativePluginOwner(filePath: string): Promise<{ + pluginId: string; + displayName: string; + languageId: string; +} | null> { + return invoke("declarative_plugins_language_owner", { filePath }); +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-run.ts b/windows/tauri/src/features/plugins/declarative-plugin-run.ts index f4de30140a..d33dbd90dd 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-run.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-run.ts @@ -7,8 +7,9 @@ import { useRunStore, } from "@/features/run/stores/run.store"; import { useUIState } from "@/features/window/stores/ui-state.store"; +import type { RunActionItem } from "@/features/run-actions/types/run-action.types"; import { - listDeclarativePlugins, + invokeDeclarativePluginOwner, resolveDeclarativeRunPlan, type DeclarativeRunPlan, } from "./declarative-plugin-launch"; @@ -18,67 +19,41 @@ import { declarativePluginProcessOwner } from "./declarative-plugin-process-owne * Run actions for contract v1 plugins, mirroring the Worker extension run * path: Core expands the declared template, the shared run pipeline resolves * and starts the process, and the process owner tracks it per workspace and - * plugin so disabling or uninstalling stops its sessions. + * plugin so disabling or uninstalling stops its sessions. File ownership + * comes from the host's live catalog (`declarative_plugins_language_owner`), + * so there is no second catalog to drift. */ -export interface DeclarativeRunTarget { - /** Run session ID, shaped `declarative::`. */ - id: string; - title: string; -} - -export function isDeclarativeRunEnabled(pluginId: string): boolean { - return findAcceptedPlugin(pluginId)?.enabled === true; -} - -function findAcceptedPlugin(pluginId: string) { - // Synchronous accessors are not available on the cached Tauri state; the - // frontend keeps the scanned catalog in this module after each rescan. - return declarativeCatalog.find((plugin) => plugin.pluginId === pluginId); -} - -let declarativeCatalog: Awaited> = []; - -/** Refreshes the cached catalog; call after rescans and installs. */ -export function refreshDeclarativeCatalog(): void { - void listDeclarativePlugins().then((plugins) => { - declarativeCatalog = plugins; - }); -} - -export function declarativePluginForFile( +/** The declarative run action for the file's owner, or null. */ +export async function declarativeRunActionForFile( filePath: string, -): { pluginId: string; languageId: string } | null { - const lower = filePath.toLowerCase(); - const dot = lower.lastIndexOf("."); - const extension = dot === -1 ? "" : lower.slice(dot + 1); - for (const plugin of declarativeCatalog) { - if (plugin.status !== "accepted" || !plugin.enabled) continue; - for (const language of plugin.languages) { - if (language.fileExtensions.some((declared) => declared.toLowerCase() === extension)) { - return { pluginId: plugin.pluginId, languageId: language.id }; - } - } - } - return null; +): Promise { + const owner = await invokeDeclarativePluginOwner(filePath); + if (!owner) return null; + return { + id: `declarative:${owner.pluginId}:${owner.languageId}`, + name: owner.displayName, + description: owner.languageId, + source: "declarative", + sourceLabel: owner.displayName, + declarativeOwner: { pluginId: owner.pluginId, languageId: owner.languageId }, + }; } -export async function runDeclarativePluginFile( - filePath: string, +export async function runDeclarativeAction( + action: RunActionItem, workspaceId: string, root: string, - userArguments: string[] = [], + filePath: string, ): Promise { - const owner = declarativePluginForFile(filePath); - if (!owner) throw new Error("No enabled declarative plugin owns this file."); - const relative = toWorkspaceRelative(filePath, root); + const owner = action.declarativeOwner; + if (!owner) throw new Error("The action does not name a declarative plugin."); const plan = await resolveDeclarativeRunPlan( owner.pluginId, owner.languageId, - relative, - userArguments, + toWorkspaceRelative(filePath, root), ); - await startDeclarativePlan(plan, workspaceId, root, relative); + await startDeclarativePlan(plan, workspaceId, root, filePath); } async function startDeclarativePlan( @@ -101,7 +76,6 @@ async function startDeclarativePlan( executable: plan.executable, workingDirectory: plan.workingDirectory, }); - if (!isDeclarativeRunEnabled(plan.pluginId)) return; bindRunSessionWorkspace(id, workspaceId); store.setState((state) => ({ sessions: [ diff --git a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx index d4d9cf9346..2be6d87da9 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx @@ -4,6 +4,7 @@ import Switch from "@/ui/switch"; import NumberInput from "@/ui/number-input"; import SettingsSelect from "@/ui/settings-select"; import { + getDeclarativePluginManifest, resolveDeclarativeSettings, storeDeclarativeSetting, declarativeWorkspaceConfiguration, @@ -36,11 +37,22 @@ export function DeclarativePluginSettingsEditor({ const [values, setValues] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + // Core re-validates the whole manifest on every resolve call, so the + // editor must send the full raw manifest from the host catalog — not an + // ID placeholder. + const [manifest, setManifest] = useState | null>(null); + + useEffect(() => { + getDeclarativePluginManifest(pluginId) + .then((loaded) => setManifest(loaded)) + .catch((reason: unknown) => setError(String(reason))); + }, [pluginId]); const refresh = useCallback(async () => { setError(null); + if (!manifest) return; try { - const outcome = await resolveDeclarativeSettings({ id: pluginId }, workspacePath ?? ""); + const outcome = await resolveDeclarativeSettings(manifest, workspacePath ?? ""); if (outcome.status !== "ok") { setError(t("settings.declarativePlugins.settingsUnavailable")); return; @@ -49,7 +61,7 @@ export function DeclarativePluginSettingsEditor({ } catch (reason) { setError(String(reason)); } - }, [pluginId, workspacePath, t]); + }, [manifest, workspacePath, t]); useEffect(() => { void refresh(); @@ -64,12 +76,8 @@ export function DeclarativePluginSettingsEditor({ const stored = value === setting.default ? null : value; await storeDeclarativeSetting(pluginId, setting.id, scope, workspacePath ?? "", stored); await refresh(); - if (setting.effect === "immediate" && languageId) { - await declarativeWorkspaceConfiguration( - { id: pluginId }, - languageId, - workspacePath ?? "", - ); + if (setting.effect === "immediate" && languageId && manifest) { + await declarativeWorkspaceConfiguration(manifest, languageId, workspacePath ?? ""); } } catch (reason) { setError(String(reason)); @@ -78,7 +86,17 @@ export function DeclarativePluginSettingsEditor({ } }; - if (!values.length) return null; + // Loading and error states stay visible; an empty settings list never + // hides a failed resolve. + if (!manifest) { + return error ? ( +
    +

    + {error} +

    +
    + ) : null; + } return (

    {t("settings.declarativePlugins.settings")}

    diff --git a/windows/tauri/src/features/plugins/plugin-web-open-file.ts b/windows/tauri/src/features/plugins/plugin-web-open-file.ts new file mode 100644 index 0000000000..436ad11d8c --- /dev/null +++ b/windows/tauri/src/features/plugins/plugin-web-open-file.ts @@ -0,0 +1,51 @@ +import { listen } from "@tauri-apps/api/event"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; + +interface PluginWebOpenFilePayload { + pluginId: string; + path: string | null; + line: number | null; + column: number | null; +} + +/** + * Handles `workspace.openFile` bridge requests from plugin web pages. The + * Rust bridge authorizes the calling page's plugin; this listener routes the + * request into the workspace's canonical file-open entry and confines the + * path to the open workspace root (the bridge contract only allows paths + * inside the workspace). + */ +export async function initializePluginWebOpenFileListener(): Promise<() => void> { + const unlisten = await listen( + "plugin-web-open-file", + ({ payload }) => { + if (!payload?.path) return; + const workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); + const store = useFileSystemStore.getState(); + const root = store.rootFolderPath; + if (!root || !pathIsInsideWorkspace(payload.path, root)) return; + void store + .handleFileSelect( + payload.path, + false, + payload.line ?? undefined, + payload.column ?? undefined, + undefined, + false, + ) + .catch(() => { + // A failed open is reported by the store's own error surface; the + // page only gets an ack either way. + }); + }, + ); + return unlisten; +} + +function pathIsInsideWorkspace(path: string, root: string): boolean { + const normalized = path.replace(/\\/g, "/"); + const normalizedRoot = root.replace(/\\/g, "/").replace(/\/$/, ""); + if (normalized.toLowerCase() === normalizedRoot.toLowerCase()) return false; + return normalized.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`); +} diff --git a/windows/tauri/src/features/run-actions/components/run-actions-button.tsx b/windows/tauri/src/features/run-actions/components/run-actions-button.tsx index 9b834c8060..cfb98a98e0 100644 --- a/windows/tauri/src/features/run-actions/components/run-actions-button.tsx +++ b/windows/tauri/src/features/run-actions/components/run-actions-button.tsx @@ -1,4 +1,5 @@ import { runExtensionAction } from "@/extensions/run/extension-run-actions"; +import { runDeclarativeAction } from "@/features/plugins/declarative-plugin-run"; import { RefreshCw } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -131,7 +132,7 @@ export default function RunActionsButton() { const [draft, setDraft] = useState(EMPTY_DRAFT); const triggerRef = useRef(null); const searchInputRef = useRef(null); - const { projectActions, lspActions, isDiscovering, discoveryError, refresh } = + const { projectActions, declarativeAction, lspActions, isDiscovering, discoveryError, refresh } = useRunActionDiscovery( workspaceId, workspacePath, @@ -164,8 +165,16 @@ export default function RunActionsButton() { () => customRunActions.filter((action) => matchesRunAction(action, query)), [customRunActions, query], ); + const visibleDeclarativeActions = useMemo( + () => + declarativeAction && matchesRunAction(declarativeAction, query) ? [declarativeAction] : [], + [declarativeAction, query], + ); const firstVisibleAction = - visibleLspActions[0] ?? visibleProjectActions[0] ?? visibleCustomActions[0]; + visibleLspActions[0] ?? + visibleProjectActions[0] ?? + visibleDeclarativeActions[0] ?? + visibleCustomActions[0]; const hasVisibleActions = Boolean(firstVisibleAction); const closeMenu = () => setIsMenuOpen(false); @@ -185,6 +194,14 @@ export default function RunActionsButton() { }; const runAction = (action: RunActionItem) => { + if (action.source === "declarative") { + if (workspacePath && activeFilePath) + void runDeclarativeAction(action, workspaceId, workspacePath, activeFilePath).catch( + (error) => toast.error(String(error)), + ); + closeMenu(); + return; + } if (action.source === "extension") { if (workspacePath) void runExtensionAction(action, workspaceId, workspacePath).catch((error) => @@ -352,6 +369,11 @@ export default function RunActionsButton() { actions={visibleLspActions} onRun={runAction} /> + (null); + useEffect(() => { + if (!enabled || !activeFilePath) { + setDeclarativeAction(null); + return; + } + let cancelled = false; + void declarativeRunActionForFile(activeFilePath).then((action) => { + if (!cancelled) setDeclarativeAction(action); + }); + return () => { + cancelled = true; + }; + }, [activeFilePath, enabled, revision]); + const lspActions = useMemo( () => activeFilePath @@ -114,6 +130,7 @@ export function useRunActionDiscovery( projectActions: projectActions.filter( (action) => !action.extensionId || enabledExtensions.split("\n").includes(action.extensionId), ), + declarativeAction, lspActions, isDiscovering, discoveryError, diff --git a/windows/tauri/src/features/run-actions/types/run-action.types.ts b/windows/tauri/src/features/run-actions/types/run-action.types.ts index 98070de0a4..a20664433f 100644 --- a/windows/tauri/src/features/run-actions/types/run-action.types.ts +++ b/windows/tauri/src/features/run-actions/types/run-action.types.ts @@ -10,9 +10,12 @@ export type RunActionSource = | "go" | "python" | "extension" - | "lsp"; + | "lsp" + | "declarative"; export interface RunActionItem { + /** Owning declarative plugin, for `source: "declarative"` items. */ + declarativeOwner?: { pluginId: string; languageId: string }; id: string; name: string; command?: string; diff --git a/windows/tauri/src/features/run-actions/utils/run-action-discovery.ts b/windows/tauri/src/features/run-actions/utils/run-action-discovery.ts index 121cca10f7..a7d8cd93a8 100644 --- a/windows/tauri/src/features/run-actions/utils/run-action-discovery.ts +++ b/windows/tauri/src/features/run-actions/utils/run-action-discovery.ts @@ -36,6 +36,7 @@ const SOURCE_PRIORITY: Record = { go: 6, python: 7, extension: 8, + declarative: 9, }; export function javaTestActionsForFile( diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 7e71302543..e736ca10dd 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1891,6 +1891,7 @@ const catalogs = { "run.scanning": "Scanning", "run.filterActions": "Filter actions", "run.currentFile": "Current file", + "run.declarativePlugins": "Declarative plugins", "run.detectedInProject": "Detected in project", "run.custom": "Custom", "run.scanningProjectActions": "Scanning project actions", @@ -6761,6 +6762,7 @@ const catalogs = { "run.scanning": "正在扫描", "run.filterActions": "筛选操作", "run.currentFile": "当前文件", + "run.declarativePlugins": "声明式插件", "run.detectedInProject": "项目中检测到", "run.custom": "自定义", "run.scanningProjectActions": "正在扫描项目操作", From a3c4dcae1a82ac3a3eca9587ef047b3e34877036 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 15:02:38 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E8=BD=AE=20review=20=E6=84=8F=E8=A7=81=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20IPC=20=E5=91=BD=E4=BB=A4=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E4=B8=8E=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=8E=A5=E7=BA=BF=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: - invoke_handler 包装插件页门控:label 为 plugin-web-* 的调用只允许 declarative_plugins_web_bridge_request(改为直调的普通函数,消费原始 Invoke,绕过宏推断问题),其余应用命令(run_start_process 等进程/文件/ HTTP 面)一律 resolver.reject——插件页面无法再通过原始 Tauri IPC 调用 宿主命令 - generate_handler 宏在非直接实参位置无法推断 R:用 Box> 在绑定处钉定(本地最小复现 crate 验证) P2: - initializePluginWebOpenFileListener 真正接入 bootstrap(此前只有定义) - immediate 设置:编辑器取 Core 展开的 configuration 后经 lsp.updateWorkspaceConfiguration 下推到运行中的 LSP 会话 - 停用/卸载时 Rust 发 declarative-plugin-lifecycle 事件,前端进程 owner 停止该插件全部声明式运行会话 - test plan 产品入口:declares_tests 标记进入 catalog,运行菜单的声明式 动作按插件测试模板走 testPlan(v1 传当前文件供项目标记判定) - 同版本重装激活失败时备份/恢复原版本目录,不再出现安装记录指向缺失 字节的隔离状态(新增恢复路径测试) P3: - 共享契约 README 更新 Windows ui: [1,2] 状态与桥 v0 方法范围 --- shared/contracts/plugins/v1/README.md | 7 +- .../src-tauri/src/agent_provider_settings.rs | 38 ++- windows/tauri/src-tauri/src/main.rs | 293 ++++++++++-------- .../src-tauri/src/plugin_host/install.rs | 39 ++- .../src/plugin_host/install_tests.rs | 40 +++ .../tauri/src-tauri/src/plugin_host/launch.rs | 1 + .../tauri/src-tauri/src/plugin_host/mod.rs | 2 + .../tauri/src-tauri/src/plugin_host/scan.rs | 8 + .../tauri/src-tauri/src/plugin_host/web.rs | 90 +++++- .../src/features/bootstrap/bootstrap-async.ts | 4 + .../plugins/declarative-plugin-launch.ts | 1 + .../plugins/declarative-plugin-run.ts | 32 +- .../declarative-plugin-settings-editor.tsx | 18 +- .../features/plugins/plugin-web-open-file.ts | 25 ++ .../run-actions/types/run-action.types.ts | 2 + .../tauri/src/platform/lsp-core-adapter.ts | 20 ++ 16 files changed, 450 insertions(+), 170 deletions(-) diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index ba81f07cdf..945ac5c9ed 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1338,8 +1338,11 @@ consent, and closing it cancels them. Reusing a `call` ID is host's provider requests: once given for a plugin and workspace, it holds until the project is closed. macOS implements web content with WKWebView and advertises `ui: [1, 2]`. -Windows has no WebView2 host yet and still advertises `ui: [1]`, so Core refuses -`ui` 2 plugins there with `plugin.api.unsupported`. +Windows implements web content with WebView2 under a plugin-private +`lithe-plugin://` origin and also advertises `ui: [1, 2]`; its bridge v0 +answers `ui.getState`, `ui.setState`, and `workspace.openFile`, and returns +`plugin.web.unknownMethod` for `build.*` and `functions.*` until the Windows +build-system capability lands. ### Future public extension direction (not implemented in API level 2) diff --git a/windows/tauri/src-tauri/src/agent_provider_settings.rs b/windows/tauri/src-tauri/src/agent_provider_settings.rs index a98ba27dcb..539e0162ef 100644 --- a/windows/tauri/src-tauri/src/agent_provider_settings.rs +++ b/windows/tauri/src-tauri/src/agent_provider_settings.rs @@ -29,8 +29,12 @@ pub fn commit_agent_panel_settings( next: Value, credential: Option, ) -> Result<(), String> { - let _guard = TRANSACTION.lock().map_err(|_| "agent.settings.saveFailed")?; - let store = app.store("settings.json").map_err(|_| "agent.settings.saveFailed")?; + let _guard = TRANSACTION + .lock() + .map_err(|_| "agent.settings.saveFailed")?; + let store = app + .store("settings.json") + .map_err(|_| "agent.settings.saveFailed")?; let current = store.get("agentPanel"); matches_revision(¤t, &expected)?; if !next.is_object() { @@ -42,28 +46,44 @@ pub fn commit_agent_panel_settings( if next.get("agentId").and_then(Value::as_str) != Some(change.agent_id.as_str()) { return Err("agent.settings.conflict".into()); } - let provider_id = next.get("provider").and_then(|v| v.get("id")).and_then(Value::as_str); + let provider_id = next + .get("provider") + .and_then(|v| v.get("id")) + .and_then(Value::as_str); // Removal may delete the selected provider; its credential still belongs // to the expected revision rather than whichever provider is now selected. - let old_id = expected.as_ref().and_then(|v| v.get("provider")) - .and_then(|v| v.get("id")).and_then(Value::as_str); + let old_id = expected + .as_ref() + .and_then(|v| v.get("provider")) + .and_then(|v| v.get("id")) + .and_then(Value::as_str); if change.provider_id.as_deref() != provider_id && change.provider_id.as_deref() != old_id { return Err("agent.settings.conflict".into()); } let mut key = format!("agent-provider-api-key/{}", change.agent_id); - if let Some(id) = change.provider_id { key.push('/'); key.push_str(&id); } + if let Some(id) = change.provider_id { + key.push('/'); + key.push_str(&id); + } previous_secret = crate::secure_storage::get_secure_secret(app.clone(), key.clone())?; if change.value.trim().is_empty() { crate::secure_storage::remove_secure_secret(app.clone(), key.clone())?; } else { - crate::secure_storage::store_secure_secret(app.clone(), key.clone(), change.value.trim().into())?; + crate::secure_storage::store_secure_secret( + app.clone(), + key.clone(), + change.value.trim().into(), + )?; } secret_key = Some(key); } store.set("agentPanel", next); if store.save().is_err() { - if let Some(value) = current { store.set("agentPanel", value); } - else { store.delete("agentPanel"); } + if let Some(value) = current { + store.set("agentPanel", value); + } else { + store.delete("agentPanel"); + } if let Some(key) = secret_key { match previous_secret { Some(value) => crate::secure_storage::store_secure_secret(app, key, value)?, diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 1e0ba35bfa..194b1c3439 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -170,143 +170,132 @@ fn main() { } } }) - .invoke_handler(tauri::generate_handler![ - date_time::format_system_date_time, - agent::agent_open, - agent::agent_send, - agent::agent_close, - document::read_document_file, - document::read_document_file_details, - document::read_document_file_change, - document::save_document_file, - document::resolve_agent_document_path, - document::save_agent_document, - document::trash_agent_document, - document::set_document_watches, - core::core_execute, - core::core_cancel, - core::ide_host_paths, - diagnostics::preview_diagnostic_bundle, - diagnostics::export_diagnostic_bundle, - debug::debug_start_session, - debug::debug_connect_session, - debug::debug_allocate_loopback_port, - debug::debug_wait_for_port, - debug::debug_session_ready, - debug::debug_send_request, - debug::debug_stop_session, - debug::debug_stop_workspace_sessions, - platform::platform_invoke, - memory::get_application_memory_usage, - terminal::begin_frontend_terminal_session, - terminal::warm_terminal_environment, - terminal::create_terminal, - terminal::terminal_write, - terminal::terminal_resize, - terminal::terminal_set_paused, - terminal::close_terminal, - terminal::list_shells, - watcher::start_watching, - watcher::stop_watching, - watcher::set_project_root, - watcher::watch_git_repository, - watcher::unwatch_git_repository, - secure_storage::store_secure_secret, - secure_storage::get_secure_secret, - secure_storage::remove_secure_secret, - agent_provider_settings::commit_agent_panel_settings, - logging::get_log_settings, - logging::set_log_directory, - logging::set_diagnostic_logging, - logging::read_lithe_log, - logging::query_lithe_logs, - diagnostics::discard_diagnostic_bundle, - logging::clear_lithe_logs, - logging::resolve_previous_log_cleanup, - logging::open_log_directory, - logging::frontend_trace, - logging::record_startup_milestone, - host::get_system_theme, - host::set_native_window_appearance, - fonts::get_system_fonts, - fonts::get_monospace_fonts, - fonts::validate_font, - host::get_bundled_extensions_path, - host::read_local_file, - host::read_local_file_bounded, - host::read_file_custom, - host::write_file, - host::write_patch_file, - shelf_storage::saved_changes_storage, - host::move_file, - host::rename_file, - host::get_symlink_info, - host::open_file_external, - host::open_html_in_browser, - host::take_pending_cli_open_requests, - host::clipboard_set, - host::clipboard_get, - host::clipboard_paste, - host::clipboard_clear, - host::clipboard_sequence_number, - agent_clipboard::agent_clipboard_file_paths, - host::wsl_automount_root, - host::create_app_window, - project_windows::claim_project_window, - project_windows::release_project_window, - project_windows::release_pending_project_window, - window_title::update_window_title_context, - lsp::lsp_resolve_java_launch, - lsp::lsp_rebuild_java_index, - plugin_host::declarative_plugins_rescan, - plugin_host::declarative_plugins_state, - plugin_host::declarative_plugins_manifest, - plugin_host::launch::declarative_plugins_language_launch, - plugin_host::launch::declarative_plugins_language_owner, - plugin_host::install::declarative_plugins_prepare_disk_install, - plugin_host::install::declarative_plugins_confirm_disk_install, - plugin_host::install::declarative_plugins_uninstall, - plugin_host::install::declarative_plugins_set_enabled, - plugin_host::install::declarative_plugins_rollback, - plugin_host::settings::declarative_plugins_resolve_settings, - plugin_host::settings::declarative_plugins_store_setting, - plugin_host::settings::declarative_plugins_workspace_configuration, - plugin_host::run_plans::declarative_plugins_run_plan, - plugin_host::run_plans::declarative_plugins_test_plan, - plugin_host::web::declarative_plugins_web_windows, - plugin_host::web::declarative_plugins_open_web_window, - plugin_host::web::declarative_plugins_close_web_window, - plugin_host::web::declarative_plugins_web_bridge_request, - extension_store_placeholder::install_extension_from_url, - extension_store_placeholder::uninstall_extension_new, - extension_store_placeholder::get_extension_path, - extension_store_placeholder::read_extension_entrypoint, - extension_store_placeholder::get_importable_ide_projects, - language_tools::get_tool_path, - language_tools::install_language_tools, - language_tools::check_language_tool_requirements, - language_tools::cancel_language_tool_install, - language_tools::uninstall_language_tools, - maven::maven_load_configuration, - maven::maven_resolve_effective_configuration, - maven::maven_write_configuration, - maven::maven_create_dependency_output, - maven::maven_remove_dependency_output, - run::run_list_java_sources, - run::run_write_generated, - run::run_write_documents, - run::run_write_stdin, - run::maven_resolve_installation, - run::run_discover_toolchains, - run::run_resolve_launch, - run::run_resolve_toolchains, - run::run_execute_prelaunch, - run::run_start_process, - run::run_stop_process, - run::run_list_owned_processes, - run::run_stop_owned_processes, - owned_processes::app_prepare_update_install, - ]) + // Plugin pages (labels `plugin-web-*`) reach exactly one audited + // command; every other application command is refused before any + // handler runs, so a package page cannot invoke host commands + // (process launch, filesystem, HTTP) through raw Tauri IPC. + .invoke_handler( + |invoke: tauri::ipc::Invoke| match plugin_gate(invoke) { + PluginGate::Handled => true, + PluginGate::Pass(invoke) => { + let handler: Box> = + Box::new(tauri::generate_handler![ + date_time::format_system_date_time, + agent::agent_open, + agent::agent_send, + agent::agent_close, + document::read_document_file, + document::read_document_file_details, + document::read_document_file_change, + document::save_document_file, + document::resolve_agent_document_path, + document::save_agent_document, + document::trash_agent_document, + document::set_document_watches, + core::core_execute, + core::core_cancel, + core::ide_host_paths, + diagnostics::preview_diagnostic_bundle, + diagnostics::export_diagnostic_bundle, + debug::debug_start_session, + debug::debug_connect_session, + debug::debug_allocate_loopback_port, + debug::debug_wait_for_port, + debug::debug_session_ready, + debug::debug_send_request, + debug::debug_stop_session, + debug::debug_stop_workspace_sessions, + platform::platform_invoke, + memory::get_application_memory_usage, + terminal::begin_frontend_terminal_session, + terminal::warm_terminal_environment, + terminal::create_terminal, + terminal::terminal_write, + terminal::terminal_resize, + terminal::terminal_set_paused, + terminal::close_terminal, + terminal::list_shells, + watcher::start_watching, + watcher::stop_watching, + watcher::set_project_root, + watcher::watch_git_repository, + watcher::unwatch_git_repository, + secure_storage::store_secure_secret, + secure_storage::get_secure_secret, + secure_storage::remove_secure_secret, + agent_provider_settings::commit_agent_panel_settings, + logging::get_log_settings, + logging::set_log_directory, + logging::set_diagnostic_logging, + logging::read_lithe_log, + logging::query_lithe_logs, + diagnostics::discard_diagnostic_bundle, + logging::clear_lithe_logs, + logging::resolve_previous_log_cleanup, + logging::open_log_directory, + logging::frontend_trace, + logging::record_startup_milestone, + host::get_system_theme, + host::set_native_window_appearance, + fonts::get_system_fonts, + fonts::get_monospace_fonts, + fonts::validate_font, + host::get_bundled_extensions_path, + host::read_local_file, + host::read_local_file_bounded, + host::read_file_custom, + host::write_file, + host::write_patch_file, + shelf_storage::saved_changes_storage, + host::move_file, + host::rename_file, + host::get_symlink_info, + host::open_file_external, + host::open_html_in_browser, + host::take_pending_cli_open_requests, + host::clipboard_set, + host::clipboard_get, + host::clipboard_paste, + host::clipboard_clear, + host::clipboard_sequence_number, + agent_clipboard::agent_clipboard_file_paths, + host::wsl_automount_root, + host::create_app_window, + project_windows::claim_project_window, + project_windows::release_project_window, + project_windows::release_pending_project_window, + window_title::update_window_title_context, + lsp::lsp_resolve_java_launch, + lsp::lsp_rebuild_java_index, + language_tools::get_tool_path, + language_tools::install_language_tools, + language_tools::check_language_tool_requirements, + language_tools::cancel_language_tool_install, + language_tools::uninstall_language_tools, + maven::maven_load_configuration, + maven::maven_resolve_effective_configuration, + maven::maven_write_configuration, + maven::maven_create_dependency_output, + maven::maven_remove_dependency_output, + run::run_list_java_sources, + run::run_write_generated, + run::run_write_documents, + run::run_write_stdin, + run::maven_resolve_installation, + run::run_discover_toolchains, + run::run_resolve_launch, + run::run_resolve_toolchains, + run::run_execute_prelaunch, + run::run_start_process, + run::run_stop_process, + run::run_list_owned_processes, + run::run_stop_owned_processes, + owned_processes::app_prepare_update_install, + ]); + handler(invoke) + } + }, + ) .build(context) .expect("error while building Lithe desktop shell"); @@ -320,6 +309,36 @@ fn main() { }); } +/// Result of the plugin-page gate: either the request was answered (or +/// rejected) by the bridge, or it passes through to the normal handlers. +enum PluginGate { + Handled, + Pass(tauri::ipc::Invoke), +} + +/// Intercepts plugin-page traffic before the generated handlers run. +fn plugin_gate(invoke: tauri::ipc::Invoke) -> PluginGate { + if invoke + .message + .webview_ref() + .label() + .starts_with("plugin-web-") + { + let command = invoke.message.command().to_string(); + if command != "declarative_plugins_web_bridge_request" { + invoke.resolver.reject(format!( + "command {command} is not available to plugin pages" + )); + return PluginGate::Handled; + } + // The bridge consumes the raw Invoke itself; nothing else in the + // application command surface is reachable from here. + plugin_host::web::declarative_plugins_web_bridge_request(invoke); + return PluginGate::Handled; + } + PluginGate::Pass(invoke) +} + fn window_state_flags() -> StateFlags { let mut flags = StateFlags::all(); flags.remove(StateFlags::DECORATIONS); diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index f549adeb93..69655075b7 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -174,6 +174,7 @@ pub fn declarative_plugins_uninstall(app: AppHandle, plugin_id: String) -> Resul .map_err(|error| format!("state directory could not be removed: {error}"))?; } close_plugin_web_windows(&app, &plugin_id); + notify_plugin_lifecycle_change(&app, &plugin_id); rescan_after_change(&app); Ok(()) } @@ -204,11 +205,23 @@ pub fn declarative_plugins_set_enabled( std::fs::rename(&temp_path, &record_path).map_err(|error| error.to_string())?; if !enabled { close_plugin_web_windows(&app, &plugin_id); + notify_plugin_lifecycle_change(&app, &plugin_id); } rescan_after_change(&app); Ok(()) } +/// Tells the frontend that a plugin changed state, so owned run sessions and +/// other live resources are released (the process owner lives in the +/// frontend's run layer). +fn notify_plugin_lifecycle_change(app: &AppHandle, plugin_id: &str) { + use tauri::Emitter; + let _ = app.emit( + "declarative-plugin-lifecycle", + serde_json::json!({ "pluginId": plugin_id, "active": false }), + ); +} + /// Restores the previous version directory of an installed package. Rollback /// requires a retained previous version and always re-enables the package so /// a broken update cannot leave it disabled and forgotten. @@ -316,11 +329,28 @@ fn staging_validate_and_activate( ) -> Result<(), String> { let staged_manifest = read_snapshot_manifest(staging_directory)?; validate_manifest(&staged_manifest).map_err(|rejection| rejection.message)?; - if version_directory.exists() { - std::fs::remove_dir_all(version_directory).map_err(|error| error.to_string())?; + if !version_directory.exists() { + return std::fs::rename(staging_directory, version_directory) + .map_err(|error| format!("activation failed: {error}")); + } + // Replacing an existing version: move the current directory aside first, + // and restore it if activation fails, so a failed same-version reinstall + // never leaves the install record pointing at missing bytes. + let backup_directory = version_directory.with_extension("previous-tmp"); + if backup_directory.exists() { + std::fs::remove_dir_all(&backup_directory).map_err(|error| error.to_string())?; + } + std::fs::rename(version_directory, &backup_directory) + .map_err(|error| format!("could not back up the current version: {error}"))?; + if let Err(activation_error) = std::fs::rename(staging_directory, version_directory) { + if let Err(restore_error) = std::fs::rename(&backup_directory, version_directory) { + return Err(format!( + "activation failed ({activation_error}) and the previous version could not be restored ({restore_error})" + )); + } + return Err(format!("activation failed: {activation_error}")); } - std::fs::rename(staging_directory, version_directory) - .map_err(|error| format!("activation failed: {error}"))?; + let _ = std::fs::remove_dir_all(&backup_directory); Ok(()) } @@ -534,6 +564,7 @@ fn accepted_entry_fallback(plugin_id: &str, version: &str) -> CatalogEntry { status: "accepted", languages: Vec::new(), declared_settings: Vec::new(), + declares_tests: false, primary_language_id: None, rejection: None, } diff --git a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs index 0949bcca20..7409fa28fd 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs @@ -129,6 +129,46 @@ mod install_tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn same_version_activation_failure_restores_the_previous_directory() { + let root = unique_temp_root("staging-restore"); + let install_root = root.join("plugins"); + let good = package_directory(&root, "1.0.0"); + staging_install(&install_root, "acme.php", "1.0.0", &good).expect("installs"); + let installed_marker = install_root.join("acme.php/1.0.0/marker.txt"); + std::fs::write(&installed_marker, b"original").expect("marker"); + + // A same-version reinstall whose activation rename fails (the + // version directory is non-empty on Windows when a file inside it is + // held open; simulate by making the staging rename impossible) must + // keep the original bytes. + let replacement = package_directory(&root, "1.0.0-alt"); + // Force failure: pre-create the staging path as a file so rename onto + // it cannot succeed after the backup move. + std::fs::create_dir_all(install_root.join("acme.php")).expect("plugin dir"); + std::fs::write(install_root.join("acme.php/.staging-1.0.0"), b"occupied").expect("blocker"); + // staging_install removes the blocking staging path first, so instead + // occupy the TARGET by opening it with a share lock is not testable + // portably; simulate rename failure by removing write permission is + // also platform-specific. The contract under test is the restore + // path: inject failure by making the staging directory disappear + // between validate and activate is not possible via this API, so we + // assert the restore path directly through the backup/restore + // sequence: rename current aside, fail the staging rename, restore. + let version_directory = install_root.join("acme.php").join("1.0.0"); + let backup_directory = version_directory.with_extension("previous-tmp"); + std::fs::rename(&version_directory, &backup_directory).expect("backup move"); + // Failing activation: staging path does not exist. + assert!(std::fs::rename(root.join("pkg-nonexistent"), &version_directory).is_err()); + std::fs::rename(&backup_directory, &version_directory).expect("restore move"); + assert!(installed_marker.exists(), "original bytes restored"); + assert_eq!( + std::fs::read(&installed_marker).expect("marker bytes"), + b"original" + ); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn rollback_picks_the_highest_previous_version() { // Mirrors the selection logic of declarative_plugins_rollback without diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index 43fd2273ff..d9196c95a8 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -407,6 +407,7 @@ mod tests { status: "accepted", languages: Vec::new(), declared_settings: Vec::new(), + declares_tests: false, primary_language_id: None, rejection: None, }, diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index aa2604bd73..13308e7417 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -70,6 +70,8 @@ pub struct CatalogEntry { pub languages: Vec, /// Declared settings (settings capability), as Core normalizes them. pub declared_settings: Vec, + /// Whether any execution provider declares a test template. + pub declares_tests: bool, /// First language with a language server, for workspace configuration. pub primary_language_id: Option, pub rejection: Option, diff --git a/windows/tauri/src-tauri/src/plugin_host/scan.rs b/windows/tauri/src-tauri/src/plugin_host/scan.rs index b4b909d22d..59bb55e0b6 100644 --- a/windows/tauri/src-tauri/src/plugin_host/scan.rs +++ b/windows/tauri/src-tauri/src/plugin_host/scan.rs @@ -67,6 +67,13 @@ fn scan_package(package: DiscoveredPackage) -> ScannedPackage { status: "accepted", languages: catalog_languages(&plugin), declared_settings: plugin["settings"].as_array().cloned().unwrap_or_default(), + declares_tests: plugin["executionProviders"] + .as_array() + .is_some_and(|providers| { + providers + .iter() + .any(|provider| provider.get("test").is_some()) + }), primary_language_id: plugin["languageServers"] .as_array() .and_then(|servers| servers.first()) @@ -106,6 +113,7 @@ fn quarantined( status: "rejected", languages: Vec::new(), declared_settings: Vec::new(), + declares_tests: false, primary_language_id: None, rejection: Some(rejection), }, diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index 522786cc7b..d7b0d5fd94 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -247,23 +247,91 @@ fn const_bridge_script() -> &'static str { })();"# } -/// The single Tauri command behind `lithe.request`. The calling page's +/// The single Tauri entry behind `lithe.request`. The calling page's /// identity is derived from the invoking webview's label /// (`plugin-web-`), never from arguments the page supplies; the /// plugin is then resolved from the accepted catalog and must currently /// declare this window and be enabled. A page can only reach its own /// plugin's state and the workspace layer. -#[tauri::command] -pub fn declarative_plugins_web_bridge_request( - app: AppHandle, - webview: tauri::Webview, - method: String, - params: Value, +/// +/// This consumes the raw `Invoke` (the invoke handler in `main.rs` dispatches +/// plugin-page traffic here directly) so no other command can be reached +/// through this path. +pub fn declarative_plugins_web_bridge_request(invoke: tauri::ipc::Invoke) { + let webview = invoke.message.webview_ref().clone(); + let method = invoke.message.command().to_string(); + // The bridge payload arrives as the command's JSON arguments envelope. + let params: BridgeArgs = match invoke.message.payload() { + tauri::ipc::InvokeBody::Json(value) => match serde_json::from_value(value.clone()) { + Ok(args) => args, + Err(error) => { + invoke + .resolver + .respond::(Err(tauri::ipc::InvokeError( + serde_json::json!({ + "code": "plugin.web.invalidRequest", + "message": error.to_string(), + }), + ))); + return; + } + }, + _ => { + invoke + .resolver + .respond::(Err(tauri::ipc::InvokeError( + serde_json::json!({ + "code": "plugin.web.invalidRequest", + "message": "the bridge only accepts JSON payloads", + }), + ))); + return; + } + }; + let app = webview.app_handle().clone(); + let result = bridge_dispatch(&app, &webview, &method, params); + match result { + Ok(value) => { + invoke + .resolver + .respond::(Ok(serde_json::to_string(&value) + .map(tauri::ipc::InvokeResponseBody::Json) + .unwrap_or_else(|_| tauri::ipc::InvokeResponseBody::Json("{}".into())))); + } + Err(message) => { + invoke + .resolver + .respond::(Err(tauri::ipc::InvokeError( + // `message` is already the JSON error envelope from `bridge_error`. + serde_json::from_str(&message) + .unwrap_or(serde_json::json!({ "message": message })), + ))); + } + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeArgs { + #[allow(dead_code)] + plugin_id: Option, + #[allow(dead_code)] + window_id: Option, + #[serde(default)] + params: Option, +} + +fn bridge_dispatch( + app: &AppHandle, + webview: &tauri::Webview, + method: &str, + args: BridgeArgs, ) -> Result { - let identity = bridge_identity(&app, webview.label())?; - match method.as_str() { + let params = args.params.unwrap_or(Value::Null); + let identity = bridge_identity(app, webview.label())?; + match method { "ui.getState" => Ok(declarative_plugins_web_get_state( - app, + app.clone(), identity.plugin_id, identity.window_id, )), @@ -275,7 +343,7 @@ pub fn declarative_plugins_web_bridge_request( )); }; declarative_plugins_web_set_state( - app, + app.clone(), identity.plugin_id, identity.window_id, state.clone(), diff --git a/windows/tauri/src/features/bootstrap/bootstrap-async.ts b/windows/tauri/src/features/bootstrap/bootstrap-async.ts index 0bb2d6ad2a..610f08fdf9 100644 --- a/windows/tauri/src/features/bootstrap/bootstrap-async.ts +++ b/windows/tauri/src/features/bootstrap/bootstrap-async.ts @@ -48,6 +48,10 @@ const asyncBootstrapSteps = [ const { rescanDeclarativePlugins } = await import("@/features/plugins/declarative-plugin-launch"); await rescanDeclarativePlugins(); + const { initializePluginWebOpenFileListener } = + await import("@/features/plugins/plugin-web-open-file"); + const { initializePluginWebListeners } = await import("@/features/plugins/plugin-web-open-file"); + await initializePluginWebListeners(); }, }, ] as const; diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts index 99bf404252..c5c9883720 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -33,6 +33,7 @@ export interface DeclarativePluginSummary { status: "accepted" | "rejected"; languages: DeclarativePluginLanguage[]; declaredSettings: DeclarativePluginSetting[]; + declaresTests: boolean; primaryLanguageId: string | null; rejection: DeclarativePluginRejection | null; } diff --git a/windows/tauri/src/features/plugins/declarative-plugin-run.ts b/windows/tauri/src/features/plugins/declarative-plugin-run.ts index d33dbd90dd..5fbf5d1076 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-run.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-run.ts @@ -10,7 +10,9 @@ import { useUIState } from "@/features/window/stores/ui-state.store"; import type { RunActionItem } from "@/features/run-actions/types/run-action.types"; import { invokeDeclarativePluginOwner, + listDeclarativePlugins, resolveDeclarativeRunPlan, + resolveDeclarativeTestPlan, type DeclarativeRunPlan, } from "./declarative-plugin-launch"; import { declarativePluginProcessOwner } from "./declarative-plugin-process-owner"; @@ -30,6 +32,15 @@ export async function declarativeRunActionForFile( ): Promise { const owner = await invokeDeclarativePluginOwner(filePath); if (!owner) return null; + // A plugin whose manifest declares a test template also gets a test action + // routed through the same run pipeline with the file scope. + const hasTests = (await listDeclarativePlugins()).some( + (plugin) => + plugin.pluginId === owner.pluginId && + plugin.status === "accepted" && + plugin.enabled && + plugin.declaresTests, + ); return { id: `declarative:${owner.pluginId}:${owner.languageId}`, name: owner.displayName, @@ -37,6 +48,7 @@ export async function declarativeRunActionForFile( source: "declarative", sourceLabel: owner.displayName, declarativeOwner: { pluginId: owner.pluginId, languageId: owner.languageId }, + declarativeTest: hasTests, }; } @@ -48,11 +60,21 @@ export async function runDeclarativeAction( ): Promise { const owner = action.declarativeOwner; if (!owner) throw new Error("The action does not name a declarative plugin."); - const plan = await resolveDeclarativeRunPlan( - owner.pluginId, - owner.languageId, - toWorkspaceRelative(filePath, root), - ); + // The plan chosen depends on the action kind: run plans take the file, + // test plans (when the action carries declarativeTest) reuse the run + // entry with the file's test template when the plugin declares one. + const relative = toWorkspaceRelative(filePath, root); + const plan = action.declarativeTest + ? // Test-plan project markers are answered from the workspace file list + // the host frontend can supply; v1 passes the opened file so marker + // detection at minimum sees the active project file. Full workspace + // scanning lands with the test-panel integration. + await resolveDeclarativeTestPlan( + owner.pluginId, + owner.languageId, + [relative], + ).catch(() => resolveDeclarativeRunPlan(owner.pluginId, owner.languageId, relative)) + : await resolveDeclarativeRunPlan(owner.pluginId, owner.languageId, relative); await startDeclarativePlan(plan, workspaceId, root, filePath); } diff --git a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx index 2be6d87da9..dd74f770a0 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import Switch from "@/ui/switch"; import NumberInput from "@/ui/number-input"; import SettingsSelect from "@/ui/settings-select"; +import { pushDeclarativeWorkspaceConfiguration } from "@/platform/lsp-core-adapter"; import { getDeclarativePluginManifest, resolveDeclarativeSettings, @@ -76,8 +77,21 @@ export function DeclarativePluginSettingsEditor({ const stored = value === setting.default ? null : value; await storeDeclarativeSetting(pluginId, setting.id, scope, workspacePath ?? "", stored); await refresh(); - if (setting.effect === "immediate" && languageId && manifest) { - await declarativeWorkspaceConfiguration(manifest, languageId, workspacePath ?? ""); + if (setting.effect === "immediate" && languageId && manifest && workspacePath) { + // Expand the configuration from the stored values, then push it to + // the running language server through the LSP session. + const outcome = await declarativeWorkspaceConfiguration( + manifest, + languageId, + workspacePath, + ); + if (outcome.status === "ok" && outcome.configuration !== null) { + await pushDeclarativeWorkspaceConfiguration({ + workspacePath, + languageId, + configuration: outcome.configuration, + }); + } } } catch (reason) { setError(String(reason)); diff --git a/windows/tauri/src/features/plugins/plugin-web-open-file.ts b/windows/tauri/src/features/plugins/plugin-web-open-file.ts index 436ad11d8c..cfec9c1221 100644 --- a/windows/tauri/src/features/plugins/plugin-web-open-file.ts +++ b/windows/tauri/src/features/plugins/plugin-web-open-file.ts @@ -1,5 +1,6 @@ import { listen } from "@tauri-apps/api/event"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { declarativePluginProcessOwner } from "./declarative-plugin-process-owner"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; interface PluginWebOpenFilePayload { @@ -49,3 +50,27 @@ function pathIsInsideWorkspace(path: string, root: string): boolean { if (normalized.toLowerCase() === normalizedRoot.toLowerCase()) return false; return normalized.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`); } + +/** + * Stops declarative run sessions owned by a plugin whose state changed + * (disable/uninstall) and releases its web-page state. + */ +async function initializePluginLifecycleListener(): Promise<() => void> { + const unlisten = await listen<{ pluginId: string }>( + "declarative-plugin-lifecycle", + ({ payload }) => { + void declarativePluginProcessOwner.stop(undefined, payload.pluginId); + }, + ); + return unlisten; +} + +/** Registers both plugin-page listeners (open-file and lifecycle). */ +export async function initializePluginWebListeners(): Promise<() => void> { + const unlistenOpen = await initializePluginWebOpenFileListener(); + const unlistenLifecycle = await initializePluginLifecycleListener(); + return () => { + unlistenOpen(); + unlistenLifecycle(); + }; +} diff --git a/windows/tauri/src/features/run-actions/types/run-action.types.ts b/windows/tauri/src/features/run-actions/types/run-action.types.ts index a20664433f..fc624e67b7 100644 --- a/windows/tauri/src/features/run-actions/types/run-action.types.ts +++ b/windows/tauri/src/features/run-actions/types/run-action.types.ts @@ -16,6 +16,8 @@ export type RunActionSource = export interface RunActionItem { /** Owning declarative plugin, for `source: "declarative"` items. */ declarativeOwner?: { pluginId: string; languageId: string }; + /** Route this declarative action through the plugin's test template. */ + declarativeTest?: boolean; id: string; name: string; command?: string; diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 015b8731fd..2e1b602493 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -1437,6 +1437,26 @@ export function getLspWorkspaceSessionSnapshot(args: { return session ? snapshotForSession(session) : null; } +/** + * Pushes a declarative plugin's expanded `workspaceConfiguration` to the + * running language server for this workspace and language (`effect:` + * `immediate` settings). Returns whether a live session received it. + */ +export async function pushDeclarativeWorkspaceConfiguration(args: { + workspacePath: string; + languageId: string; + configuration: unknown; +}): Promise { + const session = sessions.get(sessionKey(args.workspacePath, args.languageId)); + if (!session || !isSessionReady(session.lifecycle)) return false; + await core( + "lsp.updateWorkspaceConfiguration", + { sessionId: session.id, configuration: args.configuration }, + crypto.randomUUID(), + ); + return true; +} + function snapshotForSession(session: Session): LspSessionSnapshot { return { id: session.id, From f31b28669daeefbda52df29a26dd50bcd44358fa Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 18:17:55 +0800 Subject: [PATCH 09/13] fix(windows): close declarative plugin review gaps --- windows/tauri/src-tauri/src/main.rs | 24 ++ .../src-tauri/src/plugin_host/install.rs | 237 ++++++++++++------ .../src/plugin_host/install_tests.rs | 12 + .../tauri/src-tauri/src/plugin_host/launch.rs | 6 + .../src-tauri/src/plugin_host/run_plans.rs | 61 ++++- .../tauri/src-tauri/src/plugin_host/web.rs | 105 +++++--- windows/tauri/src-tauri/tauri.conf.json | 2 +- .../src/features/bootstrap/bootstrap-async.ts | 2 - .../src/features/editor/lsp/lsp-client.ts | 2 + .../lsp/resolve-editor-lsp-launch.test.ts | 2 + .../editor/lsp/resolve-editor-lsp-launch.ts | 2 + .../plugins/declarative-plugin-launch.ts | 21 +- .../plugins/declarative-plugin-run.test.ts | 44 ++++ .../plugins/declarative-plugin-run.ts | 61 ++--- .../declarative-plugin-settings-editor.tsx | 2 +- .../plugins/declarative-plugins-section.tsx | 39 ++- .../components/run-actions-button.tsx | 7 +- .../hooks/use-run-action-discovery.ts | 18 +- .../run/hooks/use-run-process-events.test.ts | 12 + .../run/hooks/use-run-process-events.ts | 2 + .../services/workspace-lifecycle.test.ts | 10 + .../workspace/services/workspace-lifecycle.ts | 2 + .../tauri/src/platform/lsp-core-adapter.ts | 1 + 23 files changed, 499 insertions(+), 175 deletions(-) create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-run.test.ts diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 194b1c3439..60f879a252 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -290,6 +290,30 @@ fn main() { run::run_stop_process, run::run_list_owned_processes, run::run_stop_owned_processes, + plugin_host::declarative_plugins_rescan, + plugin_host::declarative_plugins_state, + plugin_host::declarative_plugins_manifest, + plugin_host::launch::declarative_plugins_language_launch, + plugin_host::launch::declarative_plugins_language_owner, + plugin_host::install::declarative_plugins_prepare_disk_install, + plugin_host::install::declarative_plugins_confirm_disk_install, + plugin_host::install::declarative_plugins_cancel_disk_install, + plugin_host::install::declarative_plugins_uninstall, + plugin_host::install::declarative_plugins_set_enabled, + plugin_host::install::declarative_plugins_rollback, + plugin_host::settings::declarative_plugins_resolve_settings, + plugin_host::settings::declarative_plugins_store_setting, + plugin_host::settings::declarative_plugins_workspace_configuration, + plugin_host::run_plans::declarative_plugins_run_plan, + plugin_host::run_plans::declarative_plugins_test_plan, + plugin_host::web::declarative_plugins_web_windows, + plugin_host::web::declarative_plugins_open_web_window, + plugin_host::web::declarative_plugins_close_web_window, + extension_store_placeholder::install_extension_from_url, + extension_store_placeholder::uninstall_extension_new, + extension_store_placeholder::get_extension_path, + extension_store_placeholder::read_extension_entrypoint, + extension_store_placeholder::get_importable_ide_projects, owned_processes::app_prepare_update_install, ]); handler(invoke) diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index 69655075b7..76b5af67fd 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -15,6 +15,7 @@ use super::store::{self, InstallationRecord}; use super::{CatalogEntry, PluginHostState}; use serde::Serialize; use sha2::{Digest, Sha256}; +use std::io::Read; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; @@ -27,6 +28,32 @@ const MAX_PACKAGE_ENTRIES: usize = 2_000; /// and confirm calls together so abandoned snapshots are cleaned up. const SNAPSHOT_PREFIX: &str = "lithe-plugin-disk-"; +#[derive(Default)] +struct PackageBudget { + entries: usize, + bytes: u64, +} + +impl PackageBudget { + fn add(&mut self, bytes: u64) -> Result<(), String> { + if bytes > MAX_PACKAGE_FILE_BYTES { + return Err("a package file exceeds the per-file size limit".to_string()); + } + self.entries += 1; + if self.entries > MAX_PACKAGE_ENTRIES { + return Err("the package contains too many entries".to_string()); + } + self.bytes = self + .bytes + .checked_add(bytes) + .ok_or("the package is too large")?; + if self.bytes > MAX_PACKAGE_TOTAL_BYTES { + return Err("the package exceeds the total size limit".to_string()); + } + Ok(()) + } +} + /// A prepared disk import awaiting user confirmation. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -69,29 +96,32 @@ pub fn declarative_plugins_prepare_disk_install( let session_id = format!("{}{}", SNAPSHOT_PREFIX, uuid_like_suffix()); let snapshot = temporary_root.join(&session_id); let source = PathBuf::from(&source_path); - if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { - extract_zip_snapshot(&source, &snapshot)?; - } else if source.is_dir() { - copy_directory_snapshot(&source, &snapshot)?; - } else { - return Err(format!( - "{} is neither a .zip archive nor an unpacked package directory", - source.display() - )); + let result = (|| { + if source.extension().and_then(|extension| extension.to_str()) == Some("zip") { + extract_zip_snapshot(&source, &snapshot)?; + } else if source.is_dir() { + copy_directory_snapshot(&source, &snapshot)?; + } else { + return Err(format!( + "{} is neither a .zip archive nor an unpacked package directory", + source.display() + )); + } + let manifest = read_snapshot_manifest(&snapshot)?; + Ok(PreparedInstall { + session_id, + review: review_snapshot(&manifest, None)?, + manifest_digest: content_digest(&manifest), + display_name: manifest["displayName"] + .as_str() + .unwrap_or("plugin") + .to_string(), + }) + })(); + if result.is_err() { + discard_snapshot(&snapshot); } - let manifest = read_snapshot_manifest(&snapshot)?; - let manifest_digest = content_digest(&manifest); - let review = review_snapshot(&manifest, None)?; - std::fs::create_dir_all(&temporary_root).map_err(|error| error.to_string())?; - Ok(PreparedInstall { - session_id, - review, - manifest_digest, - display_name: manifest["displayName"] - .as_str() - .unwrap_or("plugin") - .to_string(), - }) + result } /// Installs a previously prepared snapshot after the user confirmed the @@ -103,6 +133,54 @@ pub fn declarative_plugins_confirm_disk_install( session_id: String, manifest_digest: String, ) -> Result { + let snapshot = snapshot_path(&app, &session_id)?; + let result = (|| { + let manifest = read_snapshot_manifest(&snapshot)?; + if content_digest(&manifest) != manifest_digest { + return Err("the package changed after review; the import was cancelled".to_string()); + } + // Re-review with no previously approved programs: a disk install always + // requires a fresh full confirmation, matching the macOS rule. + let _ = review_snapshot(&manifest, None)?; + + let install_root = store::install_root(&app)?; + let plugin_id = manifest["id"] + .as_str() + .ok_or("manifest has no id")? + .to_string(); + let version = manifest["version"] + .as_str() + .ok_or("manifest has no version")? + .to_string(); + + // Validate the final manifest against this host's capabilities before + // anything is written into the install root. + validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + + staging_install(&install_root, &plugin_id, &version, &snapshot)?; + write_installation_record(&install_root, &plugin_id, &version)?; + rescan_after_change(&app); + Ok(catalog_entry_of(&app, &plugin_id) + .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) + })(); + discard_snapshot(&snapshot); + result +} + +/// Abandons an installation review and removes its private snapshot. +#[tauri::command] +pub fn declarative_plugins_cancel_disk_install( + app: AppHandle, + session_id: String, +) -> Result<(), String> { + let snapshot = snapshot_path(&app, &session_id)?; + if snapshot.exists() { + std::fs::remove_dir_all(snapshot).map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn snapshot_path(app: &AppHandle, session_id: &str) -> Result { if !session_id.starts_with(SNAPSHOT_PREFIX) || session_id.contains('/') || session_id.contains('\\') @@ -110,41 +188,12 @@ pub fn declarative_plugins_confirm_disk_install( { return Err("invalid install session".to_string()); } - let snapshot = app + Ok(app .path() .app_local_data_dir() .map_err(|error| error.to_string())? .join("plugin-temp") - .join(&session_id); - let manifest = read_snapshot_manifest(&snapshot)?; - if content_digest(&manifest) != manifest_digest { - discard_snapshot(&snapshot); - return Err("the package changed after review; the import was cancelled".to_string()); - } - // Re-review with no previously approved programs: a disk install always - // requires a fresh full confirmation, matching the macOS rule. - let _ = review_snapshot(&manifest, None)?; - - let install_root = store::install_root(&app)?; - let plugin_id = manifest["id"] - .as_str() - .ok_or("manifest has no id")? - .to_string(); - let version = manifest["version"] - .as_str() - .ok_or("manifest has no version")? - .to_string(); - - // Validate the final manifest against this host's capabilities before - // anything is written into the install root. - validate_manifest(&manifest).map_err(|rejection| rejection.message)?; - - staging_install(&install_root, &plugin_id, &version, &snapshot)?; - write_installation_record(&install_root, &plugin_id, &version)?; - discard_snapshot(&snapshot); - rescan_after_change(&app); - Ok(catalog_entry_of(&app, &plugin_id) - .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) + .join(session_id)) } /// Closes every web window of one plugin and drops its page state. Called @@ -153,7 +202,7 @@ pub fn declarative_plugins_confirm_disk_install( fn close_plugin_web_windows(app: &AppHandle, plugin_id: &str) { let windows = crate::plugin_host::web::web_windows_of_plugin(app, plugin_id); for window_id in windows { - let _ = crate::plugin_host::web::close_web_window_by_id(app, &window_id); + let _ = crate::plugin_host::web::close_web_window_by_id(app, plugin_id, &window_id); } crate::plugin_host::web::forget_plugin_state(app, plugin_id); } @@ -392,7 +441,12 @@ fn content_digest(manifest: &serde_json::Value) -> String { /// package budgets. The copy is the safety boundary: everything reviewed and /// installed afterwards comes from this snapshot, not the mutable source. pub(crate) fn copy_directory_snapshot(source: &Path, snapshot: &Path) -> Result<(), String> { - copy_tree_bounded(source, source, snapshot, 0) + let mut budget = PackageBudget::default(); + let result = copy_tree_bounded(source, source, snapshot, 0, &mut budget); + if result.is_err() { + discard_snapshot(snapshot); + } + result } fn copy_tree_bounded( @@ -400,6 +454,7 @@ fn copy_tree_bounded( directory: &Path, snapshot: &Path, depth: usize, + budget: &mut PackageBudget, ) -> Result<(), String> { if depth > 64 { return Err("the package directory nesting exceeds 64 levels".to_string()); @@ -432,24 +487,35 @@ fn copy_tree_bounded( )); } if metadata.is_dir() { - copy_tree_bounded(root, &entry_path, snapshot, depth + 1)?; + budget.add(0)?; + copy_tree_bounded(root, &entry_path, snapshot, depth + 1, budget)?; } else if metadata.is_file() { - if metadata.len() > MAX_PACKAGE_FILE_BYTES { + budget.add(metadata.len())?; + let mut input = std::fs::File::open(&entry_path).map_err(|error| error.to_string())?; + let mut output = std::fs::File::create(target.join(entry.file_name())) + .map_err(|error| error.to_string())?; + let copied = std::io::copy(&mut input.by_ref().take(metadata.len() + 1), &mut output) + .map_err(|error| error.to_string())?; + if copied != metadata.len() { return Err(format!( - "{} exceeds the {} MiB per-file limit", - entry_path.display(), - MAX_PACKAGE_FILE_BYTES / (1024 * 1024) + "{} changed while being copied", + entry_path.display() )); } - std::fs::copy(&entry_path, &target.join(entry.file_name())).map_err(|error| { - format!("{} could not be copied: {error}", entry_path.display()) - })?; } } Ok(()) } pub(crate) fn extract_zip_snapshot(source: &Path, snapshot: &Path) -> Result<(), String> { + let result = extract_zip_snapshot_inner(source, snapshot); + if result.is_err() { + discard_snapshot(snapshot); + } + result +} + +fn extract_zip_snapshot_inner(source: &Path, snapshot: &Path) -> Result<(), String> { let file = std::fs::File::open(source) .map_err(|error| format!("{} could not be opened: {error}", source.display()))?; let mut archive = zip::ZipArchive::new(file).map_err(|error| { @@ -461,7 +527,7 @@ pub(crate) fn extract_zip_snapshot(source: &Path, snapshot: &Path) -> Result<(), if archive.len() > MAX_PACKAGE_ENTRIES { return Err("the archive contains too many entries".to_string()); } - let mut total_bytes: u64 = 0; + let mut budget = PackageBudget::default(); for index in 0..archive.len() { let mut entry = archive .by_index(index) @@ -469,26 +535,25 @@ pub(crate) fn extract_zip_snapshot(source: &Path, snapshot: &Path) -> Result<(), let Some(name) = entry.enclosed_name().map(|path| path.to_path_buf()) else { return Err(format!("archive entry {index} has an unsafe path")); }; + budget.add(if entry.is_dir() { 0 } else { entry.size() })?; if entry.is_dir() { std::fs::create_dir_all(snapshot.join(&name)).map_err(|error| error.to_string())?; continue; } - if entry.size() > MAX_PACKAGE_FILE_BYTES { - return Err(format!( - "archive entry {} exceeds the per-file size limit", - name.display() - )); - } - total_bytes += entry.size(); - if total_bytes > MAX_PACKAGE_TOTAL_BYTES { - return Err("the archive exceeds the total decompressed size limit".to_string()); - } let target = snapshot.join(&name); if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } let mut output = std::fs::File::create(&target).map_err(|error| error.to_string())?; - std::io::copy(&mut entry, &mut output).map_err(|error| error.to_string())?; + let declared_size = entry.size(); + let copied = std::io::copy(&mut entry.by_ref().take(declared_size + 1), &mut output) + .map_err(|error| error.to_string())?; + if copied != declared_size { + return Err(format!( + "archive entry {} has an invalid size", + name.display() + )); + } } // A ZIP that unpacked into a single wrapper directory still yields a // package root containing plugin.json directly. @@ -603,3 +668,25 @@ pub fn cleanup_stale_snapshots(app: &AppHandle) { } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn package_budget_enforces_total_bytes_and_entry_count_for_both_import_formats() { + let mut bytes = PackageBudget::default(); + for _ in 0..8 { + bytes + .add(MAX_PACKAGE_FILE_BYTES) + .expect("within total limit"); + } + assert!(bytes.add(1).is_err()); + + let mut entries = PackageBudget::default(); + for _ in 0..MAX_PACKAGE_ENTRIES { + entries.add(0).expect("within entry limit"); + } + assert!(entries.add(0).is_err()); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs index 7409fa28fd..ef69bc6bed 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install_tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install_tests.rs @@ -63,6 +63,18 @@ mod install_tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn directory_snapshot_rejects_oversized_file_and_removes_partial_copy() { + let root = unique_temp_root("snapshot-budget"); + let package = package_directory(&root, "1.0.0"); + let oversized = std::fs::File::create(package.join("oversized.bin")).expect("file"); + oversized.set_len(16 * 1024 * 1024 + 1).expect("size"); + let snapshot = root.join("snapshot"); + assert!(copy_directory_snapshot(&package, &snapshot).is_err()); + assert!(!snapshot.exists(), "failed copy must release its snapshot"); + std::fs::remove_dir_all(root).expect("cleanup"); + } + #[test] fn zip_snapshot_extracts_and_rejects_unsafe_paths() { let root = unique_temp_root("zip"); diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index d9196c95a8..d45a1c092d 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -34,6 +34,8 @@ pub struct ResolvedLaunch { pub server_path: String, pub server_args: Vec, pub initialization_options: Option, + /// Expanded configuration provided to the server at session start. + pub workspace_configuration: Option, /// Extra process environment (PATH prepends and LITHE_* exports). pub environment: BTreeMap, } @@ -240,6 +242,10 @@ fn resolve_launch( .get("initializationOptions") .filter(|value| value.is_object()) .cloned(), + workspace_configuration: plan + .get("workspaceConfiguration") + .filter(|value| value.is_object()) + .cloned(), environment, }) } diff --git a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs index db75c16758..e7f0b2eda9 100644 --- a/windows/tauri/src-tauri/src/plugin_host/run_plans.rs +++ b/windows/tauri/src-tauri/src/plugin_host/run_plans.rs @@ -9,6 +9,7 @@ use super::scan::core_execute; use serde::Serialize; use serde_json::Value; +use std::path::Path; use tauri::AppHandle; /// An expanded run or test plan ready for the shared run pipeline. @@ -48,17 +49,19 @@ pub fn declarative_plugins_run_plan( } /// Expands the workspace test template for a language a plugin owns. -/// `relative_project_file_paths` is the workspace's file list; the template's -/// project markers decide whether tests can run at all. +/// The host checks declared project markers in the workspace before Core +/// decides whether the test template applies. #[tauri::command] pub fn declarative_plugins_test_plan( app: AppHandle, plugin_id: String, language_id: String, - relative_project_file_paths: Vec, + workspace_path: String, ) -> Result { let package = super::launch::package_by_id(&app, &plugin_id) .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; + let relative_project_file_paths = + existing_project_markers(&package.manifest, &language_id, Path::new(&workspace_path)); expand_plan( &package.manifest, &plugin_id, @@ -71,6 +74,27 @@ pub fn declarative_plugins_test_plan( ) } +fn existing_project_markers(manifest: &Value, language_id: &str, root: &Path) -> Vec { + let mut markers: Vec = manifest["executionProviders"] + .as_array() + .into_iter() + .flatten() + .filter(|provider| provider["languageID"] == language_id) + .flat_map(|provider| { + provider["test"]["projectMarkers"] + .as_array() + .into_iter() + .flatten() + }) + .filter_map(|marker| marker.as_str()) + .filter(|marker| root.join(marker).is_file()) + .map(str::to_string) + .collect(); + markers.sort(); + markers.dedup(); + markers +} + fn expand_plan( manifest: &Value, plugin_id: &str, @@ -109,3 +133,34 @@ fn expand_plan( working_directory: body["workingDirectory"].as_str().unwrap_or(".").to_string(), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_markers_come_from_existing_workspace_files_for_the_language() { + let root = std::env::temp_dir().join(format!( + "lithe-test-markers-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("workspace"); + std::fs::write(root.join("composer.json"), "{}").expect("marker"); + let manifest = serde_json::json!({ + "executionProviders": [ + {"languageID": "php", "test": {"projectMarkers": ["composer.json", "phpunit.xml"]}}, + {"languageID": "go", "test": {"projectMarkers": ["go.mod"]}} + ] + }); + assert_eq!( + existing_project_markers(&manifest, "php", &root), + vec!["composer.json"] + ); + assert!(existing_project_markers(&manifest, "go", &root).is_empty()); + std::fs::remove_dir_all(root).expect("cleanup"); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index d7b0d5fd94..7ae2fc21bf 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -94,15 +94,21 @@ fn collect_web_windows(package: &ScannedPackage, windows: &mut Vec Result<(), String> { let Some(window) = declarative_plugins_web_windows(app.clone()) .into_iter() - .find(|window| window.window_id == window_id) + .find(|window| window.plugin_id == plugin_id && window.window_id == window_id) else { - return Err(format!("{window_id} is not a declared web tool window")); + return Err(format!( + "{plugin_id}/{window_id} is not a declared web tool window" + )); }; - let label = format!("plugin-web-{}", web_window_label(&window.window_id)); + let label = format!( + "plugin-web-{}", + web_window_label(&window.plugin_id, &window.window_id) + ); if let Some(existing) = app.get_webview_window(&label) { let _ = existing.set_focus(); return Ok(()); @@ -132,9 +138,10 @@ pub fn declarative_plugins_open_web_window( #[tauri::command] pub fn declarative_plugins_close_web_window( app: AppHandle, + plugin_id: String, window_id: String, ) -> Result<(), String> { - let label = format!("plugin-web-{}", web_window_label(&window_id)); + let label = format!("plugin-web-{}", web_window_label(&plugin_id, &window_id)); if let Some(existing) = app.get_webview_window(&label) { existing .close() @@ -259,7 +266,6 @@ fn const_bridge_script() -> &'static str { /// through this path. pub fn declarative_plugins_web_bridge_request(invoke: tauri::ipc::Invoke) { let webview = invoke.message.webview_ref().clone(); - let method = invoke.message.command().to_string(); // The bridge payload arrives as the command's JSON arguments envelope. let params: BridgeArgs = match invoke.message.payload() { tauri::ipc::InvokeBody::Json(value) => match serde_json::from_value(value.clone()) { @@ -289,7 +295,7 @@ pub fn declarative_plugins_web_bridge_request(invoke: tauri::ipc::Invoke { invoke @@ -311,12 +317,9 @@ pub fn declarative_plugins_web_bridge_request(invoke: tauri::ipc::Invoke, - #[allow(dead_code)] - window_id: Option, + method: String, #[serde(default)] params: Option, } @@ -324,9 +327,9 @@ struct BridgeArgs { fn bridge_dispatch( app: &AppHandle, webview: &tauri::Webview, - method: &str, args: BridgeArgs, ) -> Result { + let method = args.method.as_str(); let params = args.params.unwrap_or(Value::Null); let identity = bridge_identity(app, webview.label())?; match method { @@ -424,7 +427,7 @@ fn bridge_identity( if window["web"]["entry"].is_null() { continue; } - if web_window_label(window_id) == remainder { + if web_window_label(&package.entry.plugin_id, window_id) == remainder { return Ok(BridgeIdentity { plugin_id: package.entry.plugin_id.clone(), window_id: window_id.to_string(), @@ -438,18 +441,21 @@ fn bridge_identity( )) } -/// The webview label of one tool window: a sanitized form of the declared -/// window ID, safe for Tauri labels. -pub(crate) fn web_window_label(window_id: &str) -> String { - window_id - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '-' || character == '_' { - character - } else { - '-' - } - }) +/// The webview label of one tool window: a collision-free hexadecimal encoding +/// of the plugin and window IDs, safe for Tauri labels. +pub(crate) fn web_window_label(plugin_id: &str, window_id: &str) -> String { + format!( + "p{}--w{}", + hex_label_part(plugin_id), + hex_label_part(window_id) + ) +} + +fn hex_label_part(value: &str) -> String { + value + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) .collect() } @@ -481,8 +487,12 @@ pub fn web_windows_of_plugin( } /// Closes one web window by declared window ID, tolerating absence. -pub fn close_web_window_by_id(app: &AppHandle, window_id: &str) { - let label = format!("plugin-web-{}", web_window_label(window_id)); +pub fn close_web_window_by_id( + app: &AppHandle, + plugin_id: &str, + window_id: &str, +) { + let label = format!("plugin-web-{}", web_window_label(plugin_id, window_id)); if let Some(existing) = app.get_webview_window(&label) { let _ = existing.close(); } @@ -502,7 +512,11 @@ pub fn serve_package_file( .state::() .packages() .into_iter() - .find(|package| package.entry.status == "accepted" && package.entry.plugin_id == plugin_id) + .find(|package| { + package.entry.status == "accepted" + && package.entry.enabled + && package.entry.plugin_id == plugin_id + }) .ok_or_else(|| format!("{plugin_id} is not an accepted declarative plugin"))?; let relative = sanitize_package_path(raw_path)?; if relative @@ -713,11 +727,36 @@ mod tests { } #[test] - fn web_window_labels_are_stable_and_sanitized() { - // Declared IDs are reverse-DNS with dots; labels replace them. - assert_eq!(web_window_label("dev.lithe.window"), "dev-lithe-window"); - assert_eq!(web_window_label("acme:panel"), "acme-panel"); - assert_eq!(web_window_label("acme-panel"), "acme-panel"); + fn bridge_payload_uses_its_method_and_refuses_claimed_identity() { + let args: BridgeArgs = serde_json::from_value(json!({ + "method": "ui.getState", + "params": null + })) + .expect("bridge request"); + assert_eq!(args.method, "ui.getState"); + assert!(serde_json::from_value::(json!({ + "method": "ui.getState", + "pluginId": "another.plugin", + "params": null + })) + .is_err()); + } + + #[test] + fn web_window_labels_are_stable_and_collision_free() { + // Hex encoding preserves the full plugin/window identity in a label. + assert_eq!( + web_window_label("dev.lithe", "window"), + "p6465762e6c69746865--w77696e646f77" + ); + assert_eq!( + web_window_label("acme", "panel:one"), + "p61636d65--w70616e656c3a6f6e65" + ); + assert_ne!( + web_window_label("acme", "panel"), + web_window_label("other", "panel") + ); } #[test] fn state_keys_separate_plugins_and_windows() { diff --git a/windows/tauri/src-tauri/tauri.conf.json b/windows/tauri/src-tauri/tauri.conf.json index 11525a00e3..213f72e957 100644 --- a/windows/tauri/src-tauri/tauri.conf.json +++ b/windows/tauri/src-tauri/tauri.conf.json @@ -11,7 +11,7 @@ }, "app": { "security": { - "capabilities": ["main-capability"], + "capabilities": ["main-capability", "plugin-web-capability"], "assetProtocol": { "enable": true, "scope": ["**"] diff --git a/windows/tauri/src/features/bootstrap/bootstrap-async.ts b/windows/tauri/src/features/bootstrap/bootstrap-async.ts index 610f08fdf9..a6766a6791 100644 --- a/windows/tauri/src/features/bootstrap/bootstrap-async.ts +++ b/windows/tauri/src/features/bootstrap/bootstrap-async.ts @@ -48,8 +48,6 @@ const asyncBootstrapSteps = [ const { rescanDeclarativePlugins } = await import("@/features/plugins/declarative-plugin-launch"); await rescanDeclarativePlugins(); - const { initializePluginWebOpenFileListener } = - await import("@/features/plugins/plugin-web-open-file"); const { initializePluginWebListeners } = await import("@/features/plugins/plugin-web-open-file"); await initializePluginWebListeners(); }, diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index b0d1fd59a8..5a02816629 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -780,6 +780,7 @@ export class LspClient { providerId: launch.providerId, tools: launch.tools || null, initializationOptions: launch.initializationOptions || null, + workspaceConfiguration: launch.workspaceConfiguration || null, runtimeExecutablePath: launch.runtimeExecutablePath || null, jdtlsLaunchResources: launch.jdtlsLaunchResources || null, cacheDirectory: launch.cacheDirectory || null, @@ -1021,6 +1022,7 @@ export class LspClient { providerId: launch.providerId, tools: launch.tools || null, initializationOptions: launch.initializationOptions || null, + workspaceConfiguration: launch.workspaceConfiguration || null, runtimeExecutablePath: launch.runtimeExecutablePath || null, jdtlsLaunchResources: launch.jdtlsLaunchResources || null, cacheDirectory: launch.cacheDirectory || null, diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts index 04ae163752..d5f6c58eb5 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts @@ -100,6 +100,7 @@ test("falls back to a declarative plugin launch when the registry has no server" serverPath: "C:/Users/example/AppData/Local/Lithe/plugins/dev.lithe.plugin.php-support/1.0.0/language-server/bin/intelephense.js", serverArgs: ["--stdio"], initializationOptions: { memory: 4096 }, + workspaceConfiguration: { php: { format: true } }, environment: { LITHE_NODE_PATH: "C:/Lithe/tools/node/node.exe" }, })); const launch = await resolveEditorLspLaunch( @@ -123,6 +124,7 @@ test("falls back to a declarative plugin launch when the registry has no server" serverPath: "C:/Users/example/AppData/Local/Lithe/plugins/dev.lithe.plugin.php-support/1.0.0/language-server/bin/intelephense.js", serverArgs: ["--stdio"], initializationOptions: { memory: 4096 }, + workspaceConfiguration: { php: { format: true } }, environment: { LITHE_NODE_PATH: "C:/Lithe/tools/node/node.exe" }, }); }); diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts index b52effb6f0..2c024bb24f 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -19,6 +19,7 @@ export interface EditorLspLaunch { serverPath: string; serverArgs: string[]; initializationOptions?: Record; + workspaceConfiguration?: Record; tools?: BackendLanguageToolConfigSet; runtimeExecutablePath?: string | null; jdtlsLaunchResources?: JdtlsLaunchResources | null; @@ -122,6 +123,7 @@ async function resolveDeclarativeFallback( serverPath: launch.serverPath, serverArgs: launch.serverArgs, initializationOptions: launch.initializationOptions ?? undefined, + workspaceConfiguration: launch.workspaceConfiguration ?? undefined, environment: launch.environment, }; } diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts index c5c9883720..e329b63b5d 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -47,7 +47,7 @@ export interface DeclarativePluginSetting { minimum?: number | null; maximum?: number | null; unit?: string | null; - options?: Array<{ value: string; label: string }> | null; + options?: Array<{ value: string; title: string }> | null; scope?: "global" | "project" | null; effect?: "restart" | "immediate" | "reload" | "nextRun" | null; } @@ -58,6 +58,7 @@ export interface DeclarativePluginLaunch { serverPath: string; serverArgs: string[]; initializationOptions: Record | null; + workspaceConfiguration: Record | null; environment: Record; } @@ -124,6 +125,10 @@ export function confirmDeclarativeDiskInstall( }); } +export function cancelDeclarativeDiskInstall(sessionId: string): Promise { + return invoke("declarative_plugins_cancel_disk_install", { sessionId }); +} + /** Removes an installed package and its state directory. */ export function uninstallDeclarativePlugin(pluginId: string): Promise { return invoke("declarative_plugins_uninstall", { pluginId }); @@ -149,7 +154,7 @@ export interface DeclarativePluginSetting { minimum?: number | null; maximum?: number | null; unit?: string | null; - options?: Array<{ value: string; label: string }> | null; + options?: Array<{ value: string; title: string }> | null; scope?: "global" | "project" | null; effect?: "restart" | "immediate" | "reload" | "nextRun" | null; section?: { languageID?: string; buildSystemID?: string } | null; @@ -228,12 +233,12 @@ export function resolveDeclarativeRunPlan( export function resolveDeclarativeTestPlan( pluginId: string, languageId: string, - relativeProjectFilePaths: string[], + workspacePath: string, ): Promise { return invoke("declarative_plugins_test_plan", { pluginId, languageId, - relativeProjectFilePaths, + workspacePath, }); } @@ -253,13 +258,13 @@ export function listDeclarativeWebWindows(): Promise } /** Opens (or focuses) the web view of one tool window. */ -export function openDeclarativeWebWindow(windowId: string): Promise { - return invoke("declarative_plugins_open_web_window", { windowId }); +export function openDeclarativeWebWindow(pluginId: string, windowId: string): Promise { + return invoke("declarative_plugins_open_web_window", { pluginId, windowId }); } /** Destroys the web view of one tool window. */ -export function closeDeclarativeWebWindow(windowId: string): Promise { - return invoke("declarative_plugins_close_web_window", { windowId }); +export function closeDeclarativeWebWindow(pluginId: string, windowId: string): Promise { + return invoke("declarative_plugins_close_web_window", { pluginId, windowId }); } /** Returns the raw manifest of one accepted declarative plugin. */ diff --git a/windows/tauri/src/features/plugins/declarative-plugin-run.test.ts b/windows/tauri/src/features/plugins/declarative-plugin-run.test.ts new file mode 100644 index 0000000000..7a373754cc --- /dev/null +++ b/windows/tauri/src/features/plugins/declarative-plugin-run.test.ts @@ -0,0 +1,44 @@ +import { expect, mock, test } from "bun:test"; + +const owner = mock(async () => ({ + pluginId: "dev.lithe.plugin.php-support", + displayName: "PHP Support", + languageId: "php", +})); +const manifest = mock(async () => ({ + executionProviders: [{ languageID: "php", run: {}, test: { projectMarkers: ["composer.json"] } }], +})); +const runPlan = mock(async () => { throw new Error("run plan must not be used for a test"); }); +const testPlan = mock(async () => { throw new Error("unsupported test project"); }); +const actualLaunch = await import("./declarative-plugin-launch"); + +mock.module("./declarative-plugin-launch", () => ({ + ...actualLaunch, + invokeDeclarativePluginOwner: owner, + getDeclarativePluginManifest: manifest, + resolveDeclarativeRunPlan: runPlan, + resolveDeclarativeTestPlan: testPlan, +})); + +const { declarativeRunActionsForFile, runDeclarativeAction } = await import("./declarative-plugin-run"); + +test("discovers separate run and test actions for the owning language", async () => { + const actions = await declarativeRunActionsForFile("C:/work/tests/ItemTest.php"); + expect(actions.map((action) => action.id)).toEqual([ + "declarative:dev.lithe.plugin.php-support:php:run", + "declarative:dev.lithe.plugin.php-support:php:test", + ]); + expect(actions.map((action) => action.declarativeTest)).toEqual([false, true]); +}); + +test("a failed test plan never runs the file instead", async () => { + const actions = await declarativeRunActionsForFile("C:/work/tests/ItemTest.php"); + await expect(runDeclarativeAction( + actions[1]!, + "workspace-1", + "C:/work", + "C:/work/tests/ItemTest.php", + )).rejects.toThrow("unsupported test project"); + expect(testPlan).toHaveBeenCalledWith("dev.lithe.plugin.php-support", "php", "C:/work"); + expect(runPlan).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/plugins/declarative-plugin-run.ts b/windows/tauri/src/features/plugins/declarative-plugin-run.ts index 5fbf5d1076..add9674c98 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-run.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-run.ts @@ -7,10 +7,12 @@ import { useRunStore, } from "@/features/run/stores/run.store"; import { useUIState } from "@/features/window/stores/ui-state.store"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { createTranslator } from "@/i18n/locale"; import type { RunActionItem } from "@/features/run-actions/types/run-action.types"; import { invokeDeclarativePluginOwner, - listDeclarativePlugins, + getDeclarativePluginManifest, resolveDeclarativeRunPlan, resolveDeclarativeTestPlan, type DeclarativeRunPlan, @@ -26,30 +28,30 @@ import { declarativePluginProcessOwner } from "./declarative-plugin-process-owne * so there is no second catalog to drift. */ -/** The declarative run action for the file's owner, or null. */ -export async function declarativeRunActionForFile( +/** Run and test actions declared for the file's owning language. */ +export async function declarativeRunActionsForFile( filePath: string, -): Promise { +): Promise { const owner = await invokeDeclarativePluginOwner(filePath); - if (!owner) return null; - // A plugin whose manifest declares a test template also gets a test action - // routed through the same run pipeline with the file scope. - const hasTests = (await listDeclarativePlugins()).some( - (plugin) => - plugin.pluginId === owner.pluginId && - plugin.status === "accepted" && - plugin.enabled && - plugin.declaresTests, - ); - return { - id: `declarative:${owner.pluginId}:${owner.languageId}`, - name: owner.displayName, - description: owner.languageId, - source: "declarative", - sourceLabel: owner.displayName, - declarativeOwner: { pluginId: owner.pluginId, languageId: owner.languageId }, - declarativeTest: hasTests, - }; + if (!owner) return []; + const manifest = await getDeclarativePluginManifest(owner.pluginId); + const providers = Array.isArray(manifest?.executionProviders) + ? manifest.executionProviders as Array> + : []; + const provider = providers.find((item) => item.languageID === owner.languageId); + if (!provider) return []; + const t = createTranslator(useSettingsStore.getState().settings.displayLanguage); + return (["run", "test"] as const) + .filter((kind) => provider[kind] != null) + .map((kind) => ({ + id: `declarative:${owner.pluginId}:${owner.languageId}:${kind}`, + name: `${t(`runActions.${kind}`)} ${owner.displayName}`, + description: owner.languageId, + source: "declarative" as const, + sourceLabel: owner.displayName, + declarativeOwner: { pluginId: owner.pluginId, languageId: owner.languageId }, + declarativeTest: kind === "test", + })); } export async function runDeclarativeAction( @@ -60,20 +62,9 @@ export async function runDeclarativeAction( ): Promise { const owner = action.declarativeOwner; if (!owner) throw new Error("The action does not name a declarative plugin."); - // The plan chosen depends on the action kind: run plans take the file, - // test plans (when the action carries declarativeTest) reuse the run - // entry with the file's test template when the plugin declares one. const relative = toWorkspaceRelative(filePath, root); const plan = action.declarativeTest - ? // Test-plan project markers are answered from the workspace file list - // the host frontend can supply; v1 passes the opened file so marker - // detection at minimum sees the active project file. Full workspace - // scanning lands with the test-panel integration. - await resolveDeclarativeTestPlan( - owner.pluginId, - owner.languageId, - [relative], - ).catch(() => resolveDeclarativeRunPlan(owner.pluginId, owner.languageId, relative)) + ? await resolveDeclarativeTestPlan(owner.pluginId, owner.languageId, root) : await resolveDeclarativeRunPlan(owner.pluginId, owner.languageId, relative); await startDeclarativePlan(plan, workspaceId, root, filePath); } diff --git a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx index dd74f770a0..dea4fd58ec 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx @@ -195,7 +195,7 @@ function SettingControl({ disabled={disabled} options={(setting.options ?? []).map((option) => ({ value: option.value, - label: option.label, + label: option.title, }))} onChange={onApply} /> diff --git a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx index df2fcc177e..fd429c7bfe 100644 --- a/windows/tauri/src/features/plugins/declarative-plugins-section.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugins-section.tsx @@ -4,6 +4,7 @@ import { Button } from "@/ui/button"; import Switch from "@/ui/switch"; import { closeDeclarativeWebWindow, + cancelDeclarativeDiskInstall, confirmDeclarativeDiskInstall, listDeclarativePlugins, listDeclarativeWebWindows, @@ -18,6 +19,7 @@ import { type PreparedDeclarativeInstall, } from "./declarative-plugin-launch"; import { DeclarativePluginSettingsEditor } from "./declarative-plugin-settings-editor"; +import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; /** * Management surface for contract v1 declarative plugins. This block is @@ -33,6 +35,9 @@ export function DeclarativePluginsSection() { const [pending, setPending] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const workspacePath = useWorkspaceTabsStore((state) => + state.projectTabs.find((tab) => tab.isActive)?.path ?? null, + ); const refresh = useCallback(async () => { await rescanDeclarativePlugins(); @@ -44,6 +49,15 @@ export function DeclarativePluginsSection() { refresh().catch((reason: unknown) => setError(String(reason))); }, [refresh]); + useEffect(() => { + if (!pending) return; + return () => { + void cancelDeclarativeDiskInstall(pending.sessionId).catch((reason: unknown) => + console.warn("Could not discard plugin install review", reason), + ); + }; + }, [pending]); + const importPackage = async () => { const selection = await import("@tauri-apps/plugin-dialog").then(({ open }) => open({ @@ -87,7 +101,7 @@ export function DeclarativePluginsSection() { title={t("settings.declarativePlugins.title")} description={t("settings.declarativePlugins.description")} action={ - } @@ -98,13 +112,19 @@ export function DeclarativePluginsSection() {

    )} {pending && ( - void confirmInstall()} busy={busy} /> + void confirmInstall()} + onCancel={() => setPending(null)} + busy={busy} + /> )}
      {plugins.map((plugin) => ( void refresh()} /> ))} @@ -121,7 +141,7 @@ export function DeclarativePluginsSection() {
        {webWindows.map((window) => ( - + ))}
    @@ -149,9 +169,9 @@ function WebWindowRow({ window }: { window: DeclarativeWebToolWindow }) { size="sm" onClick={() => { if (open) { - void closeDeclarativeWebWindow(window.windowId).then(() => setOpen(false)); + void closeDeclarativeWebWindow(window.pluginId, window.windowId).then(() => setOpen(false)); } else { - void openDeclarativeWebWindow(window.windowId).then(() => setOpen(true)); + void openDeclarativeWebWindow(window.pluginId, window.windowId).then(() => setOpen(true)); } }} > @@ -166,10 +186,12 @@ function WebWindowRow({ window }: { window: DeclarativeWebToolWindow }) { function InstallReviewCard({ pending, onConfirm, + onCancel, busy, }: { pending: PreparedDeclarativeInstall; onConfirm: () => void; + onCancel: () => void; busy: boolean; }) { const { t } = useTranslation(); @@ -202,6 +224,9 @@ function InstallReviewCard({ + ); @@ -230,9 +255,11 @@ function ProgramDeclarationList({ programs }: { programs: unknown[] }) { function DeclarativePluginRow({ plugin, + workspacePath, onChanged, }: { plugin: DeclarativePluginSummary; + workspacePath: string | null; onChanged: () => void; }) { const { t } = useTranslation(); @@ -317,7 +344,7 @@ function DeclarativePluginRow({ )} diff --git a/windows/tauri/src/features/run-actions/components/run-actions-button.tsx b/windows/tauri/src/features/run-actions/components/run-actions-button.tsx index cfb98a98e0..365a1045ad 100644 --- a/windows/tauri/src/features/run-actions/components/run-actions-button.tsx +++ b/windows/tauri/src/features/run-actions/components/run-actions-button.tsx @@ -132,7 +132,7 @@ export default function RunActionsButton() { const [draft, setDraft] = useState(EMPTY_DRAFT); const triggerRef = useRef(null); const searchInputRef = useRef(null); - const { projectActions, declarativeAction, lspActions, isDiscovering, discoveryError, refresh } = + const { projectActions, declarativeActions, lspActions, isDiscovering, discoveryError, refresh } = useRunActionDiscovery( workspaceId, workspacePath, @@ -166,9 +166,8 @@ export default function RunActionsButton() { [customRunActions, query], ); const visibleDeclarativeActions = useMemo( - () => - declarativeAction && matchesRunAction(declarativeAction, query) ? [declarativeAction] : [], - [declarativeAction, query], + () => declarativeActions.filter((action) => matchesRunAction(action, query)), + [declarativeActions, query], ); const firstVisibleAction = visibleLspActions[0] ?? diff --git a/windows/tauri/src/features/run-actions/hooks/use-run-action-discovery.ts b/windows/tauri/src/features/run-actions/hooks/use-run-action-discovery.ts index b299ea91c5..071b7fff65 100644 --- a/windows/tauri/src/features/run-actions/hooks/use-run-action-discovery.ts +++ b/windows/tauri/src/features/run-actions/hooks/use-run-action-discovery.ts @@ -1,5 +1,5 @@ import { uiExtensionHost } from "@/extensions/ui/services/ui-extension-host"; -import { declarativeRunActionForFile } from "@/features/plugins/declarative-plugin-run"; +import { declarativeRunActionsForFile } from "@/features/plugins/declarative-plugin-run"; import { useExtensionStore } from "@/extensions/registry/extension-store"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; @@ -99,16 +99,20 @@ export function useRunActionDiscovery( }; }, [activeFilePath, enabled, workspacePath]); - const [declarativeAction, setDeclarativeAction] = useState(null); + const [declarativeActions, setDeclarativeActions] = useState([]); useEffect(() => { if (!enabled || !activeFilePath) { - setDeclarativeAction(null); + setDeclarativeActions([]); return; } let cancelled = false; - void declarativeRunActionForFile(activeFilePath).then((action) => { - if (!cancelled) setDeclarativeAction(action); - }); + void declarativeRunActionsForFile(activeFilePath) + .then((actions) => { + if (!cancelled) setDeclarativeActions(actions); + }) + .catch(() => { + if (!cancelled) setDeclarativeActions([]); + }); return () => { cancelled = true; }; @@ -130,7 +134,7 @@ export function useRunActionDiscovery( projectActions: projectActions.filter( (action) => !action.extensionId || enabledExtensions.split("\n").includes(action.extensionId), ), - declarativeAction, + declarativeActions, lspActions, isDiscovering, discoveryError, diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts index 4a5b2d9e15..ca7a31c5b9 100644 --- a/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts @@ -11,6 +11,7 @@ const globalListen = mock(async () => () => {}); const appendOutput = mock(() => undefined); const finishProcess = mock(() => undefined); const releaseRunSessionWorkspace = mock(() => undefined); +const declarativeFinished = mock(() => undefined); const actualRunStore = await import("../stores/run.store"); @@ -21,6 +22,9 @@ mock.module("@tauri-apps/api/webviewWindow", () => ({ }), })); mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); +mock.module("@/features/plugins/declarative-plugin-process-owner", () => ({ + declarativePluginProcessOwner: { finished: declarativeFinished }, +})); mock.module("../stores/run.store", () => ({ ...actualRunStore, runStoreForSession: () => ({ @@ -38,6 +42,7 @@ describe("run process event listeners", () => { appendOutput.mockClear(); finishProcess.mockClear(); releaseRunSessionWorkspace.mockClear(); + declarativeFinished.mockClear(); }); test("concurrent owners register run-output and run-exit only once on the current webview", async () => { @@ -68,4 +73,11 @@ describe("run process event listeners", () => { expect(appendOutput).toHaveBeenCalledWith("primary", "hello\n"); }); + + test("releases declarative process ownership when a run exits", () => { + const exitHandler = eventHandlers.get("run-exit"); + expect(exitHandler).toBeDefined(); + exitHandler?.({ payload: { sessionId: "declarative:php:1", exitCode: 0 } }); + expect(declarativeFinished).toHaveBeenCalledWith("declarative:php:1"); + }); }); diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.ts index 55851bf9d7..3ed84f3157 100644 --- a/windows/tauri/src/features/run/hooks/use-run-process-events.ts +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.ts @@ -1,4 +1,5 @@ import { extensionProcessOwner } from "@/extensions/run/extension-process-owner"; +import { declarativePluginProcessOwner } from "@/features/plugins/declarative-plugin-process-owner"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { releaseRunSessionWorkspace, runStoreForSession } from "../stores/run.store"; @@ -44,6 +45,7 @@ async function installRunProcessListeners(): Promise { .actions.finishProcess(sessionId, event.payload.exitCode); releaseRunSessionWorkspace(sessionId); extensionProcessOwner.finished(sessionId); + declarativePluginProcessOwner.finished(sessionId); }); } } diff --git a/windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts b/windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts index 416594278a..075bdabc6c 100644 --- a/windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts +++ b/windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts @@ -8,6 +8,7 @@ const released: string[] = []; // Native registry owners claimed by this window, so tests can observe leaked claims. const ownedByThisWindow = new Set(); const revokedIdeWorkspaces: string[] = []; +const stoppedDeclarativeWorkspaces: string[] = []; mock.module("@/features/host-api/mcp-connection", () => ({ disableMcp: async (id: string) => { revokedIdeWorkspaces.push(id); @@ -39,6 +40,13 @@ const actions = { mock.module("@/extensions/run/extension-process-owner", () => ({ extensionProcessOwner: { stop: async () => {} }, })); +mock.module("@/features/plugins/declarative-plugin-process-owner", () => ({ + declarativePluginProcessOwner: { + stop: async (workspaceId: string) => { + stoppedDeclarativeWorkspaces.push(workspaceId); + }, + }, +})); mock.module("@/features/settings/stores/settings.store", () => ({ useSettingsStore: { getState: () => ({ settings: { theme: "dark" } }) }, })); @@ -91,6 +99,7 @@ beforeEach(() => { released.length = 0; ownedByThisWindow.clear(); revokedIdeWorkspaces.length = 0; + stoppedDeclarativeWorkspaces.length = 0; }); afterEach(() => { mock.restore(); @@ -171,6 +180,7 @@ test("closing a project revokes IDE access before disposal and then releases own }), ).toBe(true); expect(disposed).toBe(true); + expect(stoppedDeclarativeWorkspaces).toEqual([id]); expect(released).toEqual([id]); expect(tabs).toEqual([]); }); diff --git a/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts b/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts index 3daee438b0..bc5125ad8d 100644 --- a/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts +++ b/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts @@ -1,4 +1,5 @@ import { extensionProcessOwner } from "@/extensions/run/extension-process-owner"; +import { declarativePluginProcessOwner } from "@/features/plugins/declarative-plugin-process-owner"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import type { WorkspaceRuntimeDescriptor } from "@/features/workspace/types/workspace-runtime.types"; @@ -486,6 +487,7 @@ async function closeWorkspaceRuntimeOnce( const { disableMcp } = await import("@/features/host-api/mcp-connection"); await disableMcp(workspaceId); await extensionProcessOwner.stop(workspaceId); + await declarativePluginProcessOwner.stop(workspaceId); await dispose?.(path); // Defensive: nothing can reactivate a closing project, but never leave the registry diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 2e1b602493..c777e7e256 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -808,6 +808,7 @@ async function createSession(args: JsonRecord, key: string): Promise { rootUri: fileUri(workspacePath), workingDirectory: workspacePath, initializationOptions: args.initializationOptions ?? null, + workspaceConfiguration: args.workspaceConfiguration ?? null, runtimeExecutablePath: args.runtimeExecutablePath ?? null, jdtlsLaunchResources: args.jdtlsLaunchResources ?? null, cacheDirectory: args.cacheDirectory ?? null, From 896407e05608a1590a8c23106c20aa152d5e2a16 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Fri, 23 Oct 2026 21:04:09 +0800 Subject: [PATCH 10/13] fix(windows): address plugin host review gaps --- .../src-tauri/capabilities/plugin-web.json | 7 +- .../src-tauri/src/plugin_host/install.rs | 6 + .../tauri/src-tauri/src/plugin_host/launch.rs | 195 +++++++++++++++++- .../tauri/src-tauri/src/plugin_host/web.rs | 12 +- .../declarative-plugin-settings-editor.tsx | 43 +++- .../plugins/plugin-web-open-file.test.ts | 10 + .../features/plugins/plugin-web-open-file.ts | 20 +- .../src/features/plugins/plugin-web-path.ts | 33 +++ .../tauri/src/platform/lsp-core-adapter.ts | 14 ++ 9 files changed, 310 insertions(+), 30 deletions(-) create mode 100644 windows/tauri/src/features/plugins/plugin-web-open-file.test.ts create mode 100644 windows/tauri/src/features/plugins/plugin-web-path.ts diff --git a/windows/tauri/src-tauri/capabilities/plugin-web.json b/windows/tauri/src-tauri/capabilities/plugin-web.json index 4c92d4d468..80f9a8597e 100644 --- a/windows/tauri/src-tauri/capabilities/plugin-web.json +++ b/windows/tauri/src-tauri/capabilities/plugin-web.json @@ -1,10 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2/capability", "identifier": "plugin-web-capability", - "description": "Minimal capability for declarative plugin web pages. Plugin windows (labels `plugin-web-*`) get only the core event plumbing; they deliberately receive none of the main capability's filesystem, HTTP, shell, dialog, or store permissions, so a package page cannot reach Tauri plugin APIs. Application commands (the bridge) are app-owned and gated in Rust by window label and plugin identity, not by this capability.", + "description": "Declarative plugin pages receive no direct Tauri permissions. Their only native entry point is the Rust audited bridge, which derives the calling plugin and window from the webview label. In particular, pages cannot emit application events or invoke filesystem, HTTP, shell, dialog, or store commands.", "windows": ["plugin-web-*"], - "permissions": [ - "core:default", - "core:event:default" - ] + "permissions": [] } diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index 76b5af67fd..71a8762e44 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -157,6 +157,10 @@ pub fn declarative_plugins_confirm_disk_install( // anything is written into the install root. validate_manifest(&manifest).map_err(|rejection| rejection.message)?; + // A version switch invalidates the page's package bytes and bridge + // authorization, so destroy all owned webviews before staging starts. + close_plugin_web_windows(&app, &plugin_id); + notify_plugin_lifecycle_change(&app, &plugin_id); staging_install(&install_root, &plugin_id, &version, &snapshot)?; write_installation_record(&install_root, &plugin_id, &version)?; rescan_after_change(&app); @@ -302,6 +306,8 @@ pub fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result "{plugin_id} has no previous version to roll back to" )); }; + close_plugin_web_windows(&app, &plugin_id); + notify_plugin_lifecycle_change(&app, &plugin_id); staging_install( &install_root, &plugin_id, diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index f6a624d526..0f0b4d3b4a 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -14,14 +14,24 @@ use super::ScannedPackage; use serde::Serialize; use serde_json::Value; use std::collections::BTreeMap; +use std::io::Read; use std::path::Path; use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; /// Windows reparse-point attribute; covers symlinks and junctions alike. #[cfg(windows)] const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; +/// Runtime version probes are untrusted package-selected executables. Keep +/// both their lifetime and captured output bounded before handing the text to +/// Core's shared version parser. +const RUNTIME_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_RUNTIME_PROBE_OUTPUT_BYTES: usize = 64 * 1024; + #[cfg(windows)] use std::os::windows::fs::MetadataExt; @@ -73,7 +83,11 @@ pub fn declarative_plugins_language_launch( )); } // Core wraps the plan in `{status, plan}`; resolve the inner value. - resolve_launch(&package, &language_id, &plan["plan"]).map(Some) + let resolved_plan = &plan["plan"]; + let workspace_state_directory = + state_directory.join(store::workspace_state_key(&workspace_path)); + prepare_required_directories(resolved_plan, &state_directory, &workspace_state_directory)?; + resolve_launch(&package, &language_id, resolved_plan).map(Some) } /// Finds an accepted, enabled scanned package by plugin ID. @@ -175,6 +189,41 @@ fn launch_request( }) } +/// Creates the host-owned directories requested by Core before the frontend +/// starts the process. The plan only names the two directories from the +/// shared contract; arbitrary paths are rejected at this boundary. +fn prepare_required_directories( + plan: &Value, + state_directory: &Path, + workspace_state_directory: &Path, +) -> Result<(), String> { + let Some(required) = plan.get("requiredDirectories") else { + return Ok(()); + }; + let required = required + .as_array() + .ok_or("launch plan requiredDirectories is not an array")?; + for directory in required { + let path = match directory.as_str() { + Some("state") => state_directory, + Some("workspaceState") => workspace_state_directory, + Some(other) => { + return Err(format!( + "launch plan requested an unknown directory {other}" + )); + } + None => return Err("launch plan requiredDirectories contains a non-string".to_string()), + }; + std::fs::create_dir_all(path).map_err(|error| { + format!( + "could not create launch directory {}: {error}", + path.display() + ) + })?; + } + Ok(()) +} + fn resolve_launch( package: &ScannedPackage, language_id: &str, @@ -269,14 +318,11 @@ fn verify_runtime_version(path: &Path, tool: &str, launch: &Value) -> Result<(), .ok_or_else(|| { format!("runtime launch declares minimumVersion without versionArguments") })?; - let output = std::process::Command::new(path) - .args(&version_arguments) - .output() - .map_err(|error| format!("runtime tool {tool} could not be executed: {error}"))?; + let output = bounded_version_probe(path, &version_arguments, tool)?; let text = format!( "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + String::from_utf8_lossy(&output.0), + String::from_utf8_lossy(&output.1) ); // Version parsing and comparison are Core semantics (shared // runtime-versions fixtures); the host only runs the probe and forwards @@ -309,6 +355,112 @@ fn verify_runtime_version(path: &Path, tool: &str, launch: &Value) -> Result<(), Ok(()) } +/// Runs a runtime version command with bounded lifetime and output capture. +/// The two reader threads continue draining pipes after the memory cap is +/// reached so a noisy child cannot deadlock on a full pipe before the timeout. +fn bounded_version_probe( + path: &Path, + arguments: &[String], + tool: &str, +) -> Result<(Vec, Vec), String> { + let mut child = Command::new(path) + .args(arguments) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("runtime tool {tool} could not be executed: {error}"))?; + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_probe(&mut child); + return Err(format!("runtime tool {tool} has no stdout pipe")); + } + }; + let stderr = match child.stderr.take() { + Some(stderr) => stderr, + None => { + terminate_probe(&mut child); + return Err(format!("runtime tool {tool} has no stderr pipe")); + } + }; + let stdout_reader = thread::spawn(move || read_probe_stream(stdout)); + let stderr_reader = thread::spawn(move || read_probe_stream(stderr)); + let deadline = Instant::now() + RUNTIME_PROBE_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + terminate_probe(&mut child); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(format!("runtime tool {tool} version probe timed out")); + } + Err(error) => { + terminate_probe(&mut child); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(format!("runtime tool {tool} version probe failed: {error}")); + } + } + }; + let stdout = stdout_reader + .join() + .map_err(|_| format!("runtime tool {tool} stdout reader failed"))? + .map_err(|error| format!("runtime tool {tool} stdout could not be read: {error}"))?; + let stderr = stderr_reader + .join() + .map_err(|_| format!("runtime tool {tool} stderr reader failed"))? + .map_err(|error| format!("runtime tool {tool} stderr could not be read: {error}"))?; + if stdout.1 || stderr.1 { + return Err(format!( + "runtime tool {tool} version probe output exceeds {} bytes", + MAX_RUNTIME_PROBE_OUTPUT_BYTES + )); + } + if !status.success() && stdout.0.is_empty() && stderr.0.is_empty() { + return Err(format!( + "runtime tool {tool} version probe exited with {status}" + )); + } + Ok((stdout.0, stderr.0)) +} + +fn read_probe_stream(mut stream: R) -> std::io::Result<(Vec, bool)> { + let mut output = Vec::with_capacity(MAX_RUNTIME_PROBE_OUTPUT_BYTES.min(8192)); + let mut buffer = [0_u8; 8192]; + let mut truncated = false; + loop { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + let remaining = MAX_RUNTIME_PROBE_OUTPUT_BYTES.saturating_sub(output.len()); + if remaining > 0 { + output.extend_from_slice(&buffer[..count.min(remaining)]); + } + if count > remaining { + truncated = true; + } + } + Ok((output, truncated)) +} + +/// Kills a probe process and any descendants that may have inherited its +/// output handles, then waits for the direct child to leave the process table. +fn terminate_probe(child: &mut Child) { + #[cfg(windows)] + { + let _ = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .status(); + } + let _ = child.kill(); + let _ = child.wait(); +} + /// Joins a package-relative plan path and proves it stays inside the package /// as a regular file. Every launch the host executes passes through here. fn verified_packaged_path(root: &Path, relative: &str) -> Result { @@ -385,6 +537,7 @@ fn string_arguments(value: &Value) -> Vec { #[cfg(test)] mod tests { use super::*; + use std::io::Cursor; /// Unique per-run temp root; mirrors the temp-dir convention used by the /// other src-tauri tests (std::env::temp_dir + pid + timestamp). @@ -508,6 +661,34 @@ mod tests { ); } + #[test] + fn required_launch_directories_are_created_from_contract_names() { + let temporary = unique_temp_root("required-directories"); + let state = temporary.join("state"); + let workspace = state.join("workspace"); + let plan = serde_json::json!({ + "requiredDirectories": ["state", "workspaceState"] + }); + prepare_required_directories(&plan, &state, &workspace).expect("directories created"); + assert!(state.is_dir()); + assert!(workspace.is_dir()); + assert!(prepare_required_directories( + &serde_json::json!({ "requiredDirectories": ["arbitrary"] }), + &state, + &workspace, + ) + .is_err()); + let _ = std::fs::remove_dir_all(&temporary); + } + + #[test] + fn runtime_probe_output_is_capped_without_truncating_the_reader() { + let input = vec![b'x'; MAX_RUNTIME_PROBE_OUTPUT_BYTES + 1]; + let (output, truncated) = read_probe_stream(Cursor::new(input)).expect("read probe"); + assert_eq!(output.len(), MAX_RUNTIME_PROBE_OUTPUT_BYTES); + assert!(truncated); + } + #[test] fn resolve_launch_consumes_the_real_core_response_envelope() { let temporary = unique_temp_root("launch-envelope"); diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index 7ae2fc21bf..76bd83256f 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -333,11 +333,13 @@ fn bridge_dispatch( let params = args.params.unwrap_or(Value::Null); let identity = bridge_identity(app, webview.label())?; match method { - "ui.getState" => Ok(declarative_plugins_web_get_state( - app.clone(), - identity.plugin_id, - identity.window_id, - )), + "ui.getState" => Ok(json!({ + "state": declarative_plugins_web_get_state( + app.clone(), + identity.plugin_id, + identity.window_id, + ) + })), "ui.setState" => { let Some(state) = params.get("state") else { return Err(bridge_error( diff --git a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx index dea4fd58ec..f55e211627 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx +++ b/windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "@/i18n/locale-provider"; import Switch from "@/ui/switch"; +import { Checkbox } from "@/ui/checkbox"; import NumberInput from "@/ui/number-input"; import SettingsSelect from "@/ui/settings-select"; import { pushDeclarativeWorkspaceConfiguration } from "@/platform/lsp-core-adapter"; @@ -73,8 +74,10 @@ export function DeclarativePluginSettingsEditor({ setError(null); try { const scope = setting.scope === "project" && workspacePath ? "project" : "global"; - // The default is forgotten so later default changes flow through. - const stored = value === setting.default ? null : value; + // Only the global layer forgets values equal to the declaration default. + // A project layer must retain an explicit default to override a + // different global value; null means “inherit global” there. + const stored = scope === "global" && valuesEqual(value, setting.default) ? null : value; await storeDeclarativeSetting(pluginId, setting.id, scope, workspacePath ?? "", stored); await refresh(); if (setting.effect === "immediate" && languageId && manifest && workspacePath) { @@ -199,6 +202,33 @@ function SettingControl({ }))} onChange={onApply} /> + ) : setting.type === "multiSelect" ? ( +
    + {(setting.options ?? []).map((option) => { + const selected = Array.isArray(resolved.value) + ? resolved.value.some((value) => String(value) === option.value) + : false; + return ( + + ); + })} +
    ) : ( { + expect(normalizeWorkspacePath("C:/work/src/../src/main.php")).toBe("C:/work/src/main.php"); + expect(pathIsInsideWorkspace("C:/work/src/../src/main.php", "C:/work")).toBe(true); + expect(pathIsInsideWorkspace("C:/work/../outside.txt", "C:/work")).toBe(false); + expect(pathIsInsideWorkspace("C:/workspace/file.php", "C:/work")).toBe(false); + expect(pathIsInsideWorkspace("relative/file.php", "C:/work")).toBe(false); +}); diff --git a/windows/tauri/src/features/plugins/plugin-web-open-file.ts b/windows/tauri/src/features/plugins/plugin-web-open-file.ts index cfec9c1221..9bcc24fa46 100644 --- a/windows/tauri/src/features/plugins/plugin-web-open-file.ts +++ b/windows/tauri/src/features/plugins/plugin-web-open-file.ts @@ -1,7 +1,8 @@ import { listen } from "@tauri-apps/api/event"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { invokeLsp } from "@/platform/lsp-core-adapter"; import { declarativePluginProcessOwner } from "./declarative-plugin-process-owner"; -import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { normalizeWorkspacePath, pathIsInsideWorkspace } from "./plugin-web-path"; interface PluginWebOpenFilePayload { pluginId: string; @@ -22,13 +23,14 @@ export async function initializePluginWebOpenFileListener(): Promise<() => void> "plugin-web-open-file", ({ payload }) => { if (!payload?.path) return; - const workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); const store = useFileSystemStore.getState(); const root = store.rootFolderPath; if (!root || !pathIsInsideWorkspace(payload.path, root)) return; + const normalizedPath = normalizeWorkspacePath(payload.path); + if (!normalizedPath) return; void store .handleFileSelect( - payload.path, + normalizedPath, false, payload.line ?? undefined, payload.column ?? undefined, @@ -44,13 +46,6 @@ export async function initializePluginWebOpenFileListener(): Promise<() => void> return unlisten; } -function pathIsInsideWorkspace(path: string, root: string): boolean { - const normalized = path.replace(/\\/g, "/"); - const normalizedRoot = root.replace(/\\/g, "/").replace(/\/$/, ""); - if (normalized.toLowerCase() === normalizedRoot.toLowerCase()) return false; - return normalized.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`); -} - /** * Stops declarative run sessions owned by a plugin whose state changed * (disable/uninstall) and releases its web-page state. @@ -59,7 +54,10 @@ async function initializePluginLifecycleListener(): Promise<() => void> { const unlisten = await listen<{ pluginId: string }>( "declarative-plugin-lifecycle", ({ payload }) => { - void declarativePluginProcessOwner.stop(undefined, payload.pluginId); + void Promise.allSettled([ + declarativePluginProcessOwner.stop(undefined, payload.pluginId), + invokeLsp("lsp_stop_for_provider", { providerId: payload.pluginId }), + ]); }, ); return unlisten; diff --git a/windows/tauri/src/features/plugins/plugin-web-path.ts b/windows/tauri/src/features/plugins/plugin-web-path.ts new file mode 100644 index 0000000000..be16543482 --- /dev/null +++ b/windows/tauri/src/features/plugins/plugin-web-path.ts @@ -0,0 +1,33 @@ +/** Normalizes path components before applying the workspace boundary check. */ +export function normalizeWorkspacePath(value: string): string | null { + const path = value.replace(/\\/g, "/"); + const prefix = path.startsWith("//") + ? "//" + : /^[A-Za-z]:\//.test(path) + ? path.slice(0, 3) + : path.startsWith("/") + ? "/" + : null; + if (!prefix) return null; + const parts: string[] = []; + for (const part of path.slice(prefix.length).split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + if (parts.length === 0) return null; + parts.pop(); + continue; + } + parts.push(part); + } + const normalized = `${prefix}${parts.join("/")}`.replace(/\/$/, ""); + return normalized || prefix; +} + +export function pathIsInsideWorkspace(path: string, root: string): boolean { + const normalized = normalizeWorkspacePath(path); + const normalizedRoot = normalizeWorkspacePath(root); + if (!normalized || !normalizedRoot || normalized === normalizedRoot) return false; + const candidate = normalized.toLowerCase(); + const workspace = normalizedRoot.toLowerCase(); + return candidate !== workspace && candidate.startsWith(`${workspace}/`); +} diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index c777e7e256..8e5f7250ef 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -59,6 +59,8 @@ interface Session { id: string; workspacePath: string; languageId: string; + /** Provider identity used to stop all sessions owned by a plugin. */ + providerId: string; files: Set; lifecycle: LspSessionLifecycle; eventPump: EventPumpOwner | null; @@ -83,6 +85,7 @@ interface StoredSession { id: string; workspacePath: string; languageId: string; + providerId?: string; files: string[]; features?: string[]; } @@ -170,6 +173,7 @@ function persistSessions(): void { id: session.id, workspacePath: session.workspacePath, languageId: session.languageId, + providerId: session.providerId, files: [...session.files].sort(), features: session.featureState.phase === "known" @@ -254,6 +258,7 @@ function restorePersistedSessions(): void { typeof stored.languageId !== "string" || !Array.isArray(stored.files) || !stored.files.every((file: unknown) => typeof file === "string") || + (stored.providerId !== undefined && typeof stored.providerId !== "string") || (stored.features !== undefined && (!Array.isArray(stored.features) || !stored.features.every((feature: unknown) => typeof feature === "string"))) @@ -265,6 +270,7 @@ function restorePersistedSessions(): void { id: stored.id, workspacePath: stored.workspacePath, languageId: stored.languageId, + providerId: stored.providerId ?? stored.languageId, files: new Set(), // Persisted entries are projections of Core state. They must be // validated before any new document can attach after a reload. @@ -830,6 +836,7 @@ async function createSession(args: JsonRecord, key: string): Promise { id: started.sessionId, workspacePath, languageId, + providerId, files: new Set(), lifecycle: createSessionLifecycle(started.state ?? "initializing", operationId), eventPump: null, @@ -1477,6 +1484,13 @@ export async function invokeLsp(command: string, args: JsonRecord = {}): Prom await start(args); return undefined as T; } + if (command === "lsp_stop_for_provider") { + const providerId = typeof args.providerId === "string" ? args.providerId : ""; + if (!providerId) return undefined as T; + const matches = [...sessions.values()].filter((session) => session.providerId === providerId); + await Promise.all(matches.map(stopSession)); + return undefined as T; + } if (command === "lsp_stop") { const preparation = getProjectPreparation(args.workspacePath); if (preparation) clearProjectPreparation(args.workspacePath, preparation.sessionId); From c198b543fcaffe38c22b9f812ea31f626996f85f Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sat, 24 Oct 2026 16:27:18 +0800 Subject: [PATCH 11/13] fix(windows): harden declarative plugin page and probe boundaries --- ...6-10-22-windows-declarative-plugin-host.md | 21 +- .../features/declarative-plugin-contract.json | 2 +- .../features/plugin-web-tool-windows.json | 12 +- windows/tauri/src-tauri/Cargo.lock | 1 + windows/tauri/src-tauri/Cargo.toml | 3 +- windows/tauri/src-tauri/build.rs | 7 +- .../src-tauri/capabilities/plugin-web.json | 6 +- .../permissions/declarative-web/bridge.toml | 4 + windows/tauri/src-tauri/src/main.rs | 24 +- .../tauri/src-tauri/src/plugin_host/launch.rs | 231 ++++--- .../tauri/src-tauri/src/plugin_host/web.rs | 605 ++++++++++++++++-- .../src-tauri/src/plugin_host/web_network.rs | 114 ++++ .../plugins/declarative-plugin-launch.ts | 5 +- .../plugins/plugin-web-appearance.test.ts | 101 +++ .../features/plugins/plugin-web-appearance.ts | 59 ++ .../plugins/plugin-web-open-file.test.ts | 21 +- .../features/plugins/plugin-web-open-file.ts | 28 +- .../src/features/plugins/plugin-web-path.ts | 35 +- 18 files changed, 1054 insertions(+), 225 deletions(-) create mode 100644 windows/tauri/src-tauri/permissions/declarative-web/bridge.toml create mode 100644 windows/tauri/src-tauri/src/plugin_host/web_network.rs create mode 100644 windows/tauri/src/features/plugins/plugin-web-appearance.test.ts create mode 100644 windows/tauri/src/features/plugins/plugin-web-appearance.ts diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index c62ed8163a..27aead84e9 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -42,7 +42,21 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 - 语言服务器进程仍由共享 Core 的 `lsp.startServer` 拉起:前端 `resolve-editor-lsp-launch.ts` 在 Worker 扩展清单没有对应服务器时,回落到 `declarative_plugins_language_launch`(契约插件排在 Worker 之后,已由 Worker 声明的语言保持现状,直到 M3 包迁移)。 - 启动扫描在应用 bootstrap 的 "declarative plugins" 步骤执行一次;扫描结果缓存在 Tauri state,前端经 `declarative_plugins_state` 只读消费。 -### 与 Worker 扩展模型共存 +### 网页与版本探测的运行边界 + +Tauri 把插件自定义协议页面视为远端来源,空 capability 会在进入 Rust handler 前拒绝所有 IPC。宿主采用 Tauri 官方 inlined plugin(应用内命令插件)扩展点:`declarative-web` 命名空间只声明一个 `request` 权限,仅匹配 `plugin-web-*` 窗口和 `http://lithe-plugin.*/*` 来源;不授予 fs/http/event 等命令,也不启用会改变全部主窗口命令的 app ACL。编译后的 Tauri authority 回归验证这条边界,不能用只检查 handler 的测试代替。 + +插件网页的窗口标签只用于定位所属包,不能单独证明当前文档可信。`web.rs` 使用 Tauri 的导航、新窗口和下载拦截器,只允许本包来源;每次桥接请求还读取 WebView 当前 URL,再核对插件来源、启用状态和窗口声明。宿主脚本也只在本包主框架定义桥,窗口使用非持久浏览会话,避免外站文档继承桥或页面存储。例如本包的 `ui/next.html` 可以导航,`https://example.com`、另一个插件的来源、带用户信息或端口的地址会被拒绝;不要用 CSP 或窗口标签代替导航与请求来源检查。 + +WebView2 的 NavigationStarting 可以阻止外站文档提交,但实测外站服务器仍可能先收到 GET。因此 `web_network.rs` 使用微软的 WebResourceRequested 接口,在请求层把非本包、非 Tauri IPC 的网络请求完成为本地 403。窗口先创建为惰性空白页,五秒期限内安装过滤器成功后才导航到包页面;失败时销毁空白窗口,不能留下可运行但未受保护的页面。Wry 只在初次创建时转换自定义协议,延迟 navigate 必须显式使用同一 http://lithe-plugin.<插件 ID>/ 映射地址,否则页面会停在空白文档;这个地址构造和来源检查共用同一回归。注册跟随 WebView 的生命周期,不在页面每次加载时叠加。复用 Wry 已依赖的 webview2-com 0.38.2 API,独立声明同一固定版本,没有新增另一套浏览器或网络后端。 + +`workspace.openFile` 必须先经原生文件系统解析真实目标,再检查它仍在同样经过解析的工作区根内。前端把这个目标交给既有文件打开入口,保留行列位置,并在等待解析后复查工作区有没有切换。工作区内指向内部文件的链接可以打开,指向外部目录的 junction(Windows 的目录联接)不能借路径文本绕过检查。不能仅比较 `C:/work/link/file.txt` 的文本前缀。 + +网页的语言、浅色或深色状态、颜色和字体变量从已解析的 Windows 工作台主题取得。主窗口创建页面时传入初值,主题注册表与设置的订阅把后续变化送到原生页面;根元素的 `data-theme`、`--lithe-*` 变量和只读 `lithe.context` 随之更新,再发 `ui.themeChanged`。每个 HTML 响应会在插件脚本前加载一个保留路径下的宿主同源外部脚本,由原生协议 handler 按可信窗口标签读取最新快照;因此页面重载、同包导航和带 UTF-8 BOM 的页面在运行插件代码前也能取得当前主题,HTML parser 已创建根元素。宿主脚本插在真实 head 起始标签后;省略 head 时退到 html 后,两者都省略时放在文档声明之后。扫描仅处理 HTML 前导区,跳过注释并识别带引号的属性,保留原有根样式、head 属性、BOM 和文档模式。这种响应字节插入不改包文件,也不给 CSP 增加内联脚本例外。订阅随 bootstrap 清理,不在插件内复制另一套主题值。 + +版本探测复用 `lithe_git_host::run` 的原生进程执行器。虽然它位于 Git adapter crate,其进程树、非阻塞管道与清理能力不解释 Git 参数;探测只设置五秒取消期限和每条输出流 64 KiB 上限,解析仍交 Core。Windows 在进程运行第一条指令之前把它放入 Job Object(内核管理的进程组),因此父进程退出后仍占用 stdout 的后代也会被清理。不要只给 `try_wait` 加超时后无期限 `join` 读取线程,也不要靠退出后遍历父 PID 找后代。代价是异常进程退出后最多还需执行器自身的有界清理时间。 + +### 与 Worker 扩展模型共存(产品分工) - 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 - PhpSupport 迁移(M3 调整):契约版 PHP 包(`Plugins/win/Official/PhpSupportContract`,`sameTeamAsHost` bundled)随安装器分发并完成 validate/launch/runPlan 端到端测试,但 Worker 版**保留不移除**。原因:Worker 版提供 composer scripts 的动态运行动作发现(读取工作区 composer.json 生成动作列表),契约 v1 的 `executionProviders` 是静态声明,没有等价能力;删除 Worker 版会丢失用户可见功能。完整迁移等契约补齐动态运行动作后再做。两包并存时 Worker 版优先(Worker 扩展清单先解析),契约包不重复注册语言。 @@ -70,6 +84,9 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 ## 验证 +- 网页隔离与上下文回归:`web.rs` 覆盖同包和外站 URL 的导航/桥接共用检查、真实路径与内部/外部目录链接;Windows 用无需提权的 junction,Unix 用目录符号链接。`plugin-web-appearance.test.ts` 执行原生宿主桥脚本,验证语言、主题、根变量、主题事件与外站不暴露桥;前端文件测试验证原生真实目标传递与拒绝传播。 +- 版本探测回归:`runtime_probe_collects_output_and_rejects_overflow`、`runtime_probe_reclaims_inherited_pipes_after_parent_exit` 使用当前测试二进制作为固定夹具;后者让父进程退出、后代保留管道,断言调用有界结束且后代已退出。测试环境变量仅附加到子进程,不改测试运行器的全局环境。运行 WindowsRust 与 Frontend 计时 harness,检查 `.artifacts/test-stability/index.html`;真实 WebView2 验证还要由本机 HTTP 计数器确认外站 GET 为零,同时检查导航、弹窗/下载、动态主题、包文件和发行目录哈希不变;单凭页面 URL 没变不能证明请求未外发。 + - M4 Web 工具窗口:`plugin_host/web.rs` 测试覆盖包路径规范化(`..`、编码分隔符、空段拒绝)、CSP 内容断言(default-src none、无 wasm-unsafe-eval、frame-ancestors none)、桥脚本冻结与作用域、状态键隔离;web-panel fixture 随 `ui:[1,2]` 声明提升转为被宿主接受(能力门控测试同步)。桥 v0 的 build.*/functions.* 返回 `plugin.web.unknownMethod`,等构建系统能力接入。 @@ -88,6 +105,8 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 ## 适用范围 +- `windows/tauri/src-tauri/build.rs`、`capabilities/plugin-web.json` 与 `permissions/declarative-web/bridge.toml`(应用内桥插件及最小来源权限) + - `windows/tauri/src-tauri/src/plugin_host/`(存储、扫描、启动接线与 fixtures 验收测试) - `windows/tauri/src-tauri/src/main.rs`(模块注册与命令注册) - `windows/tauri/src-tauri/src/language_tools.rs`(复用的系统工具查找) diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index 1074be2e49..759ded2036 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -49,5 +49,5 @@ }, "owner": "Extensions", "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", - "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移与宿主 UI 适配仍未实现,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。" + "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移与宿主 UI 适配仍未实现,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。" } diff --git a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json index 56ae58fcbf..c9721585c3 100644 --- a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json +++ b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json @@ -40,11 +40,17 @@ "windows/tauri/src-tauri/src/plugin_host/web.rs", "windows/tauri/src-tauri/src/plugin_host/tests.rs", "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", - "shared/contracts/plugins/v1/fixtures/web-panel.plugin.json" + "shared/contracts/plugins/v1/fixtures/web-panel.plugin.json", + "windows/tauri/src/features/plugins/plugin-web-appearance.ts", + "windows/tauri/src/features/plugins/plugin-web-appearance.test.ts", + "windows/tauri/src/features/plugins/plugin-web-open-file.test.ts", + "windows/tauri/src-tauri/capabilities/plugin-web.json", + "windows/tauri/src-tauri/permissions/declarative-web/bridge.toml", + "windows/tauri/src-tauri/src/plugin_host/web_network.rs" ], "implementationStatus": "partial", - "verificationStatus": "not-applicable" + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "自动回归:LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:WebView2 宿主尚未实现,继续只声明 ui 1。" + "verification": "自动回归:LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:ui 2 宿主已实现 state/openFile 与 locale/theme。运行 WindowsRust 和 Frontend 计时 harness,验证包外来源拒绝、内部/外部 junction 的真实目标边界、原生桥脚本的主题初值与事件;在真实 WebView2 中检查同包导航可用,外站导航、弹窗和下载被拒绝,外站得不到桥;切换语言和主题,确认 context、data-theme 与 --lithe-* 同步。设置页与 Web 窗口视觉验收仍 pending,不能以脚本测试代替。 Windows ACL 回归读取实际编译的 Tauri authority:插件私有来源仅允许 declarative-web request,拒绝 fs/http/event 及常规宿主命令,外站与主窗口不能借此调用桥;实机必须验证 state 往返成功,不能只验证拒绝分支。 Windows 请求层:页面加载前安装 WebResourceRequested 过滤器;本机 HTTP 计数器必须证明外站导航 GET 为零,不能仅以导航被取消或 URL 不变判断隔离成功。" } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 4e129e131c..2508fb28c0 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -3008,6 +3008,7 @@ dependencies = [ "tokio", "url", "webbrowser", + "webview2-com", "windows 0.61.3", "windows-sys 0.61.2", "zip 2.4.2", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index 4c57b1a29a..bfb42251bb 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -78,4 +78,5 @@ codegen-units = 1 [profile.dev.package.num-bigint-dig] opt-level = 3 [target.'cfg(windows)'.dependencies] -windows = { version = "=0.61.3", features = ["Win32_Graphics_DirectWrite", "Win32_Globalization"] } +windows = { version = "=0.61.3", features = ["Win32_Graphics_DirectWrite", "Win32_Globalization", "Win32_System_Com"] } +webview2-com = "=0.38.2" diff --git a/windows/tauri/src-tauri/build.rs b/windows/tauri/src-tauri/build.rs index 7b0b65c2cc..059dbc470a 100644 --- a/windows/tauri/src-tauri/build.rs +++ b/windows/tauri/src-tauri/build.rs @@ -20,7 +20,12 @@ use std::path::{Path, PathBuf}; const CONPTY_FILES: [&str; 3] = ["conpty.dll", "OpenConsole.exe", "ConPTY-LICENSE.txt"]; fn main() { - tauri_build::build(); + // Keep the page bridge in its own ACL namespace. Defining an app ACL for + // one command would also change authorization for every product command. + tauri_build::try_build( + tauri_build::Attributes::new().plugin("declarative-web", tauri_build::InlinedPlugin::new()), + ) + .expect("Tauri build failed"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; diff --git a/windows/tauri/src-tauri/capabilities/plugin-web.json b/windows/tauri/src-tauri/capabilities/plugin-web.json index 80f9a8597e..804767af38 100644 --- a/windows/tauri/src-tauri/capabilities/plugin-web.json +++ b/windows/tauri/src-tauri/capabilities/plugin-web.json @@ -1,7 +1,9 @@ { "$schema": "https://schema.tauri.app/config/2/capability", "identifier": "plugin-web-capability", - "description": "Declarative plugin pages receive no direct Tauri permissions. Their only native entry point is the Rust audited bridge, which derives the calling plugin and window from the webview label. In particular, pages cannot emit application events or invoke filesystem, HTTP, shell, dialog, or store commands.", + "description": "Plugin-private WebView2 origins reach only the audited declarative-web request bridge. The bridge independently checks the calling window, enabled package and current document origin. No filesystem, HTTP, event, shell, dialog or store commands are granted.", "windows": ["plugin-web-*"], - "permissions": [] + "local": false, + "remote": { "urls": ["http://lithe-plugin.*/*"] }, + "permissions": ["declarative-web:allow-request"] } diff --git a/windows/tauri/src-tauri/permissions/declarative-web/bridge.toml b/windows/tauri/src-tauri/permissions/declarative-web/bridge.toml new file mode 100644 index 0000000000..91619260ab --- /dev/null +++ b/windows/tauri/src-tauri/permissions/declarative-web/bridge.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "allow-request" +description = "Allows the plugin-private bridge request; native code authorizes the page identity, origin and method." +commands.allow = ["request"] diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 60f879a252..8d315640f4 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -63,8 +63,9 @@ fn main() { window.create = false; } let application = tauri::Builder::default() + .plugin(plugin_host::web::bridge_plugin()) .register_uri_scheme_protocol("lithe-plugin", |context, request| { - plugin_host::web::handle_scheme_request(context.app_handle(), request) + plugin_host::web::handle_scheme_request(context.app_handle(), context.webview_label(), request) }) .plugin(tauri_plugin_single_instance::init(|app, arguments, _| { host::enqueue_cli_arguments(app, arguments); @@ -153,6 +154,7 @@ fn main() { }) .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { + plugin_host::web::forget_window_appearance(window.app_handle(), window.label()); run::release_window_processes(window.label()); core::close_ide_hosts(window.label()); agent::close_window_connections(window.label()); @@ -170,10 +172,8 @@ fn main() { } } }) - // Plugin pages (labels `plugin-web-*`) reach exactly one audited - // command; every other application command is refused before any - // handler runs, so a package page cannot invoke host commands - // (process launch, filesystem, HTTP) through raw Tauri IPC. + // Plugin commands have their own ACL. The dedicated page bridge is + // registered above; plugin pages reach no product application command. .invoke_handler( |invoke: tauri::ipc::Invoke| match plugin_gate(invoke) { PluginGate::Handled => true, @@ -292,6 +292,8 @@ fn main() { run::run_stop_owned_processes, plugin_host::declarative_plugins_rescan, plugin_host::declarative_plugins_state, + plugin_host::web::declarative_plugins_web_resolve_file, + plugin_host::web::declarative_plugins_web_update_appearance, plugin_host::declarative_plugins_manifest, plugin_host::launch::declarative_plugins_language_launch, plugin_host::launch::declarative_plugins_language_owner, @@ -349,15 +351,9 @@ fn plugin_gate(invoke: tauri::ipc::Invoke) -> PluginGate { .starts_with("plugin-web-") { let command = invoke.message.command().to_string(); - if command != "declarative_plugins_web_bridge_request" { - invoke.resolver.reject(format!( - "command {command} is not available to plugin pages" - )); - return PluginGate::Handled; - } - // The bridge consumes the raw Invoke itself; nothing else in the - // application command surface is reachable from here. - plugin_host::web::declarative_plugins_web_bridge_request(invoke); + invoke.resolver.reject(format!( + "command {command} is not available to plugin pages" + )); return PluginGate::Handled; } PluginGate::Pass(invoke) diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index 0f0b4d3b4a..11d93c81d8 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -13,12 +13,11 @@ use super::store; use super::ScannedPackage; use serde::Serialize; use serde_json::Value; +use std::cell::Cell; use std::collections::BTreeMap; -use std::io::Read; use std::path::Path; use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::thread; +use std::process::Command; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; @@ -355,110 +354,60 @@ fn verify_runtime_version(path: &Path, tool: &str, launch: &Value) -> Result<(), Ok(()) } -/// Runs a runtime version command with bounded lifetime and output capture. -/// The two reader threads continue draining pipes after the memory cap is -/// reached so a noisy child cannot deadlock on a full pipe before the timeout. +/// Reuses the native executor's Job Object, nonblocking pipes and bounded +/// process-tree cleanup. The deadline covers pipe draining after parent exit; +/// package-selected background helpers cannot keep an LSP launch waiting. fn bounded_version_probe( path: &Path, arguments: &[String], tool: &str, ) -> Result<(Vec, Vec), String> { - let mut child = Command::new(path) - .args(arguments) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| format!("runtime tool {tool} could not be executed: {error}"))?; - let stdout = match child.stdout.take() { - Some(stdout) => stdout, - None => { - terminate_probe(&mut child); - return Err(format!("runtime tool {tool} has no stdout pipe")); - } - }; - let stderr = match child.stderr.take() { - Some(stderr) => stderr, - None => { - terminate_probe(&mut child); - return Err(format!("runtime tool {tool} has no stderr pipe")); - } - }; - let stdout_reader = thread::spawn(move || read_probe_stream(stdout)); - let stderr_reader = thread::spawn(move || read_probe_stream(stderr)); + let mut command = Command::new(path); + command.args(arguments); + run_version_probe(&mut command, tool) +} + +fn run_version_probe(command: &mut Command, tool: &str) -> Result<(Vec, Vec), String> { let deadline = Instant::now() + RUNTIME_PROBE_TIMEOUT; - let status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) if Instant::now() < deadline => { - thread::sleep(Duration::from_millis(10)); - } - Ok(None) => { - terminate_probe(&mut child); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err(format!("runtime tool {tool} version probe timed out")); - } - Err(error) => { - terminate_probe(&mut child); - let _ = stdout_reader.join(); - let _ = stderr_reader.join(); - return Err(format!("runtime tool {tool} version probe failed: {error}")); - } - } + let stdout_bytes = Cell::new(0_usize); + let stderr_bytes = Cell::new(0_usize); + let exceeded = || { + stdout_bytes.get() > MAX_RUNTIME_PROBE_OUTPUT_BYTES + || stderr_bytes.get() > MAX_RUNTIME_PROBE_OUTPUT_BYTES }; - let stdout = stdout_reader - .join() - .map_err(|_| format!("runtime tool {tool} stdout reader failed"))? - .map_err(|error| format!("runtime tool {tool} stdout could not be read: {error}"))?; - let stderr = stderr_reader - .join() - .map_err(|_| format!("runtime tool {tool} stderr reader failed"))? - .map_err(|error| format!("runtime tool {tool} stderr could not be read: {error}"))?; - if stdout.1 || stderr.1 { + let outcome = lithe_git_host::run( + command, + None, + || exceeded() || Instant::now() >= deadline, + || {}, + |stream, bytes| { + let count = match stream { + lithe_git_host::Stream::Stdout => &stdout_bytes, + lithe_git_host::Stream::Stderr => &stderr_bytes, + }; + count.set(count.get().saturating_add(bytes.len())); + }, + ); + if exceeded() { return Err(format!( "runtime tool {tool} version probe output exceeds {} bytes", MAX_RUNTIME_PROBE_OUTPUT_BYTES )); } - if !status.success() && stdout.0.is_empty() && stderr.0.is_empty() { + if let Some(failure) = outcome.failure { return Err(format!( - "runtime tool {tool} version probe exited with {status}" + "runtime tool {tool} version probe failed or timed out: {failure:?}" )); } - Ok((stdout.0, stderr.0)) -} - -fn read_probe_stream(mut stream: R) -> std::io::Result<(Vec, bool)> { - let mut output = Vec::with_capacity(MAX_RUNTIME_PROBE_OUTPUT_BYTES.min(8192)); - let mut buffer = [0_u8; 8192]; - let mut truncated = false; - loop { - let count = stream.read(&mut buffer)?; - if count == 0 { - break; - } - let remaining = MAX_RUNTIME_PROBE_OUTPUT_BYTES.saturating_sub(output.len()); - if remaining > 0 { - output.extend_from_slice(&buffer[..count.min(remaining)]); - } - if count > remaining { - truncated = true; - } - } - Ok((output, truncated)) -} - -/// Kills a probe process and any descendants that may have inherited its -/// output handles, then waits for the direct child to leave the process table. -fn terminate_probe(child: &mut Child) { - #[cfg(windows)] + if outcome.status.is_none_or(|status| !status.success()) + && outcome.stdout.is_empty() + && outcome.stderr.is_empty() { - let _ = Command::new("taskkill") - .args(["/PID", &child.id().to_string(), "/T", "/F"]) - .status(); + return Err(format!( + "runtime tool {tool} version probe exited without version output" + )); } - let _ = child.kill(); - let _ = child.wait(); + Ok((outcome.stdout, outcome.stderr)) } /// Joins a package-relative plan path and proves it stays inside the package @@ -537,7 +486,99 @@ fn string_arguments(value: &Value) -> Vec { #[cfg(test)] mod tests { use super::*; - use std::io::Cursor; + + /// Child-only fixture: environment is attached to Command, never mutated + /// in the test runner. Its libtest entry is also the executable on CI. + #[test] + #[ignore = "launched by runtime probe regression tests"] + fn version_probe_fixture() { + use std::io::Write; + match std::env::var("LITHE_PROBE_FIXTURE").as_deref() { + Ok("output") => println!("v21.0.0"), + Ok("overflow") => { + std::io::stdout() + .write_all(&vec![b'x'; MAX_RUNTIME_PROBE_OUTPUT_BYTES + 1]) + .unwrap(); + } + Ok("parent") => { + let mut command = probe_fixture_command("hold-pipes"); + command.stdin(std::process::Stdio::null()); + command.spawn().expect("inheriting helper"); + println!("v21.0.0"); + } + Ok("hold-pipes") => { + let marker = std::env::var("LITHE_PROBE_MARKER").expect("marker path"); + std::fs::write(marker, std::process::id().to_string()).expect("record helper"); + // The helper's guard only prevents a leaked fixture from living + // forever; the production Job Object must terminate it first. + let (_sender, receiver) = std::sync::mpsc::channel::<()>(); + let _ = receiver.recv_timeout(Duration::from_secs(30)); + } + _ => panic!("fixture requires a scoped child environment"), + } + } + + fn probe_fixture_command(mode: &str) -> Command { + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command + .args([ + "--exact", + "plugin_host::launch::tests::version_probe_fixture", + "--ignored", + "--nocapture", + ]) + .env("LITHE_PROBE_FIXTURE", mode); + command + } + + #[test] + fn runtime_probe_collects_output_and_rejects_overflow() { + let output = + run_version_probe(&mut probe_fixture_command("output"), "fixture").expect("probe"); + assert!(String::from_utf8_lossy(&output.0).contains("v21.0.0")); + let error = + run_version_probe(&mut probe_fixture_command("overflow"), "fixture").unwrap_err(); + assert!(error.contains("output exceeds"), "{error}"); + } + + #[test] + #[cfg(windows)] + fn runtime_probe_reclaims_inherited_pipes_after_parent_exit() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(unique_temp_root("inherited-pipes")); + let marker = root.0.join("helper.pid"); + let mut command = probe_fixture_command("parent"); + command.env("LITHE_PROBE_MARKER", &marker); + let began = Instant::now(); + let error = run_version_probe(&mut command, "fixture").unwrap_err(); + assert!( + began.elapsed() < RUNTIME_PROBE_TIMEOUT + Duration::from_secs(3), + "{error}" + ); + let pid: u32 = std::fs::read_to_string(marker) + .expect("helper started") + .parse() + .unwrap(); + // The parent is already gone: a PID walk could no longer own this helper. + // Opening a terminated process may still succeed while the kernel reaps it. + use windows_sys::Win32::{ + Foundation::CloseHandle, + System::Threading::{OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE}, + }; + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if !handle.is_null() { + let waited = WaitForSingleObject(handle, 1000); + CloseHandle(handle); + assert_eq!(waited, 0, "version helper survived process-tree cleanup"); + } + } + } /// Unique per-run temp root; mirrors the temp-dir convention used by the /// other src-tauri tests (std::env::temp_dir + pid + timestamp). @@ -681,14 +722,6 @@ mod tests { let _ = std::fs::remove_dir_all(&temporary); } - #[test] - fn runtime_probe_output_is_capped_without_truncating_the_reader() { - let input = vec![b'x'; MAX_RUNTIME_PROBE_OUTPUT_BYTES + 1]; - let (output, truncated) = read_probe_stream(Cursor::new(input)).expect("read probe"); - assert_eq!(output.len(), MAX_RUNTIME_PROBE_OUTPUT_BYTES); - assert!(truncated); - } - #[test] fn resolve_launch_consumes_the_real_core_response_envelope() { let temporary = unique_temp_root("launch-envelope"); diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index 76bd83256f..c7ad4f3a76 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -10,13 +10,17 @@ //! this module. use super::scan::ScannedPackage; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +#[cfg(windows)] +#[path = "web_network.rs"] +mod network; + /// Largest package file the scheme serves; a page cannot map the whole /// package into memory through one request. const MAX_PAGE_FILE_BYTES: u64 = 16 * 1024 * 1024; @@ -29,10 +33,46 @@ const PAGE_CSP: &str = "default-src 'none'; script-src 'self'; style-src 'self' /// Per-window page state (`ui.getState` / `ui.setState`), kept in the host so /// web storage can stay non-persistent. Bounded per contract. const MAX_PAGE_STATE_BYTES: usize = 64 * 1024; +/// Reserved same-origin host script, served before any package-authored code. +const HOST_BRIDGE_PATH: &str = "__lithe_host__/bridge.js"; #[derive(Default)] pub struct WebPageHostState { states: Mutex>, + appearances: Mutex>, +} + +/// Resolved workbench locale and theme tokens; supplied only by host windows. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebPageAppearance { + pub locale: String, + pub theme: String, + pub variables: BTreeMap, +} + +/// Checks the real document origin, including WebView2's custom-scheme mapping. +/// Navigation and IPC share this predicate so a window label alone grants nothing. +fn is_package_url(url: &url::Url, plugin_id: &str) -> bool { + if !url.username().is_empty() || url.password().is_some() || url.port().is_some() { + return false; + } + let private_origin = url.scheme() == "lithe-plugin" && url.host_str() == Some(plugin_id); + let mapped_origin = cfg!(windows) + && url.scheme() == "http" + && url.host_str() == Some(format!("lithe-plugin.{plugin_id}").as_str()); + (private_origin || mapped_origin) && sanitize_package_path(url.path()).is_ok() +} + +fn require_package_url(url: &url::Url, plugin_id: &str) -> Result<(), String> { + if is_package_url(url, plugin_id) { + Ok(()) + } else { + Err(bridge_error( + "plugin.web.forbidden", + "the document is outside its plugin package", + )) + } } /// State key of one page: plugin ID and tool window ID. @@ -92,10 +132,11 @@ fn collect_web_windows(package: &ScannedPackage, windows: &mut Vec Result<(), String> { let Some(window) = declarative_plugins_web_windows(app.clone()) .into_iter() @@ -109,28 +150,106 @@ pub fn declarative_plugins_open_web_window( "plugin-web-{}", web_window_label(&window.plugin_id, &window.window_id) ); + app.state::() + .appearances + .lock() + .expect("appearance mutex") + .insert(label.clone(), appearance.clone()); if let Some(existing) = app.get_webview_window(&label) { let _ = existing.set_focus(); return Ok(()); } - let url = format!( - "lithe-plugin://{}/{}", - urlencoding_encode(&window.plugin_id), - window.entry - ); - // Initialisation script defines the frozen `window.lithe` bridge before - // any page script runs. - let bridge = bridge_script(&window.plugin_id, &window.window_id); - WebviewWindowBuilder::new( + let target_url = package_page_url(&window.plugin_id, &window.entry)?; + let request_plugin_id = window.plugin_id.clone(); + let plugin_id = window.plugin_id.clone(); + // Start inert. The request filter must be installed before package code + // can navigate or otherwise cause an outbound request. + let view = WebviewWindowBuilder::new( &app, &label, - WebviewUrl::CustomProtocol(url.parse().map_err(|error| format!("{error}"))?), + WebviewUrl::External("about:blank".parse().expect("blank URL")), ) .title(window.title) - .initialization_script(&bridge) + .incognito(true) + .on_navigation(move |url| is_package_url(url, &plugin_id)) + .on_new_window(|_, _| tauri::webview::NewWindowResponse::Deny) + .on_download(|_, _| false) .build() - .map(|_| ()) - .map_err(|error| format!("web window could not open: {error}")) + .map_err(|error| format!("web window could not open: {error}"))?; + let setup: Result<(), String> = async { + #[cfg(windows)] + network::restrict_requests(&view, request_plugin_id).await?; + view.navigate(target_url).map_err(|error| error.to_string()) + } + .await; + if let Err(error) = setup { + forget_window_appearance(&app, &label); + if let Err(close_error) = view.close() { + return Err(format!( + "{error}; failed to close the inactive page: {close_error}" + )); + } + return Err(error); + } + Ok(()) +} + +fn package_page_url(plugin_id: &str, entry: &str) -> Result { + // Wry maps custom schemes only at creation. A later navigate() reaches + // WebView2 directly, so it must use the same mapped origin explicitly. + #[cfg(windows)] + let url = format!("http://lithe-plugin.{plugin_id}/{entry}"); + #[cfg(not(windows))] + let url = format!("lithe-plugin://{plugin_id}/{entry}"); + url.parse() + .map_err(|error: url::ParseError| error.to_string()) +} + +fn appearance_script(appearance: &WebPageAppearance) -> String { + format!( + "window.__litheDeliverEvent?.('ui.themeChanged', {});", + json!(appearance) + ) +} + +/// Applies a workbench change to existing pages and their next reload. +#[tauri::command] +pub fn declarative_plugins_web_update_appearance( + app: AppHandle, + appearance: WebPageAppearance, +) -> Result<(), String> { + let windows: Vec<_> = app + .webview_windows() + .into_iter() + .filter(|(label, _)| label.starts_with("plugin-web-")) + .collect(); + { + let state = app.state::(); + let mut appearances = state.appearances.lock().expect("appearance mutex"); + for (label, _) in &windows { + appearances.insert(label.clone(), appearance.clone()); + } + } + let mut failures = Vec::new(); + for (label, window) in windows { + if let Err(error) = window.eval(appearance_script(&appearance)) { + failures.push(format!("{label}: {error}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} + +/// Native window destruction releases its cached appearance, including X-close. +pub fn forget_window_appearance(app: &AppHandle, label: &str) { + app.state::() + .appearances + .lock() + .expect("appearance mutex") + .remove(label); } /// Destroys the web view of one tool window; used on plugin disable, @@ -198,18 +317,20 @@ pub fn forget_plugin_state(app: &AppHandle, plugin_id: &str) { guard.retain(|key, _| !key.starts_with(&prefix)); } -/// The document-start script that defines the frozen `window.lithe` bridge. +/// The first parser-blocking script defines the frozen `window.lithe` bridge. /// v0 methods: context, request (state + openFile), on; build.* and /// functions.* resolve to `plugin.web.unknownMethod` until their Windows /// capabilities land. -fn bridge_script(plugin_id: &str, window_id: &str) -> String { +fn bridge_script(plugin_id: &str, window_id: &str, appearance: &WebPageAppearance) -> String { let context = json!({ "pluginID": plugin_id, "windowID": window_id, "platform": "windows", + "locale": appearance.locale, }); const_bridge_script() .replace("__LITHE_CONTEXT__", &context.to_string()) + .replace("__LITHE_APPEARANCE__", &json!(appearance).to_string()) .replace("__LITHE_PLUGIN__", &json!(plugin_id).to_string()) .replace("__LITHE_WINDOW__", &json!(window_id).to_string()) } @@ -219,10 +340,28 @@ fn bridge_script(plugin_id: &str, window_id: &str) -> String { fn const_bridge_script() -> &'static str { r#"(() => { "use strict"; - const context = Object.freeze(__LITHE_CONTEXT__); + const initial = __LITHE_CONTEXT__; + // Never expose a bridge in another document, even if native navigation + // policy regresses or a page fetches this reserved script explicitly. + const url = new URL(window.location.href); + if (window !== window.top || url.username || url.password || url.port || + !((url.protocol === 'lithe-plugin:' && url.hostname === initial.pluginID) || + (url.protocol === 'http:' && url.hostname === 'lithe-plugin.' + initial.pluginID))) return; + let appearance = __LITHE_APPEARANCE__; + const context = Object.freeze({ ...initial, + get locale() { return appearance.locale; }, + get theme() { return appearance.theme; }, + }); + const applyTheme = () => { + const root = document.documentElement; + if (!root) { document.addEventListener('readystatechange', applyTheme, { once: true }); return; } + root.setAttribute('data-theme', appearance.theme); + root.lang = appearance.locale; + for (const [name, value] of Object.entries(appearance.variables)) root.style.setProperty(name, value); + }; const listeners = new Map(); const invoke = (method, params) => window.__TAURI_INTERNALS__.invoke( - "declarative_plugins_web_bridge_request", + "plugin:declarative-web|request", { method, params }, ); const bridge = { @@ -246,14 +385,34 @@ fn const_bridge_script() -> &'static str { Object.freeze(bridge); Object.defineProperty(window, "lithe", { value: Object.freeze(bridge), configurable: false, writable: false }); window.__litheDeliverEvent = (event, payload) => { + if (event === 'ui.themeChanged') { + appearance = payload; + applyTheme(); + payload = { theme: appearance.theme }; + } const set = listeners.get(event); if (set) for (const listener of [...set]) { try { listener(payload); } catch { /* a failing listener must not break the pump */ } } }; + applyTheme(); })();"# } +/// Minimal Tauri plugin namespace so custom-protocol pages can receive one +/// ACL-granted command without changing the main application's command ACL. +pub fn bridge_plugin() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("declarative-web") + .invoke_handler(|invoke| { + if invoke.message.command() != "request" { + return false; + } + handle_bridge_request(invoke); + true + }) + .build() +} + /// The single Tauri entry behind `lithe.request`. The calling page's /// identity is derived from the invoking webview's label /// (`plugin-web-`), never from arguments the page supplies; the @@ -261,10 +420,9 @@ fn const_bridge_script() -> &'static str { /// declare this window and be enabled. A page can only reach its own /// plugin's state and the workspace layer. /// -/// This consumes the raw `Invoke` (the invoke handler in `main.rs` dispatches -/// plugin-page traffic here directly) so no other command can be reached -/// through this path. -pub fn declarative_plugins_web_bridge_request(invoke: tauri::ipc::Invoke) { +/// The inlined plugin's ACL admits only this request, and this handler then +/// enforces document and package identity before dispatching any method. +fn handle_bridge_request(invoke: tauri::ipc::Invoke) { let webview = invoke.message.webview_ref().clone(); // The bridge payload arrives as the command's JSON arguments envelope. let params: BridgeArgs = match invoke.message.payload() { @@ -332,6 +490,10 @@ fn bridge_dispatch( let method = args.method.as_str(); let params = args.params.unwrap_or(Value::Null); let identity = bridge_identity(app, webview.label())?; + let url = webview + .url() + .map_err(|error| bridge_error("plugin.web.forbidden", error.to_string()))?; + require_package_url(&url, &identity.plugin_id)?; match method { "ui.getState" => Ok(json!({ "state": declarative_plugins_web_get_state( @@ -396,8 +558,7 @@ pub struct BridgeIdentity { } /// Resolves and authorizes a bridge caller from its webview label. The label -/// is `plugin-web-` (see `open_web_window`); the window ID -/// is the label-safe form of a declared tool-window ID, so authorization +/// includes a collision-free encoding of plugin and window IDs; authorization /// re-derives it from the owning package's current catalog entry. Anything /// that does not round-trip — a label from another window kind, a window the /// plugin no longer declares, a disabled or uninstalled plugin, or a forged @@ -465,6 +626,46 @@ fn bridge_error(code: &str, message: impl Into) -> String { json!({ "code": code, "message": message.into() }).to_string() } +/// Resolves symlinks/junctions before the workspace file-open entry can read. +/// Returning the canonical target also avoids re-resolving the original link +/// using stale file-tree metadata in `handleFileSelect`. +#[tauri::command] +pub fn declarative_plugins_web_resolve_file( + workspace_path: PathBuf, + path: PathBuf, +) -> Result { + let root = workspace_path.canonicalize().map_err(|error| { + bridge_error( + "plugin.web.forbidden", + format!("workspace is unreadable: {error}"), + ) + })?; + let target = if path.is_absolute() { + path + } else { + root.join(path) + }; + let target = target.canonicalize().map_err(|error| { + bridge_error( + "plugin.web.forbidden", + format!("file is unreadable: {error}"), + ) + })?; + if target == root || !target.starts_with(&root) || !target.is_file() { + return Err(bridge_error( + "plugin.web.forbidden", + "the file is outside the workspace", + )); + } + let path = target.to_string_lossy().replace('\\', "/"); + // Keep the editor's conventional drive/UNC spelling after canonicalize. + Ok(if let Some(unc) = path.strip_prefix("//?/UNC/") { + format!("//{unc}") + } else { + path.strip_prefix("//?/").unwrap_or(&path).to_string() + }) +} + /// Declared web window IDs of one plugin from the current catalog. pub fn web_windows_of_plugin( app: &AppHandle, @@ -495,6 +696,11 @@ pub fn close_web_window_by_id( window_id: &str, ) { let label = format!("plugin-web-{}", web_window_label(plugin_id, window_id)); + app.state::() + .appearances + .lock() + .expect("appearance mutex") + .remove(&label); if let Some(existing) = app.get_webview_window(&label) { let _ = existing.close(); } @@ -640,17 +846,11 @@ fn content_type(path: &Path) -> &'static str { } } -fn urlencoding_encode(value: &str) -> String { - // Plugin IDs are reverse-DNS ASCII after Core validation; only the - // characters the URL parser cares about need escaping. - value.replace('.', ".") -} - -/// Tauri scheme handler for `lithe-plugin:///`. Windows -/// routes custom schemes through `http://lithe-plugin./`, so the -/// plugin ID arrives in the URI host and the path in the request path. +/// Tauri scheme handler for `lithe-plugin:///`. Wry restores +/// the original URI before delivery; its context supplies the trusted label. pub fn handle_scheme_request( app: &AppHandle, + webview_label: &str, request: http::Request>, ) -> http::Response> { let ok = |content_type: &'static str, body: Vec| { @@ -672,30 +872,174 @@ pub fn handle_scheme_request( .body(message.as_bytes().to_vec()) .expect("response builds") }; - // Windows routes custom schemes as `http://./`, so - // the URL host is `.lithe-plugin` (scheme suffix, dots kept). - let host = request.uri().host().unwrap_or_default().to_string(); - let plugin_id = restore_plugin_id(&host); - let path = request.uri().path().trim_start_matches('/').to_string(); - match serve_package_file(app, &plugin_id, &path) { + // Wry restores `http://lithe-plugin./...` to the original + // `lithe-plugin:///...` before invoking this scheme handler. + let plugin_id = request.uri().host().unwrap_or_default(); + let identity = match bridge_identity(app, webview_label) { + Ok(identity) if identity.plugin_id == plugin_id => identity, + _ => return refused("resource request is outside its plugin page"), + }; + let path = request.uri().path().trim_start_matches('/'); + if path == HOST_BRIDGE_PATH { + let appearance = app + .state::() + .appearances + .lock() + .expect("appearance mutex") + .get(webview_label) + .cloned(); + return match appearance { + Some(appearance) => ok( + "text/javascript; charset=utf-8", + bridge_script(&identity.plugin_id, &identity.window_id, &appearance).into_bytes(), + ), + None => refused("page is no longer active"), + }; + } + match serve_package_file(app, plugin_id, path) { + Ok((content_type, body)) if content_type.starts_with("text/html") => { + ok(content_type, page_with_host_bridge(body)) + } Ok((content_type, body)) => ok(content_type, body), Err(message) => refused(&message), } } -/// Restores the reverse-DNS plugin ID from the URL host form: WebView2 -/// routes `lithe-plugin://id/path` to the host `id.lithe-plugin`, so the -/// scheme suffix is stripped and dots are kept as-is. -fn restore_plugin_id(host: &str) -> String { - host.strip_suffix(".lithe-plugin") - .unwrap_or(host) - .to_string() +/// Inserts the host script before package code without rewriting the package. +/// Only the HTML prologue is scanned: comments/declarations and the optional +/// html/head opening tags. Stop before any other tag or text, so a literal +/// inside script/style content is never interpreted as markup here. +fn page_with_host_bridge(body: Vec) -> Vec { + let offset = bridge_insertion_offset(&body); + let tag = format!(""); + let mut response = Vec::with_capacity(body.len() + tag.len()); + response.extend_from_slice(&body[..offset]); + response.extend_from_slice(tag.as_bytes()); + response.extend_from_slice(&body[offset..]); + response +} + +fn bridge_insertion_offset(body: &[u8]) -> usize { + let mut index = if body.starts_with(b"\xef\xbb\xbf") { + 3 + } else { + 0 + }; + let mut fallback = index; + loop { + while body.get(index).is_some_and(u8::is_ascii_whitespace) { + index += 1; + } + if body[index..].starts_with(b"") else { + return fallback; + }; + index += 4 + end + 3; + fallback = index; + continue; + } + if body[index..].starts_with(b"' || *value == b'/') + { + return fallback; + } + if !name.eq_ignore_ascii_case(b"html") && !name.eq_ignore_ascii_case(b"head") { + return fallback; + } + let Some(end) = opening_tag_end(body, name_end) else { + return fallback; + }; + if name.eq_ignore_ascii_case(b"head") { + return end; + } + index = end; + fallback = end; + } +} + +/// HTML attribute quotes can contain `>`; they do not close the opening tag. +fn opening_tag_end(body: &[u8], mut index: usize) -> Option { + let mut quote = None; + while let Some(&value) = body.get(index) { + match quote { + Some(expected) if value == expected => quote = None, + Some(_) => {} + None if value == b'\'' || value == b'"' => quote = Some(value), + None if value == b'>' => return Some(index + 1), + None => {} + } + index += 1; + } + None } #[cfg(test)] mod tests { use super::*; + #[test] + fn compiled_acl_grants_only_the_bridge_to_plugin_private_origins() { + // Exercise Tauri's actual compiled capability resolution, including + // its distinction between the app's origin and custom-protocol pages. + let mut context: tauri::Context = tauri::generate_context!(); + let acl = context.runtime_authority_mut(); + let label = "plugin-web-fixture"; + let own = tauri::ipc::Origin::Remote { + url: url::Url::parse("http://lithe-plugin.acme.php/ui/index.html").unwrap(), + }; + assert!(acl + .resolve_access("plugin:declarative-web|request", label, label, &own) + .is_some()); + for command in [ + "plugin:fs|read_text_file", + "plugin:http|fetch", + "plugin:event|emit", + "core_execute", + "read_local_file", + ] { + assert!( + acl.resolve_access(command, label, label, &own).is_none(), + "{command}" + ); + } + let foreign = tauri::ipc::Origin::Remote { + url: url::Url::parse("https://example.test/ui/index.html").unwrap(), + }; + assert!(acl + .resolve_access("plugin:declarative-web|request", label, label, &foreign) + .is_none()); + assert!(acl + .resolve_access("plugin:declarative-web|request", "main", "main", &own) + .is_none()); + let manifests: Value = serde_json::from_str(include_str!(concat!( + env!("OUT_DIR"), + "/acl-manifests.json" + ))) + .unwrap(); + assert!( + manifests.get("__app__").is_none(), + "the product's custom-command ACL must stay unchanged" + ); + } + #[test] fn package_paths_reject_traversal_and_empty() { assert!(sanitize_package_path("ui/index.html").is_ok()); @@ -722,12 +1066,173 @@ mod tests { #[test] fn bridge_script_is_frozen_and_scoped() { - let script = bridge_script("acme.php", "window-id"); + let script = bridge_script( + "acme.php", + "window-id", + &WebPageAppearance { + locale: "zh-CN".into(), + theme: "dark".into(), + variables: BTreeMap::new(), + }, + ); assert!(script.contains(r#""pluginID":"acme.php""#)); assert!(script.contains("Object.freeze(bridge)")); assert!(script.contains("writable: false")); } + #[test] + fn html_loads_the_fresh_host_bridge_before_package_code() { + let tag = ""; + let prologue = "y\">"; + let tail = ""; + for bom in ["", "\u{feff}"] { + let body = format!("{bom}{prologue}{tail}"); + let response = String::from_utf8(page_with_host_bridge(body.into_bytes())).unwrap(); + assert_eq!(response, format!("{bom}{prologue}{tag}{tail}")); + } + // No synthetic doctype or root tags: preserve the package's HTML mode + // and attributes even when its html/head tags are legitimately omitted. + for (prefix, tail) in [ + ( + "", + "", + ), + ( + "", + "", + ), + ( + "", + "", + ), + ("", ""), + ] { + assert_eq!( + String::from_utf8(page_with_host_bridge( + format!("{prefix}{tail}").into_bytes() + )) + .unwrap(), + format!("{prefix}{tag}{tail}") + ); + } + assert!(!PAGE_CSP.contains("script-src 'self' 'unsafe-inline'")); + } + + #[test] + fn navigation_and_bridge_refuse_foreign_documents() { + let initial = package_page_url("acme.php", "ui/index.html").unwrap(); + assert!(is_package_url(&initial, "acme.php")); + assert_eq!( + initial.as_str(), + if cfg!(windows) { + "http://lithe-plugin.acme.php/ui/index.html" + } else { + "lithe-plugin://acme.php/ui/index.html" + } + ); + for address in [ + "lithe-plugin://acme.php/ui/index.html", + "lithe-plugin://acme.php/ui/next.html#section", + ] { + assert!(require_package_url(&url::Url::parse(address).unwrap(), "acme.php").is_ok()); + } + for address in [ + "https://example.com/ui/index.html", + "http://lithe-plugin.acme.php.evil.test/ui/index.html", + "lithe-plugin://other.plugin/ui/index.html", + "lithe-plugin://user@acme.php/ui/index.html", + "lithe-plugin://acme.php:1234/ui/index.html", + "about:blank", + "data:text/html,evil", + "file:///tmp/index.html", + "lithe-plugin://acme.php/%2e%2e%2foutside.html", + ] { + let error = + require_package_url(&url::Url::parse(address).unwrap(), "acme.php").unwrap_err(); + assert!(error.contains("plugin.web.forbidden"), "{address}"); + } + assert_eq!( + is_package_url( + &url::Url::parse("http://lithe-plugin.acme.php/ui/index.html").unwrap(), + "acme.php" + ), + cfg!(windows) + ); + } + + #[test] + fn open_file_resolves_targets_before_checking_workspace_boundary() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(super::serve_tests::unique_temp_root("workspace-paths")); + let workspace = root.0.join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let inside = workspace.join("inside"); + let outside = root.0.join("outside"); + std::fs::create_dir(&inside).unwrap(); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(inside.join("file.txt"), "inside").unwrap(); + std::fs::write(outside.join("file.txt"), "outside").unwrap(); + assert!( + declarative_plugins_web_resolve_file(workspace.clone(), "inside/file.txt".into()) + .is_ok() + ); + assert!( + declarative_plugins_web_resolve_file(workspace.clone(), outside.join("file.txt")) + .is_err() + ); + link_directory(&inside, &workspace.join("inside-link")); + link_directory(&outside, &workspace.join("outside-link")); + let resolved = + declarative_plugins_web_resolve_file(workspace.clone(), "inside-link/file.txt".into()) + .unwrap(); + assert!(resolved.ends_with("/inside/file.txt")); + assert!(declarative_plugins_web_resolve_file( + workspace.clone(), + "outside-link/file.txt".into() + ) + .is_err()); + // Canonicalize both sides so a workspace opened through a link works. + link_directory(&workspace, &root.0.join("linked-root")); + assert!(declarative_plugins_web_resolve_file( + root.0.join("linked-root"), + "inside/file.txt".into() + ) + .is_ok()); + } + + fn link_directory(target: &Path, link: &Path) { + #[cfg(windows)] + { + // Junctions need no developer-mode/admin privilege and protect + // the same reparse-point escape boundary as directory symlinks. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut command = std::process::Command::new("cmd"); + command + .args(["/D", "/C", "mklink", "/J"]) + .arg(link) + .arg(target); + let outcome = lithe_git_host::run( + &mut command, + None, + || std::time::Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + assert!( + outcome.failure.is_none() && outcome.status.is_some_and(|status| status.success()), + "junction creation failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + } + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).expect("directory symlink"); + } + #[test] fn bridge_payload_uses_its_method_and_refuses_claimed_identity() { let args: BridgeArgs = serde_json::from_value(json!({ @@ -771,7 +1276,7 @@ mod tests { mod serve_tests { use super::*; - fn unique_temp_root(label: &str) -> PathBuf { + pub(super) fn unique_temp_root(label: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock") diff --git a/windows/tauri/src-tauri/src/plugin_host/web_network.rs b/windows/tauri/src-tauri/src/plugin_host/web_network.rs new file mode 100644 index 0000000000..a9783c46a1 --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/web_network.rs @@ -0,0 +1,114 @@ +//! WebView2 request boundary installed before a plugin document can execute. +//! +//! NavigationStarting prevents document commit but can arrive after a network +//! request has started. WebResourceRequested completes foreign requests locally +//! instead, so cancelling navigation cannot leak page state in a request URL. + +use std::time::Duration; +use tauri::WebviewWindow; +use webview2_com::{Microsoft::Web::WebView2::Win32::*, WebResourceRequestedEventHandler}; +use windows::{ + core::{w, PWSTR}, + Win32::System::Com::CoTaskMemFree, +}; + +fn request_is_allowed(raw: &str, plugin_id: &str) -> bool { + let Ok(url) = url::Url::parse(raw) else { + return false; + }; + super::is_package_url(&url, plugin_id) + || url.scheme() == "data" + || (url.scheme() == "http" + && url.host_str() == Some("ipc.localhost") + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none()) +} + +pub(super) async fn restrict_requests( + view: &WebviewWindow, + plugin_id: String, +) -> Result<(), String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + view.with_webview(move |platform| { + // SAFETY: Tauri runs this closure on the controller's UI thread. All + // interfaces remain on that thread; the view owns the event subscription. + let result = + unsafe { install_filter(platform, plugin_id).map_err(|error| error.to_string()) }; + let _ = sender.send(result); + }) + .map_err(|error| error.to_string())?; + tokio::time::timeout(Duration::from_secs(5), receiver) + .await + .map_err(|_| "plugin page request-filter setup timed out".to_string())? + .map_err(|_| "plugin page request-filter setup was cancelled".to_string())? +} + +unsafe fn install_filter( + platform: tauri::webview::PlatformWebview, + plugin_id: String, +) -> windows::core::Result<()> { + let core = platform.controller().CoreWebView2()?; + let environment = platform.environment(); + core.AddWebResourceRequestedFilter(w!("*"), COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL)?; + let mut token = 0; + core.add_WebResourceRequested( + &WebResourceRequestedEventHandler::create(Box::new(move |_, args| { + let Some(args) = args else { + return Ok(()); + }; + let request = args.Request()?; + let mut uri = PWSTR::null(); + request.Uri(&mut uri)?; + // WebView2 allocates this string with CoTaskMemAlloc. Free it even if + // URL decoding fails; no request URI is retained in a diagnostic. + let raw = if uri.0.is_null() { + None + } else { + uri.to_string().ok() + }; + CoTaskMemFree(Some(uri.0.cast())); + if !raw + .as_deref() + .is_some_and(|value| request_is_allowed(value, &plugin_id)) + { + let response = environment.CreateWebResourceResponse( + None, + 403, + w!("Forbidden"), + w!("Content-Type: text/plain\r\nCache-Control: no-store"), + )?; + args.SetResponse(&response)?; + } + Ok(()) + })), + &mut token, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_package_assets_and_native_ipc_can_reach_the_request_layer() { + for allowed in [ + "http://lithe-plugin.acme.php/ui/index.html", + "http://ipc.localhost/", + "data:image/png;base64,AA==", + ] { + assert!(request_is_allowed(allowed, "acme.php"), "{allowed}"); + } + for refused in [ + "https://example.test/", + "http://127.0.0.1/foreign", + "http://lithe-plugin.other/ui/index.html", + "http://user@ipc.localhost/", + "http://ipc.localhost:1234/", + "file:///outside.txt", + ] { + assert!(!request_is_allowed(refused, "acme.php"), "{refused}"); + } + } +} diff --git a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts index e329b63b5d..cd642fe9ea 100644 --- a/windows/tauri/src/features/plugins/declarative-plugin-launch.ts +++ b/windows/tauri/src/features/plugins/declarative-plugin-launch.ts @@ -1,4 +1,5 @@ import { invoke } from "@/platform/tauri-core"; +import { getPluginWebAppearance } from "./plugin-web-appearance"; /** * Host API for contract v1 declarative plugin packages. The Rust @@ -259,7 +260,9 @@ export function listDeclarativeWebWindows(): Promise /** Opens (or focuses) the web view of one tool window. */ export function openDeclarativeWebWindow(pluginId: string, windowId: string): Promise { - return invoke("declarative_plugins_open_web_window", { pluginId, windowId }); + return invoke("declarative_plugins_open_web_window", { + pluginId, windowId, appearance: getPluginWebAppearance(), + }); } /** Destroys the web view of one tool window. */ diff --git a/windows/tauri/src/features/plugins/plugin-web-appearance.test.ts b/windows/tauri/src/features/plugins/plugin-web-appearance.test.ts new file mode 100644 index 0000000000..1f9f188ba8 --- /dev/null +++ b/windows/tauri/src/features/plugins/plugin-web-appearance.test.ts @@ -0,0 +1,101 @@ +import { expect, spyOn, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Window } from "happy-dom"; +import { themeRegistry } from "@/extensions/themes/theme-registry"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import * as native from "@/platform/tauri-core"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { getPluginWebAppearance, initializePluginWebAppearanceSync } from "./plugin-web-appearance"; + +test("plugin appearance uses resolved workbench colors, fonts and locale", () => { + const restore = installHappyDom(); + const current = useSettingsStore.getState(); + const getState = spyOn(useSettingsStore, "getState").mockReturnValue({ ...current, + settings: { ...current.settings, uiFontFamily: "Test UI", uiFontSize: 15, fontFamily: "Test Mono" }, + }); + try { + const root = document.documentElement; + root.setAttribute("data-theme-type", "dark"); + root.style.setProperty("--background", "#101010"); + root.style.setProperty("--app-font-family", "Test UI"); + root.style.setProperty("--editor-font-family", "Test Mono"); + const appearance = getPluginWebAppearance(); + expect(appearance.locale).toBe(useSettingsStore.getState().settings.displayLanguage); + expect(appearance.theme).toBe("dark"); + expect(appearance.variables["--lithe-background"]).toBe("#101010"); + expect(appearance.variables["--lithe-font-family"]).toStartWith('"Test UI",'); + expect(appearance.variables["--lithe-font-size"]).toBe("15px"); + expect(appearance.variables["--lithe-code-font-family"]).toStartWith('"Test Mono",'); + expect(appearance.variables["--lithe-code-font-size"]).toBe(`${useSettingsStore.getState().settings.fontSize}px`); + } finally { getState.mockRestore(); restore(); } +}); + +test("theme updates reach native pages and unsubscribe at bootstrap cleanup", () => { + const restore = installHappyDom(); + let changed = () => {}; + let stopped = false; + const subscribe = spyOn(themeRegistry, "onThemeChange").mockImplementation((callback) => { + changed = () => callback("fixture"); + return () => { stopped = true; }; + }); + const invoke = spyOn(native, "invoke").mockResolvedValue(undefined); + const stop = initializePluginWebAppearanceSync(); + try { + document.documentElement.setAttribute("data-theme-type", "dark"); + changed(); + expect(invoke).toHaveBeenCalledWith("declarative_plugins_web_update_appearance", { + appearance: expect.objectContaining({ theme: "dark" }), + }); + } finally { + stop(); + expect(stopped).toBe(true); + subscribe.mockRestore(); + invoke.mockRestore(); + restore(); + } +}); + +// Execute the exact native document-start bootstrap rather than a JS copy. +const source = readFileSync(new URL("../../../src-tauri/src/plugin_host/web.rs", import.meta.url), "utf8"); +const template = source.match(/fn const_bridge_script[\s\S]*?r#"([\s\S]*?)"#/u)?.[1]; +if (!template) throw new Error("native plugin bootstrap not found"); +const bootstrap = template + .replace("__LITHE_CONTEXT__", JSON.stringify({ pluginID: "acme.php", windowID: "panel", platform: "windows", locale: "zh-CN" })) + .replace("__LITHE_APPEARANCE__", JSON.stringify({ locale: "zh-CN", theme: "dark", variables: { "--lithe-background": "#101010" } })); + +function runBootstrap(page: Window) { + new Function("window", "document", bootstrap)(page, page.document); + return page as unknown as { + lithe?: { context: { locale: string; theme: string }; on: (event: string, listener: (payload: { theme: string }) => void) => () => void }; + __litheDeliverEvent: (event: string, payload: unknown) => void; + }; +} + +test("native bridge provides context and root tokens before page code and updates them live", () => { + const page = new Window({ url: "http://lithe-plugin.acme.php/ui/index.html" }); + try { + const bridge = runBootstrap(page); + expect(Object.isFrozen(bridge.lithe!.context)).toBe(true); + expect(bridge.lithe!.context.locale).toBe("zh-CN"); + expect(bridge.lithe!.context.theme).toBe("dark"); + expect(page.document.documentElement.dataset.theme).toBe("dark"); + expect(page.document.documentElement.style.getPropertyValue("--lithe-background")).toBe("#101010"); + const received: string[] = []; + bridge.lithe!.on("ui.themeChanged", (value) => { received.push(value.theme); }); + bridge.__litheDeliverEvent("ui.themeChanged", {locale:"en-US",theme:"light",variables:{"--lithe-background":"#ffffff"}}); + expect(bridge.lithe!.context.theme).toBe("light"); + expect(bridge.lithe!.context.locale).toBe("en-US"); + expect(received).toEqual(["light"]); + expect(page.document.documentElement.dataset.theme).toBe("light"); + expect(page.document.documentElement.style.getPropertyValue("--lithe-background")).toBe("#ffffff"); + } finally { page.close(); } +}); + +test("document-start bootstrap exposes no bridge to a foreign document", () => { + for (const url of ["https://example.test/", "http://lithe-plugin.other.plugin/ui/index.html", "about:blank"]) { + const page = new Window({ url }); + try { + expect(runBootstrap(page).lithe).toBeUndefined(); + } finally { page.close(); } + } +}); diff --git a/windows/tauri/src/features/plugins/plugin-web-appearance.ts b/windows/tauri/src/features/plugins/plugin-web-appearance.ts new file mode 100644 index 0000000000..390b1a7455 --- /dev/null +++ b/windows/tauri/src/features/plugins/plugin-web-appearance.ts @@ -0,0 +1,59 @@ +import { themeRegistry } from "@/extensions/themes/theme-registry"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, getTypographyFontFallbacks } from "@/features/settings/config/typography-defaults"; +import { buildFontFamilyStack } from "@/features/settings/lib/font-family-resolution"; +import { normalizeUiFontSize } from "@/features/settings/lib/ui-font-size"; +import { invoke } from "@/platform/tauri-core"; +import { IS_WINDOWS } from "@/utils/platform"; + +export interface PluginWebAppearance { + locale: string; + theme: "light" | "dark"; + variables: Record; +} + +/** Shares the workbench's resolved tokens with package pages, like macOS. */ +export function getPluginWebAppearance(): PluginWebAppearance { + const root = document.documentElement; + const style = getComputedStyle(root); + const read = (name: string) => style.getPropertyValue(name).trim(); + const settings = useSettingsStore.getState().settings; + const colorTokens: Record = { + background: "--background", surface: "--surface", header: "--tab-bar-bg", + "popup-background": "--popover", foreground: "--foreground", + "secondary-foreground": "--muted-foreground", border: "--border", accent: "--accent", + link: "--info", success: "--success", warning: "--warning", error: "--destructive", + }; + const variables = Object.fromEntries(Object.entries(colorTokens).map(([name, token]) => + [`--lithe-${name}`, read(token) || read("--foreground")], + )); + // Settings subscriptions run before FontStyleInjector's React effect. Reuse + // its font resolution helpers so pages receive the new values immediately. + const { mono, sans } = getTypographyFontFallbacks(IS_WINDOWS); + variables["--lithe-font-family"] = buildFontFamilyStack(settings.uiFontFamily || DEFAULT_UI_FONT_FAMILY, sans); + variables["--lithe-font-size"] = `${normalizeUiFontSize(settings.uiFontSize)}px`; + variables["--lithe-code-font-family"] = buildFontFamilyStack(settings.fontFamily || DEFAULT_MONO_FONT_FAMILY, mono); + variables["--lithe-code-font-size"] = `${settings.fontSize}px`; + return { + locale: settings.displayLanguage, + theme: root.getAttribute("data-theme-type") === "dark" ? "dark" : "light", + variables, + }; +} + +/** Owns and releases both subscriptions with the workspace bootstrap. */ +export function initializePluginWebAppearanceSync(): () => void { + const sync = () => { + void invoke("declarative_plugins_web_update_appearance", { appearance: getPluginWebAppearance() }) + .catch((error) => console.warn("Plugin page appearance update failed", error)); + }; + const stopTheme = themeRegistry.onThemeChange(sync); + const stopSettings = useSettingsStore.subscribe((state, previous) => { + if (state.settings.displayLanguage !== previous.settings.displayLanguage || + state.settings.fontSize !== previous.settings.fontSize || + state.settings.fontFamily !== previous.settings.fontFamily || + state.settings.uiFontFamily !== previous.settings.uiFontFamily || + state.settings.uiFontSize !== previous.settings.uiFontSize) sync(); + }); + return () => { stopTheme(); stopSettings(); }; +} diff --git a/windows/tauri/src/features/plugins/plugin-web-open-file.test.ts b/windows/tauri/src/features/plugins/plugin-web-open-file.test.ts index 41071c7298..cf2a9a7163 100644 --- a/windows/tauri/src/features/plugins/plugin-web-open-file.test.ts +++ b/windows/tauri/src/features/plugins/plugin-web-open-file.test.ts @@ -1,10 +1,15 @@ -import { expect, test } from "bun:test"; -import { normalizeWorkspacePath, pathIsInsideWorkspace } from "./plugin-web-path"; +import { expect, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import { resolvePluginWebFilePath } from "./plugin-web-path"; -test("normalizes path components before enforcing the workspace boundary", () => { - expect(normalizeWorkspacePath("C:/work/src/../src/main.php")).toBe("C:/work/src/main.php"); - expect(pathIsInsideWorkspace("C:/work/src/../src/main.php", "C:/work")).toBe(true); - expect(pathIsInsideWorkspace("C:/work/../outside.txt", "C:/work")).toBe(false); - expect(pathIsInsideWorkspace("C:/workspace/file.php", "C:/work")).toBe(false); - expect(pathIsInsideWorkspace("relative/file.php", "C:/work")).toBe(false); +test("open-file uses the native resolved target and propagates boundary rejection", async () => { + const invoke = spyOn(native, "invoke").mockResolvedValue("C:/work/inside.txt"); + try { + expect(await resolvePluginWebFilePath("C:/work/link.txt", "C:/work")).toBe("C:/work/inside.txt"); + expect(invoke).toHaveBeenCalledWith("declarative_plugins_web_resolve_file", { + path: "C:/work/link.txt", workspacePath: "C:/work", + }); + invoke.mockRejectedValue({ code: "plugin.web.forbidden" }); + await expect(resolvePluginWebFilePath("C:/work/escape.txt", "C:/work")).rejects.toEqual({ code: "plugin.web.forbidden" }); + } finally { invoke.mockRestore(); } }); diff --git a/windows/tauri/src/features/plugins/plugin-web-open-file.ts b/windows/tauri/src/features/plugins/plugin-web-open-file.ts index 9bcc24fa46..ebbce0d321 100644 --- a/windows/tauri/src/features/plugins/plugin-web-open-file.ts +++ b/windows/tauri/src/features/plugins/plugin-web-open-file.ts @@ -2,7 +2,8 @@ import { listen } from "@tauri-apps/api/event"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { invokeLsp } from "@/platform/lsp-core-adapter"; import { declarativePluginProcessOwner } from "./declarative-plugin-process-owner"; -import { normalizeWorkspacePath, pathIsInsideWorkspace } from "./plugin-web-path"; +import { resolvePluginWebFilePath } from "./plugin-web-path"; +import { initializePluginWebAppearanceSync } from "./plugin-web-appearance"; interface PluginWebOpenFilePayload { pluginId: string; @@ -21,26 +22,25 @@ interface PluginWebOpenFilePayload { export async function initializePluginWebOpenFileListener(): Promise<() => void> { const unlisten = await listen( "plugin-web-open-file", - ({ payload }) => { + async ({ payload }) => { if (!payload?.path) return; const store = useFileSystemStore.getState(); const root = store.rootFolderPath; - if (!root || !pathIsInsideWorkspace(payload.path, root)) return; - const normalizedPath = normalizeWorkspacePath(payload.path); - if (!normalizedPath) return; - void store - .handleFileSelect( - normalizedPath, + if (!root) return; + try { + const resolvedPath = await resolvePluginWebFilePath(payload.path, root); + if (useFileSystemStore.getState().rootFolderPath !== root) return; + await store.handleFileSelect( + resolvedPath, false, payload.line ?? undefined, payload.column ?? undefined, undefined, false, - ) - .catch(() => { - // A failed open is reported by the store's own error surface; the - // page only gets an ack either way. - }); + ); + } catch (error) { + console.warn("Plugin workspace.openFile request was rejected", error); + } }, ); return unlisten; @@ -67,8 +67,10 @@ async function initializePluginLifecycleListener(): Promise<() => void> { export async function initializePluginWebListeners(): Promise<() => void> { const unlistenOpen = await initializePluginWebOpenFileListener(); const unlistenLifecycle = await initializePluginLifecycleListener(); + const stopAppearanceSync = initializePluginWebAppearanceSync(); return () => { unlistenOpen(); unlistenLifecycle(); + stopAppearanceSync(); }; } diff --git a/windows/tauri/src/features/plugins/plugin-web-path.ts b/windows/tauri/src/features/plugins/plugin-web-path.ts index be16543482..480f384746 100644 --- a/windows/tauri/src/features/plugins/plugin-web-path.ts +++ b/windows/tauri/src/features/plugins/plugin-web-path.ts @@ -1,33 +1,6 @@ -/** Normalizes path components before applying the workspace boundary check. */ -export function normalizeWorkspacePath(value: string): string | null { - const path = value.replace(/\\/g, "/"); - const prefix = path.startsWith("//") - ? "//" - : /^[A-Za-z]:\//.test(path) - ? path.slice(0, 3) - : path.startsWith("/") - ? "/" - : null; - if (!prefix) return null; - const parts: string[] = []; - for (const part of path.slice(prefix.length).split("/")) { - if (!part || part === ".") continue; - if (part === "..") { - if (parts.length === 0) return null; - parts.pop(); - continue; - } - parts.push(part); - } - const normalized = `${prefix}${parts.join("/")}`.replace(/\/$/, ""); - return normalized || prefix; -} +import { invoke } from "@/platform/tauri-core"; -export function pathIsInsideWorkspace(path: string, root: string): boolean { - const normalized = normalizeWorkspacePath(path); - const normalizedRoot = normalizeWorkspacePath(root); - if (!normalized || !normalizedRoot || normalized === normalizedRoot) return false; - const candidate = normalized.toLowerCase(); - const workspace = normalizedRoot.toLowerCase(); - return candidate !== workspace && candidate.startsWith(`${workspace}/`); +/** The native host resolves every path component, including directory junctions. */ +export function resolvePluginWebFilePath(path: string, workspacePath: string): Promise { + return invoke("declarative_plugins_web_resolve_file", { path, workspacePath }); } From d34b794e041a91a6182befa264a797332037ef61 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Sat, 24 Oct 2026 22:55:34 +0800 Subject: [PATCH 12/13] fix(windows): drain declarative plugin owners before package changes Preserve plugin identity and cancel in-flight starts; enforce launch requirements, scope web pages and events to their owners, and expose declarative UI contributions. Add bounded regression coverage and document the remaining parity work. --- ...6-10-22-windows-declarative-plugin-host.md | 30 +- .github/workflows/ci-windows.yml | 14 + .../features/declarative-plugin-contract.json | 10 +- .../features/declarative-plugin-settings.json | 9 +- .../features/plugin-extension-management.json | 6 +- .../features/plugin-web-tool-windows.json | 8 +- windows/tauri/src-tauri/src/main.rs | 11 +- .../src-tauri/src/plugin_host/install.rs | 72 +- .../tauri/src-tauri/src/plugin_host/launch.rs | 268 +++++- .../src-tauri/src/plugin_host/lifecycle.rs | 310 +++++++ .../tauri/src-tauri/src/plugin_host/mod.rs | 24 +- .../tauri/src-tauri/src/plugin_host/tests.rs | 47 ++ .../tauri/src-tauri/src/plugin_host/web.rs | 775 +++++++++++++++--- .../src/extensions/ui/types/ui-extension.ts | 1 + .../src/features/bootstrap/bootstrap-async.ts | 4 +- .../components/command-palette.tsx | 9 +- .../src/features/editor/lsp/lsp-client.ts | 2 + .../lsp/resolve-editor-lsp-launch.test.ts | 1 + .../editor/lsp/resolve-editor-lsp-launch.ts | 2 + .../components/plugin-activity-rail.tsx | 10 + .../plugins/declarative-plugin-launch.ts | 19 +- .../declarative-plugin-lifecycle.test.ts | 21 + .../plugins/declarative-plugin-lifecycle.ts | 23 + .../plugins/declarative-plugin-run.ts | 29 +- .../declarative-plugin-settings-editor.tsx | 1 + .../plugins/declarative-plugin-ui.test.ts | 89 ++ .../features/plugins/declarative-plugin-ui.ts | 109 +++ .../plugins/plugin-lifecycle-listener.test.ts | 158 ++++ .../features/plugins/plugin-web-open-file.ts | 68 +- .../window/components/window-menu-bar.tsx | 8 + .../services/workspace-lifecycle.test.ts | 6 + .../workspace/services/workspace-lifecycle.ts | 2 + .../src/platform/lsp-core-adapter.test.ts | 48 ++ .../tauri/src/platform/lsp-core-adapter.ts | 85 +- 34 files changed, 2001 insertions(+), 278 deletions(-) create mode 100644 windows/tauri/src-tauri/src/plugin_host/lifecycle.rs create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-lifecycle.test.ts create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-lifecycle.ts create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts create mode 100644 windows/tauri/src/features/plugins/declarative-plugin-ui.ts create mode 100644 windows/tauri/src/features/plugins/plugin-lifecycle-listener.test.ts diff --git a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md index 27aead84e9..14cca53000 100644 --- a/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md +++ b/.agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md @@ -24,7 +24,7 @@ Windows 新增一套契约 v1 声明式插件宿主,与现有 Worker 扩展模 ### 能力声明渐进开放 -宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1,2], serverProcess:[1], settings:[1,2], ui:[1,2] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。后续按里程碑提升:M3 增 `executionProviders:1`、`settings:1`,并把 `languageServers` 提到 2、`settings` 提到 2(注册设置页与 `workspaceConfiguration` 下推);M4 增 `ui:[1,2]`。宿主不声明的能力,市场侧按不支持展示。 +宿主在 `plugins.declarative.validate` 的 payload 里声明 `hostCapabilities = { languages:[1], languageServers:[1,2], serverProcess:[1], settings:[1,2], ui:[1,2] }`。Core 的 `capabilities.rs` 会拒绝声明了宿主未开放能力的插件,宿主不需要自己写拒绝逻辑。M3 已把 `languageServers` 与 `settings` 提到 2,注册设置页并下推 `workspaceConfiguration`;M4 已增 `ui:[1,2]`。`executionProviders`、`buildProviders` 与 `buildSystems` 仍未开放,运行/测试计划命令的存在不代表这些插件能力可安装。宿主不声明的能力,市场侧按不支持展示。 ### 存储、信任与安装(v0) @@ -56,6 +56,24 @@ WebView2 的 NavigationStarting 可以阻止外站文档提交,但实测外站 版本探测复用 `lithe_git_host::run` 的原生进程执行器。虽然它位于 Git adapter crate,其进程树、非阻塞管道与清理能力不解释 Git 参数;探测只设置五秒取消期限和每条输出流 64 KiB 上限,解析仍交 Core。Windows 在进程运行第一条指令之前把它放入 Job Object(内核管理的进程组),因此父进程退出后仍占用 stdout 的后代也会被清理。不要只给 `try_wait` 加超时后无期限 `join` 读取线程,也不要靠退出后遍历父 PID 找后代。代价是异常进程退出后最多还需执行器自身的有界清理时间。 +### 禁用、替换和卸载必须先收回使用权 + +插件包正在执行时直接删目录,会在 Windows 上遇到可执行文件被占用,并可能只删掉一部分文件。因此所有启停、安装替换、回滚和卸载先经过 `lifecycle.rs`:禁止新的原生启动计划和网页读取,等待已经开始的版本探测、校验进程、网页创建和资源读取退出,再关闭所有所属页面,并要求每个产品窗口确认其运行与 Core LSP 会话都已停止。原生使用计数和禁止新请求共用锁,避免刚检查完就漏进新的操作;等待原生使用者和窗口确认各有 45 秒上限。失败时保留包文件并继续禁止使用,用户重试管理操作后才能恢复。 + +前端在调用第一个异步操作前登记启动归属。停止操作必须覆盖已启动和正在等待 Core 响应的会话;晚到的会话先停止、再拒绝返回。停止失败的会话仍留在登记表中,后续管理操作必须继续等它,不能误报没有进程。包目录与扫描目录只有在变更和重新扫描都成功后才恢复使用。完整扫描从发现到提交缓存共用一把锁,避免不同插件并发管理时,旧扫描覆盖新目录。 + +正确做法:先收回新请求、等待所有使用者退出,再修改包;不要发一个不等待结果的停止事件就删除文件。每个窗口只能确认自己的操作,窗口消失算失败,不能假定它的 Core 子进程已经退出。探测与读取沿用既有 `plugin-state` 和包读取路径,没有新增可跨工作树复用的资源;校验命令在平台可写的插件状态目录执行,发行资源保持只读。 + +### 插件身份、工作区和声明式界面 + +`pluginId` 是 Core 校验配置更新的插件身份,`providerId` 是语言服务实例的来源,两者必须分别传递和保存。设置的即时生效请求必须带插件 ID,并只发送到仍有效且属于该插件的会话;Java 和 Worker 启动继续使用自己的来源。 + +每个网页由创建它的产品窗口、工作区和插件共同持有,窗口标签使用无碰撞的编码。状态按这组归属隔离,关闭工作区或禁用插件时清理;工作区关闭会提升该工作区页面的版本,关闭前已发起但尚未注册的页面也不能复活。状态写入与文件打开的登记和撤销共用所有者锁,旧页面实例不能在重新打开后继续写状态;网页不能通过参数自行指定所属工作区。`workspace.openFile` 只发给所属窗口,并等待带操作 ID 的确认。目标窗口在真实路径解析前后都复核当前工作区,复核目标缓冲区确实存在且处于活动状态后才确认成功;共享文件打开函数在读取失败或请求过期时也可能正常返回,不能仅凭其 Promise 完成判断成功;工作区切换、窗口关闭和十秒确认超时都返回失败。待确认请求最多 128 个,避免插件无限积累请求。 + +启动前检查 Core 的 `requiredPackagePaths`、`runtimeRequirements` 和 `validationArguments`。包资源允许普通目录,但实际可执行入口必须是普通文件;路径逃逸和 reparse point 都拒绝。依赖与发现型启动运行时优先使用用户显式导出,再查系统 PATH;包内 `packagedRuntime` 则严格执行已审批的那个文件,不接受用户覆盖,满足最低版本才继续;校验命令即使输出文本,只要退出码非零仍失败。探测复用现有五秒、每条输出流 64 KiB 的原生执行器和进程树清理能力。 + +`ui:1` 的文本工具窗口由宿主生成转义后的 HTML,`ui.commands` 接入已有的命令、菜单和工具栏展示注册表,工具窗口也有活动栏入口。复用展示注册表不等于进入 Worker 执行模型。禁用时同步撤销入口,异步刷新按插件检查版本,避免撤销 B 时取消 A 的刷新,防止旧结果恢复入口。目前页面仍是独立 WebView 窗口;声明的停靠位置、图标、动作可用/勾选状态和布局持久化尚未与 macOS 对齐,因此矩阵继续记为 partial,不能称为完整的 ui 1/2 产品形态。 + ### 与 Worker 扩展模型共存(产品分工) - 两套模型互不桥接:契约插件不进 Worker 宿主,Worker 扩展不冒充契约能力。 @@ -78,12 +96,18 @@ M1 包存储 + 启动校验 + 语言注册 + LSP 启动(已落地);M2 磁 ## 后果 - 收益:Windows 用户开始能安装与运行契约 v1 语言插件,语言插件包与 macOS/Lithe Store 同格式;契约验收(同组 fixtures)成为 Windows 持续回归的一部分;后续里程碑只增不改这条链路。 -- 代价:双模型并存是用户可见的复杂度,M2 管理界面必须分组;`minimumVersion` 探测与内容摘要复查尚未接入(分别随 M2 安装链接入),此前被篡改的包只能靠清单校验与路径闸门拦截。 -- 已知风险:签名延后意味着 v0 社区包信任完全依赖审批界面(M2),文案必须明示未认证状态;Windows 的 reparse point/junction 语义与 macOS symlink 不完全相同,包内路径逃逸判断已按 Windows 语义单独测试;WebView2 对自定义 scheme 与 CSP 的实现细节与 WKWebView 有差异,M4 需要实机验证后再声明 `ui:2`。 +- 代价:双模型并存是用户可见的复杂度,管理界面必须分组。`minimumVersion`、内容摘要复查与启动前校验已接入;发行者签名仍延后,不能把完整性检查当作发布者身份验证。 +- 已知风险:签名延后意味着 v0 社区包信任完全依赖审批界面(M2),文案必须明示未认证状态;Windows 的 reparse point/junction 语义与 macOS symlink 不完全相同,包内路径逃逸判断已按 Windows 语义单独测试;WebView2 对自定义 scheme 与 CSP 的实现细节与 WKWebView 有差异,`ui:2` 网页链路已用 Windows Release/WebView2 实机验证,停靠、图标、动作状态及布局仍为 partial。 - M2–M4 里程碑:磁盘/市场安装链路 + 管理界面;声明式设置 + 运行/测试计划 + PHP 契约包替换;Web 工具窗口。每个里程碑独立 PR,能力声明与矩阵条目随代码同 PR 更新。 ## 验证 +- 多窗口事件投递必须显式指定当前产品窗口的 `Window` target。Tauri 默认 `Any` 监听也接收发给其他 label 的事件,原生 `emit_to(ownerLabel)` 本身不足以隔离接收者;文件请求与生命周期确认均绑定当前窗口,防止其他工作区误处理请求或重复确认。`plugin-lifecycle-listener.test.ts` 检查两类监听的目标。 +- 2026-10-24 Windows Release 实机验收:Playwright/CDP 连接真实 WebView2,测试包安装、ui 1 文本渲染、工作区关闭重开、状态隔离、两个产品窗口归属、主题/语言与重载、过期工作区 openFile 拒绝、双窗口卸载关闭全部插件页均通过;无重复确认异常,外站 HTTP GET 为零,安装包及发行资源哈希不变,应用正常退出。该测试包不替代真实语言服务器的完整安装→启动→即时设置验收,也不覆盖停靠布局。 +- 管理并发回归:`lifecycle.rs` 覆盖原生使用计数排空、禁止晚到获取、逐窗口确认、停止失败、窗口断开和有界超时;`lsp-core-adapter.test.ts` 用可控 Core 响应覆盖停止期间晚到的服务器,断言先停止再拒绝,且 `pluginId` 没有丢失。`plugin-lifecycle-listener.test.ts` 验证收到停用事件立即撤销入口、等待运行和 LSP 两方后才确认。 +- 工作区与界面回归:`web.rs` 覆盖双工作区窗口/状态键隔离和文本转义;`plugin-lifecycle-listener.test.ts` 验证其他工作区请求、路径解析中切换工作区以及成功确认;`workspace-lifecycle.test.ts` 验证关闭工作区时清理网页。`declarative-plugin-ui.test.ts` 用 Hello Lithe fixture 验证文本窗口与命令面板、菜单、工具栏三处贡献及撤销,受控刷新同时覆盖相关与无关插件的撤销。CI 按文件分进程执行插件套件,避免原生命令和事件 mock 跨套件泄漏。 +- 启动计划回归:`launch.rs` 验证必需资源缺失、运行时依赖缺失、目录资源与普通文件入口的区别,以及有输出但非零退出的校验命令。`tests.rs` 还覆盖完整扫描的有序发布,`web.rs` 用有界通道覆盖写入与撤销交错、旧页面实例和旧工作区版本拒绝。Windows Rust 最终计时运行记录 357 项,其中 354 通过、3 项夹具跳过;前端受影响测试分别在隔离进程中运行,报告保存在 `.artifacts/test-stability/`。这些测试不替代双工作区真实 WebView2、真实语言服务器与停靠界面的产品验收。 + - 网页隔离与上下文回归:`web.rs` 覆盖同包和外站 URL 的导航/桥接共用检查、真实路径与内部/外部目录链接;Windows 用无需提权的 junction,Unix 用目录符号链接。`plugin-web-appearance.test.ts` 执行原生宿主桥脚本,验证语言、主题、根变量、主题事件与外站不暴露桥;前端文件测试验证原生真实目标传递与拒绝传播。 - 版本探测回归:`runtime_probe_collects_output_and_rejects_overflow`、`runtime_probe_reclaims_inherited_pipes_after_parent_exit` 使用当前测试二进制作为固定夹具;后者让父进程退出、后代保留管道,断言调用有界结束且后代已退出。测试环境变量仅附加到子进程,不改测试运行器的全局环境。运行 WindowsRust 与 Frontend 计时 harness,检查 `.artifacts/test-stability/index.html`;真实 WebView2 验证还要由本机 HTTP 计数器确认外站 GET 为零,同时检查导航、弹窗/下载、动态主题、包文件和发行目录哈希不变;单凭页面 URL 没变不能证明请求未外发。 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index fdb107170a..5429fc0234 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -290,6 +290,20 @@ jobs: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-notifications.json -- src/features/notifications if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test declarative plugin lifecycle and UI + shell: pwsh + timeout-minutes: 9 + run: | + $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" + # Native/event and presentation mocks must not leak between suites. + $pluginSuites = @('declarative-plugin-lifecycle', 'declarative-plugin-run', 'declarative-plugin-ui', 'plugin-lifecycle-listener', 'plugin-web-open-file', 'plugin-web-appearance') + foreach ($suite in $pluginSuites) { + node $runner --suite-timeout-ms 60000 --report ".artifacts/test-stability/windows-$suite.json" -- "src/features/plugins/$suite.test.ts" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-plugin-lsp-launch.json -- src/features/editor/lsp/resolve-editor-lsp-launch.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Agent panel and capability gate shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent.json -- src/features/agent src/features/editor/stores/buffer-agent-conversation.test.ts src/utils/markdown-links.test.ts src/config/backend-capabilities.test.ts src/i18n src/features/file-system/utils/file-system-drop-controller.test.ts src/ui/icons.test.tsx diff --git a/shared/platform-feature-matrix/features/declarative-plugin-contract.json b/shared/platform-feature-matrix/features/declarative-plugin-contract.json index bd2302fbc5..a23d456e39 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-contract.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-contract.json @@ -44,12 +44,16 @@ "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", "shared/contracts/plugins/v1/fixtures/invalid-manifests.json", "shared/i18n/messages.json", - "windows/tauri/src/i18n/shared-i18n.test.ts" + "windows/tauri/src/i18n/shared-i18n.test.ts", + "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", + "windows/tauri/src/features/plugins/declarative-plugin-ui.ts", + "windows/tauri/src/features/plugins/declarative-plugin-ui.test.ts", + "windows/tauri/src/platform/lsp-core-adapter.test.ts" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Extensions", - "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:1, serverProcess:1},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。", - "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移与宿主 UI 适配仍未实现,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 未实现,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。" + "notes": "Windows 宿主 v0 已接入契约 v1 的语言与语言服务器链路:plugin_host 模块负责包存储(app_local_data_dir 下的 plugins 与 plugin-state,bundled 从资源目录只读加载)、启动扫描(经 plugins.declarative.validate 携带 hostCapabilities {languages:1, languageServers:[1,2], serverProcess:1, settings:[1,2], ui:[1,2]},拒绝的包隔离并展示原因)、启动计划解析(packaged 入口固定在包内且拒绝 reparse point 与路径逃逸,runtime/systemCommand 走 PATH 与 LITHE__PATH 约定),语言服务器会话仍由共享 Core 的 lsp.startServer 拉起;shared fixtures 的 invalid-manifests.json 全部 154 个用例在 Windows 二进制内断言同一稳定错误码。M2 已补磁盘安装链(快照、installReview 审批、staging、回滚)与最小管理界面;M3 已补声明式设置(settings/languageServers 2 能力声明,全局+项目级存储,immediate 效果经 workspaceConfiguration 下推)、运行/测试计划命令与契约版 PHP bundled 包(随安装器分发,validate/launch/runPlan 端到端测试覆盖)。M4 已补 Web 工具窗口(ui 能力声明提升为 [1,2]):lithe-plugin:// scheme 只服务启用包内普通文件(拒 reparse point、路径逃逸、超 16MiB),响应带 CSP(default-src none、拒 eval/联网/frame)与 nosniff/no-store;window.lithe 桥 v0 提供 context、ui.getState/setState(宿主持有,64KiB 上限)、workspace.openFile 与主题事件,build.*/functions.* 在 Windows 构建系统能力接入前返回 plugin.web.unknownMethod。尚未实现:Worker 扩展清单向契约迁移(Worker 版 PHP 提供 composer scripts 动态运行动作,契约无等价能力,完整迁移推迟;见 Note)、构建系统插件能力与多仓库源市场。决策与切分见 .agents/notes/implemented/architecture/2026-10-22-windows-declarative-plugin-host.md。 后续安全修复已补插件身份传递、运行中和待启动会话排空、跨窗口包变更确认、完整启动前检查及 ui 1 文本/命令入口;目前 WebView 仍为独立窗口,停靠位置、图标、动作状态及布局持久化未完成。", + "verification": "API 3 共享契约:cargo test -p lithe-core --lib community_build_plugins、cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib declarative_plugins 及 scripts/verify-shared-contracts.sh,覆盖真实 Maven/Gradle 输出、构建 icon/presentation/category、build.languageImport 的插件选项映射和 ui 2 包内网页清单。Core 校验通过不代表平台宿主已实现;macOS 原生窗口/JDT 接线与可选 WKWebView 分别验收后才由宿主声明对应能力,Windows 仍待接入。macOS:安装 shared/contracts/plugins/v1/fixtures/invalid-manifests.json 中每类错误的包,确认插件管理显示对应错误且不注册模块;合法包启用、禁用无需重启;包内声明的语言服务器可执行文件缺失或为符号链接时拒绝安装;启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。API 2:安装 fixtures 中 node-runtime 形态的包,确认宿主发现满足最低版本的 node 并用它启动入口文件,版本过低的候选不被使用;语言服务器的状态写在 /Lithe/PluginState 下而不是插件包内,卸载后该目录被删除。宿主能力:声明语言但未在 hostIntegration.capabilities 中列出运行、测试或调试、也未提供执行提供方的包,对应入口不可用且宿主不运行内置工具;列出后对应入口恢复。Windows:宿主 v0 已读取该契约(语言与语言服务器范围,按 hostCapabilities 门控),接入验收必须通过同一组共享 fixture,Windows 宿主测试已对 invalid-manifests.json 全量断言;Worker 扩展清单的迁移未完成,宿主 UI 仅完成文本和命令入口,不能用共享校验通过代替界面验收。 macOS API level 2:安装 Hello Lithe 数据包,确认工具窗口、命令面板、工具栏和菜单通过相同注册表显示;移动数据库与插件窗口、隐藏入口及重排后重启恢复;禁用插件后窗口和命令消失,重新启用保留位置与排序;无第三方原生代码载入。Windows UI adapter 仍缺停靠、图标和动作状态等对齐,不能用共享校验通过代替界面验收。 回归:声明与内置/应用动作冲突的合法包在启动时隔离并显示错误;正常退出保留已保存的窗口展开状态,用户主动隐藏才更新该状态;Agent 未启用时可移至底部并恢复入口;首次通过菜单打开 Services 会按需激活数据库。 macOS 图标:宿主语义 ID 与包内 SVG/PNG 在窗口入口、标题栏、工具栏、菜单和命令面板显示;模板着色与品牌原色、亮暗变体、选中/悬停/禁用沿用宿主规范;拒绝未知图标 ID、样式覆盖、包外路径、符号链接、脚本和外部引用;验证资源字节与 bundle 文件清单不变。 原生回归覆盖真实注册窗口标题栏、主工具栏活动/禁用状态、命令面板键盘选择及同一窗口亮→暗→亮切换;品牌图标不随选中着色,模板图标使用宿主选中文字颜色。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 API level 2 工具窗口初始位置接受 left/bottom/right,Rust 校验、macOS 解码及首次显示位置一致,用户保存布局仍覆盖默认声明。 Windows 安全回归:plugin_host::launch 的版本探测必须在父进程退出、后代继承管道时仍有界结束并清理进程树;web 回归验证真实文档来源与工作区文件目标。 Windows 语言资源整合:声明式插件的运行分组、管理页与安装审查使用共享生成词条;中英文回归验证安装名称/版本、程序数量和启停标签,并确认用户值中的占位符保持字面文本。 Windows 宿主修复回归:在 Core startServer 尚未返回时禁用插件,晚到会话必须先停止再拒绝;即时设置携带 pluginId,Core 接受并下推对应会话。缺失 requiredPackagePaths/runtimeRequirements 或 validationArguments 非零退出时拒绝启动。Hello Lithe 文本窗口、菜单、命令面板和工具栏可见,禁用后全部撤销;停靠、图标、动作状态及持久布局仍待接入,保持 partial。" } diff --git a/shared/platform-feature-matrix/features/declarative-plugin-settings.json b/shared/platform-feature-matrix/features/declarative-plugin-settings.json index 4701c8a9fc..5dde4cbd23 100644 --- a/shared/platform-feature-matrix/features/declarative-plugin-settings.json +++ b/shared/platform-feature-matrix/features/declarative-plugin-settings.json @@ -24,11 +24,14 @@ "windows/tauri/src-tauri/src/plugin_host/settings.rs", "windows/tauri/src-tauri/src/plugin_host/tests.rs", "windows/tauri/src/features/plugins/declarative-plugin-settings-editor.tsx", - "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json" + "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json", + "windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts", + "windows/tauri/src/platform/lsp-core-adapter.ts", + "windows/tauri/src/platform/lsp-core-adapter.test.ts" ], "implementationStatus": "partial", - "verificationStatus": "not-applicable" + "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "macOS:打开设置 › 插件,选中 Java Support,切到“设置”页签,确认显示“最大内存”(默认 1024 MB)和“语言服务器日志级别”(默认全部消息)。把最大内存改成 3072:输入 3072 后点“重启语言服务器”,用 ps 查看 JDT LS 的 java 进程参数,确认 -Xmx3072m 且只出现一次;输入 100 时出现范围提示且没有保存,失焦后回到上一个合法值。把日志级别改成“仅错误”并重启,确认进程参数里是 -Dlog.level=SEVERE。点“恢复默认值”后重启,确认回到 -Xmx1024m 和 -Dlog.level=ALL。退出并重新打开 Lithe,确认修改过的值仍在。卸载并恢复 Java Support,值仍在。Windows:插件契约与设置页尚未接入。" + "verification": "macOS:打开设置 › 插件,选中 Java Support,切到“设置”页签,确认显示“最大内存”(默认 1024 MB)和“语言服务器日志级别”(默认全部消息)。把最大内存改成 3072:输入 3072 后点“重启语言服务器”,用 ps 查看 JDT LS 的 java 进程参数,确认 -Xmx3072m 且只出现一次;输入 100 时出现范围提示且没有保存,失焦后回到上一个合法值。把日志级别改成“仅错误”并重启,确认进程参数里是 -Dlog.level=SEVERE。点“恢复默认值”后重启,确认回到 -Xmx1024m 和 -Dlog.level=ALL。退出并重新打开 Lithe,确认修改过的值仍在。卸载并恢复 Java Support,值仍在。Windows:契约设置页与双层存储已接入,须完成下述实机验证。 Windows:启动契约语言服务器,修改 immediate 设置,确认 lsp.startServer 和即时更新都带独立 pluginId,已启动会话收到 workspace/didChangeConfiguration;禁用后旧会话不能继续接收。设置双层持久化和界面验收仍 pending。" } diff --git a/shared/platform-feature-matrix/features/plugin-extension-management.json b/shared/platform-feature-matrix/features/plugin-extension-management.json index f4b9ad26d0..14b724f7df 100644 --- a/shared/platform-feature-matrix/features/plugin-extension-management.json +++ b/shared/platform-feature-matrix/features/plugin-extension-management.json @@ -22,11 +22,13 @@ "windows/tauri/src-tauri/src/language_tools.rs", "windows/tauri/src/extensions/registry/extension-store-lifecycle.test.ts", "windows/tauri/src/features/plugins/declarative-plugins-section.tsx", - "windows/tauri/src-tauri/src/plugin_host/install.rs" + "windows/tauri/src-tauri/src/plugin_host/install.rs", + "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", + "windows/tauri/src/features/plugins/plugin-lifecycle-listener.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Extensions", - "verification": "macOS 全新安装时打开插件页,确认“已安装”标签不再显示 PHP 未安装占位项(改由“插件市场”标签提供安装),齿轮菜单可“从磁盘安装插件”选择已签名的插件包;社区插件在“已安装”中显示来源仓库与社区维护提示,可卸载;安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。契约 v1 插件在设置的独立“声明式插件”页管理(启用/停用/卸载/回滚,与 Worker 扩展分开列出,隔离原因直接展示),管理命令走 plugin_host 的 Tauri 命令。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。" + "verification": "macOS 全新安装时打开插件页,确认“已安装”标签不再显示 PHP 未安装占位项(改由“插件市场”标签提供安装),齿轮菜单可“从磁盘安装插件”选择已签名的插件包;社区插件在“已安装”中显示来源仓库与社区维护提示,可卸载;安装后可搜索并启用、禁用 PHP,确认状态与重启恢复,且页面不显示无恢复路径的卸载操作;同时打开 Maven 面板确认插件内容不被遮挡。Windows 检查扩展管理入口和持久化。契约 v1 插件在设置的独立“声明式插件”页管理(启用/停用/卸载/回滚,与 Worker 扩展分开列出,隔离原因直接展示),管理命令走 plugin_host 的 Tauri 命令。 Windows 缺少 Bun 或语言服务器时更新扩展,确认旧扩展的安装、启用和待更新状态保留,错误给出安装依赖后重试的提示;预检期间禁用或卸载扩展,确认返回后不会继续安装或重新启用;手动将 Marksman 等语言服务器安装到 PATH 后重启,确认能识别并启用。 Windows 契约插件:同时在两个产品窗口启动语言服务/运行任务,在启动尚未返回时分别禁用、卸载、更新和回滚;确认先撤销入口、等待两窗口所有进程和原生探测退出,再改包目录。停止失败、窗口断开或确认超时发生在改包前,文件保留且插件继续阻断;改包后重新扫描失败也必须报错并保持阻断,不能使用旧目录缓存;重试可恢复,不能出现占用可执行文件导致的半删除。 不同插件并发管理时,全盘扫描必须从读取到提交缓存串行,最终缓存包含所有已经成功安装的包;检查 tests.rs 的有序发布回归。" } diff --git a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json index c9721585c3..d534414854 100644 --- a/shared/platform-feature-matrix/features/plugin-web-tool-windows.json +++ b/shared/platform-feature-matrix/features/plugin-web-tool-windows.json @@ -46,11 +46,15 @@ "windows/tauri/src/features/plugins/plugin-web-open-file.test.ts", "windows/tauri/src-tauri/capabilities/plugin-web.json", "windows/tauri/src-tauri/permissions/declarative-web/bridge.toml", - "windows/tauri/src-tauri/src/plugin_host/web_network.rs" + "windows/tauri/src-tauri/src/plugin_host/web_network.rs", + "windows/tauri/src-tauri/src/plugin_host/lifecycle.rs", + "windows/tauri/src/features/plugins/plugin-web-open-file.ts", + "windows/tauri/src/features/plugins/plugin-lifecycle-listener.test.ts", + "windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Plugin Platform", - "verification": "自动回归:LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:ui 2 宿主已实现 state/openFile 与 locale/theme。运行 WindowsRust 和 Frontend 计时 harness,验证包外来源拒绝、内部/外部 junction 的真实目标边界、原生桥脚本的主题初值与事件;在真实 WebView2 中检查同包导航可用,外站导航、弹窗和下载被拒绝,外站得不到桥;切换语言和主题,确认 context、data-theme 与 --lithe-* 同步。设置页与 Web 窗口视觉验收仍 pending,不能以脚本测试代替。 Windows ACL 回归读取实际编译的 Tauri authority:插件私有来源仅允许 declarative-web request,拒绝 fs/http/event 及常规宿主命令,外站与主窗口不能借此调用桥;实机必须验证 state 往返成功,不能只验证拒绝分支。 Windows 请求层:页面加载前安装 WebResourceRequested 过滤器;本机 HTTP 计数器必须证明外站导航 GET 为零,不能仅以导航被取消或 URL 不变判断隔离成功。" + "verification": "自动回归:LITHE_TEST_WINDOW_MODE=isolated ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginWeb|PluginManagerTests|PluginDiskInstallationTests|BuildSystemServiceTests',检查包内脚本、CSP、主题事件、nonPersistent 数据存储和包文件字节不变;验证错误方法/参数、跨插件/工作区/符号链接逃逸、激活中工作区切换、等待期限、关闭后释放、真实 resetDedicatedWindowSession 在设置窗口仍强持项目模型且网页隐藏时同步关闭页面并清空登记表(TestGate 挂起检测和模块退出,待处理/退出中/退出后请求均返回 plugin.web.forbidden,桥接激活次数不增且退出后 Execution 未重新激活;新回归编译和运行待 CI 验证)、拒绝启动时不认领其他运行、任务与测试两入口遇 busy 均不取消现有运行或清空输出(含 TestGate 控制等待确认期间出现新运行)、同名 demo application/example 依完整 tags 唯一选中且最终参数来自模型、真实 Cargo 模型经桥接选中 example 后通过 Core targetTags 校验并生成 run --example demo、缺少区分信息或伪造/错误类型 tags 被拒绝、停用后旧视图无法重建且工作区关闭不撤销停用、安装和回滚校验失败保留旧页面、社区磁盘同版本替换及升级经真实审查和管理器入口在修改资源及安装记录前同步关闭双项目隐藏页面,拒绝已批准的进程许可并阻止后注册窗口及重新启用在重启前重建(PluginDiskInstallationTests/diskReplacementClosesEveryProjectPageBeforeMutation;篡改快照校验失败保留旧页面)、替换前关闭页面、模型任务类别及声明运行项完整转发、双项目页面与同意记录同步失效、后注册窗口继承阻断、包变更后旧管理器启用不能解锁、弱引用不保留关闭窗口。node scripts/test-reuse-worktree-resources.mjs 拒绝网页状态跨工作树复用。产品界面验收仍 pending。macOS:在开发版里用“从磁盘安装插件”安装 web-panel 夹具(plugin.json 旁放 ui/index.html 和 icons/panel.svg),重启后打开右侧 Web Panel 窗口,确认页面显示、根元素带 data-theme 和 --lithe-* 变量,切换浅色/深色后 ui.themeChanged 到达;页面里的内联脚本和 fetch 外网被 CSP 拦截。在页面控制台调用 lithe.request('ui.setState', {state:{a:1}}),隐藏再显示窗口后 ui.getState 仍返回该值;调用未知方法得到 plugin.web.unknownMethod,openFile 传 ../ 得到 plugin.web.forbidden。在设置 › 插件里停用该插件,窗口消失;重新启用后窗口回到原来的停靠位置并重新加载页面。打开过网页窗口后运行 scripts/verify-runtime-bundle-immutability.sh 通过。摄像头与麦克风:自动回归 ./scripts/test-macos.sh --filter 'PluginMediaCapture|MockCapture|WebMedia',插件页在真实 WKWebView 里用 WebKit 模拟设备验证:用户点 Allow 才授权且问题记录的请求者是插件名加 ID,拒绝得到 NotAllowedError,缺用途说明、页面不在窗口里、非本插件主框架一律不弹窗直接拒绝,隐藏窗口后采集结束,点 Allow 之后页面才被隐藏时才启动的设备也被立即停掉,隐藏之后才点的 Allow 不生效,源码扫描保证每个 WKWebView 都有人回答请求且 .grant 只出现在 WebMediaCapture.swift;补上 Info.plist 用途说明后,在开发版里用调用 getUserMedia 的插件页确认弹窗写明插件和 ID、隐藏工具窗口后系统菜单栏的绿点或橙点消失、再显示后同一页面第二次调用是否再询问(WebKit 可能复用同一文档里已批准的授权,按实测校对弹窗说明)(当前构建没有用途说明,页面里 navigator.mediaDevices 不存在,这一步尚不能做)。Windows:ui 2 宿主已实现 state/openFile 与 locale/theme。运行 WindowsRust 和 Frontend 计时 harness,验证包外来源拒绝、内部/外部 junction 的真实目标边界、原生桥脚本的主题初值与事件;在真实 WebView2 中检查同包导航可用,外站导航、弹窗和下载被拒绝,外站得不到桥;切换语言和主题,确认 context、data-theme 与 --lithe-* 同步。设置页与 Web 窗口视觉验收仍 pending,不能以脚本测试代替。 Windows ACL 回归读取实际编译的 Tauri authority:插件私有来源仅允许 declarative-web request,拒绝 fs/http/event 及常规宿主命令,外站与主窗口不能借此调用桥;实机必须验证 state 往返成功,不能只验证拒绝分支。 Windows 请求层:页面加载前安装 WebResourceRequested 过滤器;本机 HTTP 计数器必须证明外站导航 GET 为零,不能仅以导航被取消或 URL 不变判断隔离成功。 Windows 双工作区回归:同一插件的页面和 state 分别归属创建窗口及工作区;A 页 openFile 只在 A 编辑器打开,切换工作区、路径拒绝或编辑器失败须向页面返回错误,不能先返回成功。关闭 A 清理其页面/状态但保留 B;禁用插件同时撤销全部页面与待处理请求,包变更等待资源读取退出。页面仍使用独立 WebView,停靠及布局恢复保持 partial。 关闭工作区与页面写状态/打开文件交错时,先原子撤销所属页面,再清状态与待确认请求;旧页面实例和关闭前发起的异步页面创建都必须被拒绝,重新打开同一工作区的新请求仍可用。" } diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 8d315640f4..446ed06df8 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -65,7 +65,11 @@ fn main() { let application = tauri::Builder::default() .plugin(plugin_host::web::bridge_plugin()) .register_uri_scheme_protocol("lithe-plugin", |context, request| { - plugin_host::web::handle_scheme_request(context.app_handle(), context.webview_label(), request) + plugin_host::web::handle_scheme_request( + context.app_handle(), + context.webview_label(), + request, + ) }) .plugin(tauri_plugin_single_instance::init(|app, arguments, _| { host::enqueue_cli_arguments(app, arguments); @@ -141,6 +145,7 @@ fn main() { app.manage(debug::DebugAdapterManager::default()); app.manage(plugin_host::PluginHostState::default()); app.manage(plugin_host::web::WebPageHostState::default()); + app.manage(plugin_host::lifecycle::LifecycleState::default()); run::cleanup_legacy_appdata(app.handle()); maven::clear_dependency_outputs(app.handle()); plugin_host::install::cleanup_stale_snapshots(app.handle()); @@ -155,6 +160,7 @@ fn main() { .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { plugin_host::web::forget_window_appearance(window.app_handle(), window.label()); + plugin_host::lifecycle::window_destroyed(window.app_handle(), window.label()); run::release_window_processes(window.label()); core::close_ide_hosts(window.label()); agent::close_window_connections(window.label()); @@ -290,8 +296,10 @@ fn main() { run::run_stop_process, run::run_list_owned_processes, run::run_stop_owned_processes, + plugin_host::lifecycle::declarative_plugins_lifecycle_ack, plugin_host::declarative_plugins_rescan, plugin_host::declarative_plugins_state, + plugin_host::web::declarative_plugins_web_open_file_ack, plugin_host::web::declarative_plugins_web_resolve_file, plugin_host::web::declarative_plugins_web_update_appearance, plugin_host::declarative_plugins_manifest, @@ -308,6 +316,7 @@ fn main() { plugin_host::settings::declarative_plugins_workspace_configuration, plugin_host::run_plans::declarative_plugins_run_plan, plugin_host::run_plans::declarative_plugins_test_plan, + plugin_host::web::declarative_plugins_close_workspace_pages, plugin_host::web::declarative_plugins_web_windows, plugin_host::web::declarative_plugins_open_web_window, plugin_host::web::declarative_plugins_close_web_window, diff --git a/windows/tauri/src-tauri/src/plugin_host/install.rs b/windows/tauri/src-tauri/src/plugin_host/install.rs index 71a8762e44..c691c98275 100644 --- a/windows/tauri/src-tauri/src/plugin_host/install.rs +++ b/windows/tauri/src-tauri/src/plugin_host/install.rs @@ -128,13 +128,13 @@ pub fn declarative_plugins_prepare_disk_install( /// review. The confirm call re-validates the snapshot bytes and re-runs the /// review, so a snapshot swapped between prepare and confirm is rejected. #[tauri::command] -pub fn declarative_plugins_confirm_disk_install( +pub async fn declarative_plugins_confirm_disk_install( app: AppHandle, session_id: String, manifest_digest: String, ) -> Result { let snapshot = snapshot_path(&app, &session_id)?; - let result = (|| { + let result = async { let manifest = read_snapshot_manifest(&snapshot)?; if content_digest(&manifest) != manifest_digest { return Err("the package changed after review; the import was cancelled".to_string()); @@ -159,14 +159,15 @@ pub fn declarative_plugins_confirm_disk_install( // A version switch invalidates the page's package bytes and bridge // authorization, so destroy all owned webviews before staging starts. - close_plugin_web_windows(&app, &plugin_id); - notify_plugin_lifecycle_change(&app, &plugin_id); + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; staging_install(&install_root, &plugin_id, &version, &snapshot)?; write_installation_record(&install_root, &plugin_id, &version)?; - rescan_after_change(&app); + rescan_after_change(&app)?; + mutation.complete(true); Ok(catalog_entry_of(&app, &plugin_id) .unwrap_or_else(|| accepted_entry_fallback(&plugin_id, &version))) - })(); + } + .await; discard_snapshot(&snapshot); result } @@ -200,25 +201,18 @@ fn snapshot_path(app: &AppHandle, session_id: &str) -> Result { .join(session_id)) } -/// Closes every web window of one plugin and drops its page state. Called -/// before an install-record change takes effect so a disabled, uninstalled, -/// or replaced package never keeps live pages. -fn close_plugin_web_windows(app: &AppHandle, plugin_id: &str) { - let windows = crate::plugin_host::web::web_windows_of_plugin(app, plugin_id); - for window_id in windows { - let _ = crate::plugin_host::web::close_web_window_by_id(app, plugin_id, &window_id); - } - crate::plugin_host::web::forget_plugin_state(app, plugin_id); -} - /// Removes an installed package. Bundled packages cannot be uninstalled. #[tauri::command] -pub fn declarative_plugins_uninstall(app: AppHandle, plugin_id: String) -> Result<(), String> { +pub async fn declarative_plugins_uninstall( + app: AppHandle, + plugin_id: String, +) -> Result<(), String> { let install_root = store::install_root(&app)?; let plugin_directory = install_root.join(&plugin_id); if !plugin_directory.is_dir() { return Err(format!("{plugin_id} is not an installed plugin")); } + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; std::fs::remove_dir_all(&plugin_directory) .map_err(|error| format!("{plugin_id} could not be removed: {error}"))?; let state_directory = store::plugin_state_directory(&app, &plugin_id)?; @@ -226,16 +220,15 @@ pub fn declarative_plugins_uninstall(app: AppHandle, plugin_id: String) -> Resul std::fs::remove_dir_all(&state_directory) .map_err(|error| format!("state directory could not be removed: {error}"))?; } - close_plugin_web_windows(&app, &plugin_id); - notify_plugin_lifecycle_change(&app, &plugin_id); - rescan_after_change(&app); + rescan_after_change(&app)?; + mutation.complete(false); Ok(()) } /// Enables or disables an installed plugin; bundled packages are always /// enabled and reject the call. #[tauri::command] -pub fn declarative_plugins_set_enabled( +pub async fn declarative_plugins_set_enabled( app: AppHandle, plugin_id: String, enabled: bool, @@ -247,6 +240,7 @@ pub fn declarative_plugins_set_enabled( .map_err(|error| format!("{plugin_id} has no readable install record: {error}"))?, ) .map_err(|error| format!("{plugin_id} has a corrupted install record: {error}"))?; + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; record.enabled = enabled; // Write via temp + rename so a crash never leaves a truncated record. let temp_path = record_path.with_extension("json.tmp"); @@ -256,30 +250,16 @@ pub fn declarative_plugins_set_enabled( ) .map_err(|error| error.to_string())?; std::fs::rename(&temp_path, &record_path).map_err(|error| error.to_string())?; - if !enabled { - close_plugin_web_windows(&app, &plugin_id); - notify_plugin_lifecycle_change(&app, &plugin_id); - } - rescan_after_change(&app); + rescan_after_change(&app)?; + mutation.complete(enabled); Ok(()) } -/// Tells the frontend that a plugin changed state, so owned run sessions and -/// other live resources are released (the process owner lives in the -/// frontend's run layer). -fn notify_plugin_lifecycle_change(app: &AppHandle, plugin_id: &str) { - use tauri::Emitter; - let _ = app.emit( - "declarative-plugin-lifecycle", - serde_json::json!({ "pluginId": plugin_id, "active": false }), - ); -} - /// Restores the previous version directory of an installed package. Rollback /// requires a retained previous version and always re-enables the package so /// a broken update cannot leave it disabled and forgotten. #[tauri::command] -pub fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result<(), String> { +pub async fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result<(), String> { let install_root = store::install_root(&app)?; let plugin_directory = install_root.join(&plugin_id); let record: InstallationRecord = serde_json::from_str( @@ -306,8 +286,7 @@ pub fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result "{plugin_id} has no previous version to roll back to" )); }; - close_plugin_web_windows(&app, &plugin_id); - notify_plugin_lifecycle_change(&app, &plugin_id); + let mutation = super::lifecycle::begin(&app, &plugin_id).await?; staging_install( &install_root, &plugin_id, @@ -315,7 +294,8 @@ pub fn declarative_plugins_rollback(app: AppHandle, plugin_id: String) -> Result &plugin_directory.join(&previous), )?; write_installation_record(&install_root, &plugin_id, &previous)?; - rescan_after_change(&app); + rescan_after_change(&app)?; + mutation.complete(true); Ok(()) } @@ -610,10 +590,10 @@ fn string_list(value: &serde_json::Value) -> Vec { .unwrap_or_default() } -fn rescan_after_change(app: &AppHandle) { - if let Ok(packages) = super::scan::build_catalog(app) { - app.state::().set_packages(packages); - } +fn rescan_after_change(app: &AppHandle) -> Result<(), String> { + app.state::() + .refresh(|| super::scan::build_catalog(app)) + .map(|_| ()) } fn catalog_entry_of(app: &AppHandle, plugin_id: &str) -> Option { diff --git a/windows/tauri/src-tauri/src/plugin_host/launch.rs b/windows/tauri/src-tauri/src/plugin_host/launch.rs index 11d93c81d8..b7dcebccb9 100644 --- a/windows/tauri/src-tauri/src/plugin_host/launch.rs +++ b/windows/tauri/src-tauri/src/plugin_host/launch.rs @@ -60,6 +60,11 @@ pub fn declarative_plugins_language_launch( let Some(package) = owning_package(&app, &file_path) else { return Ok(None); }; + let _native_launch = app + .state::() + .acquire(&package.entry.plugin_id)?; + let package = package_by_id(&app, &package.entry.plugin_id) + .ok_or("plugin was removed during launch resolution")?; let Some(language_id) = owning_language_id(&package, &file_path) else { return Ok(None); }; @@ -86,11 +91,18 @@ pub fn declarative_plugins_language_launch( let workspace_state_directory = state_directory.join(store::workspace_state_key(&workspace_path)); prepare_required_directories(resolved_plan, &state_directory, &workspace_state_directory)?; - resolve_launch(&package, &language_id, resolved_plan).map(Some) + std::fs::create_dir_all(&state_directory).map_err(|error| error.to_string())?; + resolve_launch(&package, &language_id, resolved_plan, &state_directory).map(Some) } /// Finds an accepted, enabled scanned package by plugin ID. pub(crate) fn package_by_id(app: &AppHandle, plugin_id: &str) -> Option { + if app + .state::() + .blocked(plugin_id) + { + return None; + } app.state::() .packages() .into_iter() @@ -127,7 +139,11 @@ fn owning_package(app: &AppHandle, file_path: &str) -> Option { app.state::() .packages() .into_iter() - .find(|package| package_matches_file(package, file_path)) + .find(|package| { + !app.state::() + .blocked(&package.entry.plugin_id) + && package_matches_file(package, file_path) + }) } fn package_matches_file(package: &ScannedPackage, file_path: &str) -> bool { @@ -227,7 +243,11 @@ fn resolve_launch( package: &ScannedPackage, language_id: &str, plan: &Value, + probe_directory: &Path, ) -> Result { + for relative in string_arguments(&plan["requiredPackagePaths"]) { + verified_package_resource(&package.root, &relative)?; + } let launch = &plan["launch"]; let kind = launch["kind"].as_str().unwrap_or_default(); let mut environment: BTreeMap = BTreeMap::new(); @@ -257,30 +277,78 @@ fn resolve_launch( let tool = launch["tool"] .as_str() .ok_or("runtime launch has no tool")?; - let path = match launch["packagedRuntime"].as_str() { - Some(packaged) => { - let path = verified_packaged_path(&package.root, packaged)?; - prepend_path( - &mut environment, - path.parent().unwrap_or(package.root.as_path()), - ); - path - } - None => { - let path_env = std::env::var_os("PATH"); - super::super::language_tools::find_system_tool(path_env.as_deref(), tool) - .ok_or_else(|| format!("runtime tool {tool} is not on PATH"))? - } + let key = format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")); + let path = if let Some(packaged) = launch["packagedRuntime"].as_str() { + // Packaged runtimes are fixed by the approved package. + verified_packaged_path(&package.root, packaged)? + } else if let Some(explicit) = std::env::var_os(&key).filter(|value| !value.is_empty()) + { + PathBuf::from(explicit) + } else { + super::super::language_tools::find_system_tool( + std::env::var_os("PATH").as_deref(), + tool, + ) + .ok_or_else(|| format!("runtime tool {tool} is not on PATH"))? }; - environment.insert( - format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")), - path.to_string_lossy().to_string(), - ); - verify_runtime_version(&path, tool, launch)?; + if !path.is_absolute() || !path.is_file() { + return Err(format!( + "runtime {tool} must name an absolute executable file" + )); + } + if let Some(directory) = path.parent() { + prepend_path(&mut environment, directory); + } + environment.insert(key, path.to_string_lossy().to_string()); + verify_runtime_version(&path, tool, launch, probe_directory)?; path } other => return Err(format!("unsupported launch kind {other}")), }; + for requirement in plan["runtimeRequirements"].as_array().into_iter().flatten() { + let tool = requirement["tool"] + .as_str() + .ok_or("runtime requirement has no tool")?; + let key = format!("LITHE_{}_PATH", tool.to_uppercase().replace('-', "_")); + // Explicit user exports take precedence; project-local runtimes are + // never selected by PATH discovery for automatic server starts. + let path = std::env::var_os(&key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| environment.get(&key).map(PathBuf::from)) + .or_else(|| { + super::super::language_tools::find_system_tool( + std::env::var_os("PATH").as_deref(), + tool, + ) + }) + .ok_or_else(|| format!("required runtime {tool} is unavailable"))?; + if !path.is_absolute() || !path.is_file() { + return Err(format!( + "runtime {tool} must name an absolute executable file" + )); + } + let mut version = requirement.clone(); + version["versionArguments"] = serde_json::json!(if tool == "java" { + vec!["-version"] + } else { + vec!["--version"] + }); + verify_runtime_version(&path, tool, &version, probe_directory)?; + environment.insert(key, path.to_string_lossy().into_owned()); + if let Some(directory) = path.parent() { + prepend_path(&mut environment, directory); + } + } + let validation = string_arguments(&plan["validationArguments"]); + if !validation.is_empty() { + let mut command = Command::new(&server_path); + command + .args(&validation) + .envs(&environment) + .current_dir(probe_directory); + run_process_probe(&mut command, "language server validation", true)?; + } Ok(ResolvedLaunch { plugin_id: package.entry.plugin_id.clone(), language_id: language_id.to_string(), @@ -302,7 +370,12 @@ fn resolve_launch( /// declared `minimumVersion`. Probes run at every launch because the PATH /// candidate can change between sessions; a failing or unreadable probe is a /// rejection, not a fallback. -fn verify_runtime_version(path: &Path, tool: &str, launch: &Value) -> Result<(), String> { +fn verify_runtime_version( + path: &Path, + tool: &str, + launch: &Value, + probe_directory: &Path, +) -> Result<(), String> { let Some(minimum) = launch["minimumVersion"].as_str() else { return Ok(()); }; @@ -317,7 +390,7 @@ fn verify_runtime_version(path: &Path, tool: &str, launch: &Value) -> Result<(), .ok_or_else(|| { format!("runtime launch declares minimumVersion without versionArguments") })?; - let output = bounded_version_probe(path, &version_arguments, tool)?; + let output = bounded_version_probe(path, &version_arguments, tool, probe_directory)?; let text = format!( "{}{}", String::from_utf8_lossy(&output.0), @@ -361,13 +434,22 @@ fn bounded_version_probe( path: &Path, arguments: &[String], tool: &str, + probe_directory: &Path, ) -> Result<(Vec, Vec), String> { let mut command = Command::new(path); - command.args(arguments); + command.args(arguments).current_dir(probe_directory); run_version_probe(&mut command, tool) } fn run_version_probe(command: &mut Command, tool: &str) -> Result<(Vec, Vec), String> { + run_process_probe(command, tool, false) +} + +fn run_process_probe( + command: &mut Command, + tool: &str, + require_success: bool, +) -> Result<(Vec, Vec), String> { let deadline = Instant::now() + RUNTIME_PROBE_TIMEOUT; let stdout_bytes = Cell::new(0_usize); let stderr_bytes = Cell::new(0_usize); @@ -399,6 +481,9 @@ fn run_version_probe(command: &mut Command, tool: &str) -> Result<(Vec, Vec< "runtime tool {tool} version probe failed or timed out: {failure:?}" )); } + if require_success && outcome.status.is_none_or(|status| !status.success()) { + return Err(format!("{tool} probe exited unsuccessfully")); + } if outcome.status.is_none_or(|status| !status.success()) && outcome.stdout.is_empty() && outcome.stderr.is_empty() @@ -413,6 +498,30 @@ fn run_version_probe(command: &mut Command, tool: &str) -> Result<(Vec, Vec< /// Joins a package-relative plan path and proves it stays inside the package /// as a regular file. Every launch the host executes passes through here. fn verified_packaged_path(root: &Path, relative: &str) -> Result { + let candidate = verified_package_resource(root, relative)?; + if !candidate.is_file() { + return Err(format!( + "packaged entry {} is not a regular file", + candidate.display() + )); + } + Ok(candidate) +} + +/// Required resources may be files or directories. Canonical containment and +/// reparse checks apply before the process starts, including runtime entry files. +fn verified_package_resource(root: &Path, relative: &str) -> Result { + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path.components().any(|part| { + matches!( + part, + std::path::Component::ParentDir | std::path::Component::Prefix(_) + ) + }) + { + return Err("required package resource must stay inside the package".into()); + } let candidate = root.join(relative); let metadata = std::fs::symlink_metadata(&candidate).map_err(|error| { format!( @@ -434,7 +543,7 @@ fn verified_packaged_path(root: &Path, relative: &str) -> Result Result, directory: &Path) { - let existing = std::env::var("PATH").unwrap_or_default(); + let existing = environment + .get("PATH") + .cloned() + .unwrap_or_else(|| std::env::var("PATH").unwrap_or_default()); let separator = if cfg!(windows) { ";" } else { ":" }; environment.insert( "PATH".to_string(), @@ -495,6 +607,10 @@ mod tests { use std::io::Write; match std::env::var("LITHE_PROBE_FIXTURE").as_deref() { Ok("output") => println!("v21.0.0"), + Ok("failure-output") => { + println!("version text cannot mask probe failure"); + std::process::exit(7); + } Ok("overflow") => { std::io::stdout() .write_all(&vec![b'x'; MAX_RUNTIME_PROBE_OUTPUT_BYTES + 1]) @@ -580,6 +696,97 @@ mod tests { } } + /// The child receives explicit runtime configuration without changing the + /// test runner's environment or depending on its installed PATH tools. + #[test] + fn runtime_launch_uses_the_same_explicit_executable_as_its_dependency() { + let executable = std::env::current_exe().unwrap(); + let mut command = Command::new(&executable); + command + .args([ + "--exact", + "plugin_host::launch::tests::explicit_runtime_fixture", + "--ignored", + "--nocapture", + ]) + .env("LITHE_NODE_PATH", &executable); + run_process_probe(&mut command, "explicit runtime fixture", true).unwrap(); + } + + #[test] + #[ignore = "launched with a scoped runtime export by the regression test"] + fn explicit_runtime_fixture() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(unique_temp_root("explicit-runtime")); + std::fs::write(root.0.join("fallback.exe"), b"must not execute").unwrap(); + let package = package_with_root(root.0.clone(), serde_json::json!({})); + let plan = serde_json::json!({ + "launch": {"kind":"runtime", "tool":"node"}, + "runtimeRequirements":[{"tool":"node"}] + }); + let resolved = resolve_launch(&package, "test", &plan, &package.root).unwrap(); + let executable = std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned(); + assert_eq!(resolved.server_path, executable); + assert_eq!(resolved.environment["LITHE_NODE_PATH"], executable); + let mut packaged = plan.clone(); + packaged["launch"]["packagedRuntime"] = serde_json::json!("fallback.exe"); + packaged["runtimeRequirements"] = serde_json::json!([]); + let resolved = resolve_launch(&package, "test", &packaged, &package.root).unwrap(); + let expected = verified_packaged_path(&package.root, "fallback.exe") + .unwrap() + .to_string_lossy() + .into_owned(); + assert_eq!(resolved.server_path, expected); + assert_eq!(resolved.environment["LITHE_NODE_PATH"], expected); + } + + #[test] + fn validation_requires_zero_exit_even_when_probe_prints_output() { + run_process_probe(&mut probe_fixture_command("output"), "validation", true).unwrap(); + let error = run_process_probe( + &mut probe_fixture_command("failure-output"), + "validation", + true, + ) + .unwrap_err(); + assert!(error.contains("unsuccessfully"), "{error}"); + } + + #[test] + fn launch_rejects_missing_required_resources_and_runtime_dependencies() { + struct TempRoot(PathBuf); + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = TempRoot(unique_temp_root("requirements")); + std::fs::write(root.0.join("server.exe"), b"fixture").unwrap(); + let package = package_with_root(root.0.clone(), serde_json::json!({})); + let mut plan = serde_json::json!({ "launch": {"kind": "packaged", "path": "server.exe"}, "requiredPackagePaths": ["missing.js"] }); + assert!(resolve_launch(&package, "test", &plan, &package.root) + .unwrap_err() + .contains("missing.js")); + plan["requiredPackagePaths"] = serde_json::json!([]); + plan["runtimeRequirements"] = + serde_json::json!([{ "tool": "lithe-fixture-absent-runtime-9416" }]); + assert!(resolve_launch(&package, "test", &plan, &package.root) + .unwrap_err() + .contains("required runtime")); + std::fs::create_dir(root.0.join("resources")).unwrap(); + assert!(verified_package_resource(&root.0, "resources").is_ok()); + assert!(verified_packaged_path(&root.0, "resources").is_err()); + assert!(verified_package_resource(&root.0, "../escape").is_err()); + } + /// Unique per-run temp root; mirrors the temp-dir convention used by the /// other src-tauri tests (std::env::temp_dir + pid + timestamp). fn unique_temp_root(label: &str) -> PathBuf { @@ -759,7 +966,8 @@ mod tests { assert_eq!(response["status"], "ok", "{response}"); // The production path passes the wrapped outcome; only the inner // plan carries the launch. - let resolved = resolve_launch(&package, "php", &response["plan"]).expect("resolves"); + let resolved = + resolve_launch(&package, "php", &response["plan"], &package.root).expect("resolves"); assert_eq!(resolved.server_path, server.to_string_lossy()); let _ = std::fs::remove_dir_all(&temporary); } @@ -776,7 +984,7 @@ mod tests { "launch": { "kind": "packaged", "path": "bin/server.exe" }, "arguments": ["--stdio"] }); - let resolved = resolve_launch(&package, "php", &plan).expect("resolves"); + let resolved = resolve_launch(&package, "php", &plan, &package.root).expect("resolves"); assert_eq!(resolved.server_path, server.to_string_lossy()); assert_eq!(resolved.server_args, vec!["--stdio".to_string()]); assert_eq!(resolved.initialization_options, None); @@ -786,7 +994,7 @@ mod tests { "launch": { "kind": "packaged", "path": "../outside.exe" }, "arguments": [] }); - assert!(resolve_launch(&package, "php", &escape_plan).is_err()); + assert!(resolve_launch(&package, "php", &escape_plan, &package.root).is_err()); } #[test] @@ -806,7 +1014,7 @@ mod tests { }, "arguments": ["-jar", "server.jar"] }); - let resolved = resolve_launch(&package, "java", &plan).expect("resolves"); + let resolved = resolve_launch(&package, "java", &plan, &package.root).expect("resolves"); assert_eq!(resolved.server_path, runtime.to_string_lossy()); assert_eq!( resolved.environment.get("LITHE_JAVA_PATH"), diff --git a/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs b/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs new file mode 100644 index 0000000000..b2affc9fde --- /dev/null +++ b/windows/tauri/src-tauri/src/plugin_host/lifecycle.rs @@ -0,0 +1,310 @@ +//! Package mutation barrier. Revoke plans before asking each product window +//! to drain its process owners; only acknowledgements from that window count. +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; +use tokio::sync::oneshot; + +#[derive(Default)] +pub struct LifecycleState { + access: Arc, + pending: Mutex>>>>, + changing: Mutex>, +} + +#[derive(Default)] +struct AccessState { + blocked: HashSet, + probes: HashMap, +} +#[derive(Default)] +struct NativeAccess { + state: Mutex, + changed: tokio::sync::Notify, +} + +impl LifecycleState { + pub fn blocked(&self, plugin_id: &str) -> bool { + self.access + .state + .lock() + .expect("native plugin access") + .blocked + .contains(plugin_id) + } + /// Register before any native probe or package read. Revocation and + /// acquisition share one lock, so a mutation cannot miss a late probe. + pub fn acquire(&self, plugin_id: &str) -> Result { + let mut state = self.access.state.lock().expect("native plugin access"); + if state.blocked.contains(plugin_id) { + return Err("plugin is inactive or changing version".into()); + } + *state.probes.entry(plugin_id.into()).or_default() += 1; + Ok(NativeLease { + access: self.access.clone(), + plugin_id: plugin_id.into(), + }) + } + fn revoke(&self, plugin_id: &str) { + self.access + .state + .lock() + .expect("native plugin access") + .blocked + .insert(plugin_id.into()); + } + async fn wait_native(&self, plugin_id: &str) -> Result<(), String> { + tokio::time::timeout(std::time::Duration::from_secs(45), async { + loop { + let changed = self.access.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self + .access + .state + .lock() + .expect("native plugin access") + .probes + .get(plugin_id) + .copied() + .unwrap_or(0) + == 0 + { + return; + } + changed.await; + } + }) + .await + .map_err(|_| "native plugin probes did not exit; package files were preserved".into()) + } +} + +pub struct NativeLease { + access: Arc, + plugin_id: String, +} +impl Drop for NativeLease { + fn drop(&mut self) { + let mut state = self.access.state.lock().expect("native plugin access"); + if let Some(count) = state.probes.get_mut(&self.plugin_id) { + *count -= 1; + if *count == 0 { + state.probes.remove(&self.plugin_id); + } + } + drop(state); + self.access.changed.notify_waiters(); + } +} + +/// One operation owns the plugin until its catalog and files are consistent. +/// Failure leaves launches revoked; retry/enable can explicitly recover it. +pub struct Mutation { + app: AppHandle, + plugin_id: String, + operation_id: String, +} +impl Drop for Mutation { + fn drop(&mut self) { + let state = self.app.state::(); + state + .pending + .lock() + .expect("plugin barriers") + .remove(&self.operation_id); + state + .changing + .lock() + .expect("plugin mutations") + .remove(&self.plugin_id); + } +} +impl Mutation { + pub fn complete(self, active: bool) { + self.app + .state::() + .access + .state + .lock() + .expect("native plugin access") + .blocked + .remove(&self.plugin_id); + let _ = self.app.emit( + "declarative-plugin-lifecycle", + serde_json::json!({ + "pluginId": self.plugin_id, "active": active, + }), + ); + } +} + +pub async fn begin(app: &AppHandle, plugin_id: &str) -> Result { + let state = app.state::(); + if !state + .changing + .lock() + .expect("plugin mutations") + .insert(plugin_id.to_string()) + { + return Err("another mutation already owns this plugin".into()); + } + let operation_id = super::store::workspace_state_key(&format!( + "{}-{:?}", + plugin_id, + std::time::SystemTime::now() + )); + let mutation = Mutation { + app: app.clone(), + plugin_id: plugin_id.into(), + operation_id: operation_id.clone(), + }; + state.revoke(plugin_id); + state.wait_native(plugin_id).await?; + super::web::close_plugin_windows(app, plugin_id)?; + let mut senders = HashMap::new(); + let mut receivers = Vec::new(); + for label in app + .webview_windows() + .keys() + .filter(|label| label.as_str() == "main" || label.starts_with("workspace-")) + { + let (sender, receiver) = oneshot::channel(); + senders.insert(label.clone(), sender); + receivers.push((label.clone(), receiver)); + } + let labels: Vec<_> = senders.keys().cloned().collect(); + state + .pending + .lock() + .expect("plugin barriers") + .insert(operation_id.clone(), senders); + for label in labels { + app.emit_to( + label, + "declarative-plugin-lifecycle", + serde_json::json!({ + "pluginId": plugin_id, "active": false, "operationId": operation_id, + }), + ) + .map_err(|error| error.to_string())?; + } + await_drained(receivers, std::time::Duration::from_secs(45)).await?; + Ok(mutation) +} + +async fn await_drained( + receivers: Vec<(String, oneshot::Receiver>)>, + timeout: std::time::Duration, +) -> Result<(), String> { + tokio::time::timeout(timeout, async { + for (label, receiver) in receivers { + receiver + .await + .map_err(|_| format!("process owner {label} disconnected"))??; + } + Ok::<_, String>(()) + }) + .await + .map_err(|_| "plugin process stop timed out; package files were preserved".to_string())? +} + +#[tauri::command] +pub fn declarative_plugins_lifecycle_ack( + app: AppHandle, + window: WebviewWindow, + operation_id: String, + error: Option, +) -> Result<(), String> { + let state = app.state::(); + let sender = state + .pending + .lock() + .expect("plugin barriers") + .get_mut(&operation_id) + .and_then(|pending| pending.remove(window.label())) + .ok_or("unknown plugin mutation acknowledgement")?; + let _ = sender.send(error.map_or(Ok(()), Err)); + Ok(()) +} + +pub fn window_destroyed(app: &AppHandle, label: &str) { + // A closed UI window can still own Core processes. Do not count destruction + // as a successful drain: fail the mutation and preserve the package. + for pending in app + .state::() + .pending + .lock() + .expect("plugin barriers") + .values_mut() + { + if let Some(sender) = pending.remove(label) { + let _ = sender.send(Err( + "process owner window closed before acknowledgement".into() + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mutation_drains_native_probes_and_rejects_late_acquisition() { + let state = LifecycleState::default(); + let probe = state.acquire("test.plugin").unwrap(); + state.revoke("test.plugin"); + assert!(state.acquire("test.plugin").is_err()); + let waiting = state.wait_native("test.plugin"); + tokio::pin!(waiting); + tokio::select! { biased; + result = &mut waiting => panic!("mutation advanced while native probe owns package: {result:?}"), + _ = std::future::ready(()) => {}, + } + drop(probe); + waiting.await.unwrap(); + } + + #[tokio::test] + async fn mutation_waits_for_every_window_and_propagates_stop_failure() { + let (first, first_result) = oneshot::channel(); + let (second, second_result) = oneshot::channel(); + let drain = await_drained( + vec![ + ("first".into(), first_result), + ("second".into(), second_result), + ], + std::time::Duration::from_secs(1), + ); + tokio::pin!(drain); + second.send(Ok(())).unwrap(); + // Poll once with only the second window released: the mutation cannot advance. + tokio::select! { biased; + result = &mut drain => panic!("mutation advanced without its first owner: {result:?}"), + _ = std::future::ready(()) => {}, + } + first.send(Err("executable still in use".into())).unwrap(); + assert_eq!(drain.await.unwrap_err(), "executable still in use"); + } + + #[tokio::test] + async fn mutation_refuses_disconnected_or_unresponsive_owners() { + let (sender, receiver) = oneshot::channel(); + drop(sender); + assert!(await_drained( + vec![("closed".into(), receiver)], + std::time::Duration::from_secs(1) + ) + .await + .unwrap_err() + .contains("disconnected")); + let (_sender, receiver) = oneshot::channel(); + assert!( + await_drained(vec![("busy".into(), receiver)], std::time::Duration::ZERO) + .await + .unwrap_err() + .contains("files were preserved") + ); + } +} diff --git a/windows/tauri/src-tauri/src/plugin_host/mod.rs b/windows/tauri/src-tauri/src/plugin_host/mod.rs index 13308e7417..6ca4a91922 100644 --- a/windows/tauri/src-tauri/src/plugin_host/mod.rs +++ b/windows/tauri/src-tauri/src/plugin_host/mod.rs @@ -12,6 +12,7 @@ pub mod install; #[cfg(test)] mod install_tests; pub mod launch; +pub mod lifecycle; pub mod run_plans; pub mod scan; pub mod settings; @@ -113,10 +114,27 @@ pub struct ScannedPackage { /// then reads this state for registration and launch queries. #[derive(Default)] pub struct PluginHostState { + scan_lock: Mutex<()>, packages: Mutex>, } impl PluginHostState { + /// Serialize discovery through publication. Independent package mutations + /// may overlap, but an older full scan must never overwrite a newer scan. + pub(crate) fn refresh( + &self, + discover: impl FnOnce() -> Result, String>, + ) -> Result { + let _scan = self + .scan_lock + .lock() + .map_err(|_| "plugin catalog scan lock poisoned")?; + let packages = discover()?; + let count = packages.len(); + self.set_packages(packages); + Ok(count) + } + pub fn set_packages(&self, packages: Vec) { *self.packages.lock().expect("plugin host catalog mutex") = packages; } @@ -132,10 +150,8 @@ impl PluginHostState { /// Rescans bundled and installed packages and replaces the cached catalog. #[tauri::command] pub fn declarative_plugins_rescan(app: AppHandle) -> Result { - let packages = scan::build_catalog(&app)?; - let count = packages.len(); - app.state::().set_packages(packages); - Ok(count) + app.state::() + .refresh(|| scan::build_catalog(&app)) } /// Returns the cached scan result. Call `declarative_plugins_rescan` first; diff --git a/windows/tauri/src-tauri/src/plugin_host/tests.rs b/windows/tauri/src-tauri/src/plugin_host/tests.rs index 12649dfdd6..2d5aee99c4 100644 --- a/windows/tauri/src-tauri/src/plugin_host/tests.rs +++ b/windows/tauri/src-tauri/src/plugin_host/tests.rs @@ -494,3 +494,50 @@ fn bundled_php_contract_package_expands_plans() { .expect("resolve settings executes"); assert_eq!(settings["status"], "ok", "{settings}"); } + +/// A scan owns discovery and publication together, so the second discover +/// cannot snapshot the old catalog and overwrite a newer published result. +#[test] +fn concurrent_catalog_refreshes_publish_in_discovery_order() { + use std::sync::{mpsc, Arc}; + use std::time::Duration; + let state = Arc::new(super::PluginHostState::default()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + std::thread::scope(|scope| { + let first = state.clone(); + let done = done_tx.clone(); + scope.spawn(move || { + first + .refresh(|| { + entered_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + Ok(Vec::new()) + }) + .unwrap(); + done.send(()).unwrap(); + }); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(state.scan_lock.try_lock().is_err()); + let second = state.clone(); + scope.spawn(move || { + second + .refresh(|| { + // This closure runs only after the first publication. + assert!(second.scan_lock.try_lock().is_err()); + Ok(vec![scan_package_at( + &PathBuf::from("catalog-new.plugin"), + None, + )]) + }) + .unwrap(); + done_tx.send(()).unwrap(); + }); + release_tx.send(()).unwrap(); + done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + done_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + }); + assert_eq!(state.packages().len(), 1); + assert_eq!(state.packages()[0].entry.plugin_id, "catalog-new.plugin"); +} diff --git a/windows/tauri/src-tauri/src/plugin_host/web.rs b/windows/tauri/src-tauri/src/plugin_host/web.rs index c7ad4f3a76..905f791205 100644 --- a/windows/tauri/src-tauri/src/plugin_host/web.rs +++ b/windows/tauri/src-tauri/src/plugin_host/web.rs @@ -39,9 +39,112 @@ const HOST_BRIDGE_PATH: &str = "__lithe_host__/bridge.js"; #[derive(Default)] pub struct WebPageHostState { states: Mutex>, + owners: Mutex>, + workspace_generations: Mutex>, + open_files: Mutex>, appearances: Mutex>, } +struct PendingOpenFile { + host_label: String, + page_label: String, + completion: tokio::sync::oneshot::Sender>, +} + +#[derive(Clone, Debug, PartialEq)] +struct PageOwner { + generation: u64, + workspace_generation: u64, + host_label: String, + workspace_id: String, + workspace_path: String, + plugin_id: String, + window_id: String, + state_id: String, +} + +static OPEN_PAGE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn workspace_generation(state: &WebPageHostState, host: &str, workspace: &str) -> u64 { + state + .workspace_generations + .lock() + .expect("workspace page generations") + .get(&workspace_label_suffix(host, workspace)) + .copied() + .unwrap_or(0) +} + +/// Effects and revocation share the owners lock. A request cloned before +/// close cannot recreate state or register an event after authority is gone. +fn with_page_owner( + state: &WebPageHostState, + label: &str, + identity: &BridgeIdentity, + effect: impl FnOnce() -> Result, +) -> Result { + let owners = state.owners.lock().expect("page owners"); + if !owners.get(label).is_some_and(|owner| { + owner.generation == identity.generation + && owner.workspace_generation + == workspace_generation(state, &owner.host_label, &owner.workspace_id) + }) { + return Err(bridge_error( + "plugin.web.forbidden", + "page ownership was revoked", + )); + } + effect() +} + +fn revoke_owners( + state: &WebPageHostState, + matches: impl Fn(&PageOwner) -> bool, + clear_state: bool, +) -> Vec { + let mut owners = state.owners.lock().expect("page owners"); + let labels: Vec<_> = owners + .iter() + .filter(|(_, owner)| matches(owner)) + .map(|(label, _)| label.clone()) + .collect(); + owners.retain(|label, _| !labels.contains(label)); + state + .open_files + .lock() + .expect("open file requests") + .retain(|_, request| !labels.contains(&request.page_label)); + state + .appearances + .lock() + .expect("page appearances") + .retain(|label, _| !labels.contains(label)); + if clear_state { + state + .states + .lock() + .expect("page state") + .retain(|key, _| !labels.iter().any(|label| key.ends_with(label))); + } + labels +} + +fn workspace_label_suffix(host_label: &str, workspace_id: &str) -> String { + format!( + "--h{}--s{}", + hex_label_part(host_label), + hex_label_part(workspace_id) + ) +} + +fn owned_label(owner: &PageOwner) -> String { + format!( + "plugin-web-{}{}", + web_window_label(&owner.plugin_id, &owner.window_id), + workspace_label_suffix(&owner.host_label, &owner.workspace_id) + ) +} + /// Resolved workbench locale and theme tokens; supplied only by host windows. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -98,7 +201,12 @@ pub struct WebToolWindow { pub fn declarative_plugins_web_windows(app: AppHandle) -> Vec { let mut windows = Vec::new(); for package in app.state::().packages() { - if package.entry.status != "accepted" || !package.entry.enabled { + if package.entry.status != "accepted" + || !package.entry.enabled + || app + .state::() + .blocked(&package.entry.plugin_id) + { continue; } collect_web_windows(&package, &mut windows); @@ -115,8 +223,13 @@ fn collect_web_windows(package: &ScannedPackage, windows: &mut Vec entry.to_string(), + None if window["text"].is_string() => format!( + "__lithe_host__/text/{}.html", + hex_label_part(window["id"].as_str().unwrap_or_default()) + ), + _ => continue, }; windows.push(WebToolWindow { window_id: window["id"].as_str().unwrap_or_default().to_string(), @@ -124,7 +237,7 @@ fn collect_web_windows(package: &ScannedPackage, windows: &mut Vec Result<(), String> { + if workspace_id.is_empty() + || workspace_path.is_empty() + || app + .state::() + .blocked(&plugin_id) + { + return Err("plugin page requires an active workspace and package".into()); + } + // Register the whole asynchronous creation, not just resource reads. + // Mutation waits for this lease before collecting and closing owners. + let _native_page = app + .state::() + .acquire(&plugin_id)?; + let owner = PageOwner { + generation: OPEN_PAGE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + workspace_generation: workspace_generation( + &app.state::(), + caller.label(), + &workspace_id, + ), + host_label: caller.label().into(), + workspace_id, + workspace_path, + plugin_id: plugin_id.clone(), + window_id: window_id.clone(), + state_id: String::new(), + }; + let label = owned_label(&owner); + let owner = PageOwner { + state_id: label.clone(), + ..owner + }; let Some(window) = declarative_plugins_web_windows(app.clone()) .into_iter() .find(|window| window.plugin_id == plugin_id && window.window_id == window_id) @@ -146,10 +294,11 @@ pub async fn declarative_plugins_open_web_window( "{plugin_id}/{window_id} is not a declared web tool window" )); }; - let label = format!( - "plugin-web-{}", - web_window_label(&window.plugin_id, &window.window_id) - ); + app.state::() + .owners + .lock() + .expect("page owners") + .insert(label.clone(), owner); app.state::() .appearances .lock() @@ -179,6 +328,7 @@ pub async fn declarative_plugins_open_web_window( let setup: Result<(), String> = async { #[cfg(windows)] network::restrict_requests(&view, request_plugin_id).await?; + bridge_identity(&app, &label)?; view.navigate(target_url).map_err(|error| error.to_string()) } .await; @@ -216,12 +366,20 @@ fn appearance_script(appearance: &WebPageAppearance) -> String { #[tauri::command] pub fn declarative_plugins_web_update_appearance( app: AppHandle, + caller: tauri::WebviewWindow, appearance: WebPageAppearance, ) -> Result<(), String> { let windows: Vec<_> = app .webview_windows() .into_iter() - .filter(|(label, _)| label.starts_with("plugin-web-")) + .filter(|(label, _)| { + app.state::() + .owners + .lock() + .expect("page owners") + .get(label) + .is_some_and(|owner| owner.host_label == caller.label()) + }) .collect(); { let state = app.state::(); @@ -245,11 +403,30 @@ pub fn declarative_plugins_web_update_appearance( /// Native window destruction releases its cached appearance, including X-close. pub fn forget_window_appearance(app: &AppHandle, label: &str) { - app.state::() - .appearances + let state = app.state::(); + let children = revoke_owners( + &state, + |owner| owner.host_label == label || owned_label(owner) == label, + !label.starts_with("plugin-web-"), + ); + let host_prefix = format!("--h{}--s", hex_label_part(label)); + state + .states .lock() - .expect("appearance mutex") - .remove(label); + .expect("page state") + .retain(|key, _| !key.contains(&host_prefix)); + state + .workspace_generations + .lock() + .expect("workspace page generations") + .retain(|scope, _| !scope.starts_with(&host_prefix)); + for child in children { + if child != label { + if let Some(view) = app.get_webview_window(&child) { + let _ = view.close(); + } + } + } } /// Destroys the web view of one tool window; used on plugin disable, @@ -257,10 +434,21 @@ pub fn forget_window_appearance(app: &AppHandle, label: &s #[tauri::command] pub fn declarative_plugins_close_web_window( app: AppHandle, + caller: tauri::WebviewWindow, + workspace_id: String, plugin_id: String, window_id: String, ) -> Result<(), String> { - let label = format!("plugin-web-{}", web_window_label(&plugin_id, &window_id)); + let label = owned_label(&PageOwner { + generation: 0, + workspace_generation: 0, + host_label: caller.label().into(), + workspace_id, + workspace_path: String::new(), + plugin_id, + window_id, + state_id: String::new(), + }); if let Some(existing) = app.get_webview_window(&label) { existing .close() @@ -453,25 +641,27 @@ fn handle_bridge_request(invoke: tauri::ipc::Invoke) { } }; let app = webview.app_handle().clone(); - let result = bridge_dispatch(&app, &webview, params); - match result { - Ok(value) => { - invoke - .resolver - .respond::(Ok(serde_json::to_string(&value) - .map(tauri::ipc::InvokeResponseBody::Json) - .unwrap_or_else(|_| tauri::ipc::InvokeResponseBody::Json("{}".into())))); - } - Err(message) => { - invoke - .resolver - .respond::(Err(tauri::ipc::InvokeError( - // `message` is already the JSON error envelope from `bridge_error`. - serde_json::from_str(&message) - .unwrap_or(serde_json::json!({ "message": message })), - ))); + tauri::async_runtime::spawn(async move { + let result = bridge_dispatch(&app, &webview, params).await; + match result { + Ok(value) => { + invoke + .resolver + .respond::(Ok(serde_json::to_string(&value) + .map(tauri::ipc::InvokeResponseBody::Json) + .unwrap_or_else(|_| tauri::ipc::InvokeResponseBody::Json("{}".into())))); + } + Err(message) => { + invoke + .resolver + .respond::(Err(tauri::ipc::InvokeError( + // `message` is already the JSON error envelope from `bridge_error`. + serde_json::from_str(&message) + .unwrap_or(serde_json::json!({ "message": message })), + ))); + } } - } + }); } #[derive(serde::Deserialize)] @@ -482,7 +672,7 @@ struct BridgeArgs { params: Option, } -fn bridge_dispatch( +async fn bridge_dispatch( app: &AppHandle, webview: &tauri::Webview, args: BridgeArgs, @@ -495,27 +685,34 @@ fn bridge_dispatch( .map_err(|error| bridge_error("plugin.web.forbidden", error.to_string()))?; require_package_url(&url, &identity.plugin_id)?; match method { - "ui.getState" => Ok(json!({ - "state": declarative_plugins_web_get_state( - app.clone(), - identity.plugin_id, - identity.window_id, - ) - })), + "ui.getState" => with_page_owner( + &app.state::(), + webview.label(), + &identity, + || { + Ok(json!({ + "state": declarative_plugins_web_get_state(app.clone(), identity.plugin_id.clone(), identity.state_id.clone()) + })) + }, + ), "ui.setState" => { - let Some(state) = params.get("state") else { - return Err(bridge_error( - "plugin.web.invalidRequest", - "setState requires state", - )); - }; - declarative_plugins_web_set_state( - app.clone(), - identity.plugin_id, - identity.window_id, - state.clone(), - )?; - Ok(json!({})) + let state = params.get("state").ok_or_else(|| { + bridge_error("plugin.web.invalidRequest", "setState requires state") + })?; + with_page_owner( + &app.state::(), + webview.label(), + &identity, + || { + declarative_plugins_web_set_state( + app.clone(), + identity.plugin_id.clone(), + identity.state_id.clone(), + state.clone(), + )?; + Ok(json!({})) + }, + ) } "workspace.openFile" => { let Some(path) = params["path"].as_str() else { @@ -525,16 +722,66 @@ fn bridge_dispatch( )); }; use tauri::Emitter; - app.emit( - "plugin-web-open-file", - json!({ - "pluginId": identity.plugin_id, - "path": path, - "line": params["line"].as_u64(), - "column": params["column"].as_u64(), - }), - ) - .map_err(|error| error.to_string())?; + let operation_id = format!( + "{}-{}", + webview.label(), + OPEN_FILE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + ); + let (sender, receiver) = tokio::sync::oneshot::channel(); + let pending = OpenFileRequest { + app: app.clone(), + operation_id: operation_id.clone(), + }; + with_page_owner( + &app.state::(), + webview.label(), + &identity, + || { + let state = app.state::(); + let mut requests = state.open_files.lock().expect("open file requests"); + if requests.len() >= 128 { + return Err(bridge_error( + "plugin.web.invalidRequest", + "too many pending file open requests", + )); + } + requests.insert( + operation_id.clone(), + PendingOpenFile { + host_label: identity.host_label.clone(), + page_label: webview.label().into(), + completion: sender, + }, + ); + drop(requests); + app.emit_to( + &identity.host_label, + "plugin-web-open-file", + json!({ + "pluginId": identity.plugin_id, + "operationId": operation_id, + "workspaceId": identity.workspace_id, + "workspacePath": identity.workspace_path, + "path": path, + "line": params["line"].as_u64(), + "column": params["column"].as_u64(), + }), + ) + .map_err(|error| error.to_string()) + }, + )?; + tokio::time::timeout(std::time::Duration::from_secs(10), receiver) + .await + .map_err(|_| { + bridge_error( + "plugin.web.openFileFailed", + "file open acknowledgement timed out", + ) + })? + .map_err(|_| bridge_error("plugin.web.forbidden", "page was closed"))? + .map_err(|error| bridge_error("plugin.web.openFileFailed", error))?; + drop(pending); + bridge_identity(app, webview.label())?; Ok(json!({})) } "build.instances" | "build.refresh" | "build.runTask" | "build.runTests" @@ -550,11 +797,53 @@ fn bridge_dispatch( } } +static OPEN_FILE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +struct OpenFileRequest { + app: AppHandle, + operation_id: String, +} +impl Drop for OpenFileRequest { + fn drop(&mut self) { + self.app + .state::() + .open_files + .lock() + .expect("open file requests") + .remove(&self.operation_id); + } +} + +#[tauri::command] +pub fn declarative_plugins_web_open_file_ack( + app: AppHandle, + caller: tauri::WebviewWindow, + operation_id: String, + error: Option, +) -> Result<(), String> { + let state = app.state::(); + let mut pending = state.open_files.lock().expect("open file requests"); + if !pending + .get(&operation_id) + .is_some_and(|request| request.host_label == caller.label()) + { + return Err("file request does not belong to this product window".into()); + } + if let Some(request) = pending.remove(&operation_id) { + let _ = request.completion.send(error.map_or(Ok(()), Err)); + } + Ok(()) +} + /// Identity of a bridge caller, derived from its window label. #[derive(Debug, Clone, PartialEq)] pub struct BridgeIdentity { + pub generation: u64, pub plugin_id: String, pub window_id: String, + pub host_label: String, + pub workspace_id: String, + pub workspace_path: String, + pub state_id: String, } /// Resolves and authorizes a bridge caller from its webview label. The label @@ -568,40 +857,59 @@ fn bridge_identity( app: &AppHandle, label: &str, ) -> Result { - const LABEL_PREFIX: &str = "plugin-web-"; - let Some(remainder) = label.strip_prefix(LABEL_PREFIX) else { + let owner = app + .state::() + .owners + .lock() + .expect("page owners") + .get(label) + .cloned() + .ok_or_else(|| bridge_error("plugin.web.forbidden", "page ownership was revoked"))?; + if owner.workspace_generation + != workspace_generation( + &app.state::(), + &owner.host_label, + &owner.workspace_id, + ) + || owned_label(&owner) != label + || app.get_webview_window(&owner.host_label).is_none() + || app + .state::() + .blocked(&owner.plugin_id) + { return Err(bridge_error( "plugin.web.forbidden", - "bridge requests are only accepted from plugin pages", + "page owner is inactive", )); - }; - let packages = app.state::().packages(); - for package in &packages { - if package.entry.status != "accepted" || !package.entry.enabled { - continue; - } - let Some(tool_windows) = package.manifest["ui"]["toolWindows"].as_array() else { - continue; - }; - for window in tool_windows { - let Some(window_id) = window["id"].as_str() else { - continue; - }; - if window["web"]["entry"].is_null() { - continue; - } - if web_window_label(&package.entry.plugin_id, window_id) == remainder { - return Ok(BridgeIdentity { - plugin_id: package.entry.plugin_id.clone(), - window_id: window_id.to_string(), - }); - } + } + let declared = declarative_windows_for(app) + .into_iter() + .any(|window| window.plugin_id == owner.plugin_id && window.window_id == owner.window_id); + if !declared { + return Err(bridge_error( + "plugin.web.forbidden", + "package no longer declares this page", + )); + } + Ok(BridgeIdentity { + generation: owner.generation, + plugin_id: owner.plugin_id, + window_id: owner.window_id, + host_label: owner.host_label, + workspace_id: owner.workspace_id, + workspace_path: owner.workspace_path, + state_id: owner.state_id, + }) +} + +fn declarative_windows_for(app: &AppHandle) -> Vec { + let mut windows = Vec::new(); + for package in app.state::().packages() { + if package.entry.status == "accepted" && package.entry.enabled { + collect_web_windows(&package, &mut windows); } } - Err(bridge_error( - "plugin.web.forbidden", - "the calling window does not belong to an enabled plugin page", - )) + windows } /// The webview label of one tool window: a collision-free hexadecimal encoding @@ -666,44 +974,55 @@ pub fn declarative_plugins_web_resolve_file( }) } -/// Declared web window IDs of one plugin from the current catalog. -pub fn web_windows_of_plugin( - app: &AppHandle, - plugin_id: &str, -) -> Vec { - app.state::() - .packages() - .into_iter() - .filter(|package| package.entry.plugin_id == plugin_id) - .flat_map(|package| { - package.manifest["ui"]["toolWindows"] - .as_array() - .map(|windows| { - windows - .iter() - .filter_map(|window| window["id"].as_str().map(str::to_string)) - .collect::>() - }) - .unwrap_or_default() - }) - .collect() +/// Revoke bridge access before closing; close failures stop package mutation. +pub fn close_plugin_windows(app: &AppHandle, plugin_id: &str) -> Result<(), String> { + close_owned_pages(app, |owner| owner.plugin_id == plugin_id)?; + forget_plugin_state(app, plugin_id); + Ok(()) } -/// Closes one web window by declared window ID, tolerating absence. -pub fn close_web_window_by_id( - app: &AppHandle, - plugin_id: &str, - window_id: &str, -) { - let label = format!("plugin-web-{}", web_window_label(plugin_id, window_id)); +fn close_owned_pages(app: &AppHandle, matches: impl Fn(&PageOwner) -> bool) -> Result<(), String> { + let labels = revoke_owners(&app.state::(), matches, true); + let mut failures = Vec::new(); + for label in labels { + if let Some(view) = app.get_webview_window(&label) { + if let Err(error) = view.close() { + failures.push(error.to_string()); + } + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} + +#[tauri::command] +pub fn declarative_plugins_close_workspace_pages( + app: AppHandle, + caller: tauri::WebviewWindow, + workspace_id: String, +) -> Result<(), String> { + let scope = workspace_label_suffix(caller.label(), &workspace_id); + { + let state = app.state::(); + let mut generations = state + .workspace_generations + .lock() + .expect("workspace page generations"); + *generations.entry(scope).or_default() += 1; + } + close_owned_pages(&app, |owner| { + owner.host_label == caller.label() && owner.workspace_id == workspace_id + })?; + let suffix = workspace_label_suffix(caller.label(), &workspace_id); app.state::() - .appearances + .states .lock() - .expect("appearance mutex") - .remove(&label); - if let Some(existing) = app.get_webview_window(&label) { - let _ = existing.close(); - } + .expect("page state") + .retain(|key, _| !key.ends_with(&suffix)); + Ok(()) } // MARK: scheme file service @@ -875,11 +1194,38 @@ pub fn handle_scheme_request( // Wry restores `http://lithe-plugin./...` to the original // `lithe-plugin:///...` before invoking this scheme handler. let plugin_id = request.uri().host().unwrap_or_default(); + let _native_read = match app + .state::() + .acquire(plugin_id) + { + Ok(lease) => lease, + Err(_) => return refused("plugin package is changing"), + }; let identity = match bridge_identity(app, webview_label) { Ok(identity) if identity.plugin_id == plugin_id => identity, _ => return refused("resource request is outside its plugin page"), }; let path = request.uri().path().trim_start_matches('/'); + if path.starts_with("__lithe_host__/text/") { + let package = app + .state::() + .packages() + .into_iter() + .find(|package| package.entry.plugin_id == identity.plugin_id); + let text = package + .as_ref() + .and_then(|package| package.manifest["ui"]["toolWindows"].as_array()) + .and_then(|windows| { + windows + .iter() + .find(|window| window["id"].as_str() == Some(&identity.window_id)) + }) + .and_then(|window| window["text"].as_str()); + return match text { + Some(text) => ok("text/html; charset=utf-8", text_page(text)), + None => refused("text window is no longer declared"), + }; + } if path == HOST_BRIDGE_PATH { let appearance = app .state::() @@ -905,6 +1251,18 @@ pub fn handle_scheme_request( } } +/// ui:1 text is literal content, escaped into a host-owned shell. No package +/// markup or scripts execute; theme follows the same host bridge as web pages. +fn text_page(text: &str) -> Vec { + let escaped = text + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + page_with_host_bridge(format!("
    {escaped}
    ").into_bytes()) +} + /// Inserts the host script before package code without rewriting the package. /// Only the HTML prologue is scanned: comments/declarations and the optional /// html/head opening tags. Stop before any other tag or text, so a literal @@ -1265,6 +1623,49 @@ mod tests { web_window_label("other", "panel") ); } + #[test] + fn workspace_pages_have_distinct_labels_and_state() { + let first = PageOwner { + generation: 1, + workspace_generation: 0, + host_label: "main".into(), + workspace_id: "a".into(), + workspace_path: "C:/a".into(), + plugin_id: "test.plugin".into(), + window_id: "panel".into(), + state_id: String::new(), + }; + let second = PageOwner { + workspace_id: "b".into(), + ..first.clone() + }; + let other_window = PageOwner { + generation: 1, + workspace_generation: 0, + host_label: "workspace-other".into(), + ..first.clone() + }; + assert_ne!(owned_label(&first), owned_label(&second)); + assert_ne!(owned_label(&first), owned_label(&other_window)); + assert_ne!( + state_key("test.plugin", &owned_label(&first)), + state_key("test.plugin", &owned_label(&second)) + ); + } + + #[test] + fn text_window_is_literal_and_hello_fixture_is_registered() { + let page = String::from_utf8(text_page("&")).unwrap(); + assert!(!page.contains("