From 8788e8f1bc808d59651a67fbb2f329380f28eb0a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 00:27:31 +0800 Subject: [PATCH 01/16] =?UTF-8?q?docs(plugins):=20=E6=8C=89=20preview=20e8?= =?UTF-8?q?c4e9fc9=20=E5=88=B7=E6=96=B0=20macOS=20=E6=8F=92=E4=BB=B6=20API?= =?UTF-8?q?=20=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1517(原生 Provider 工作流)和 #1515(远程工作区适配)都已合并, 计划里"在途/未合并"的说法已过时。本次只改文档,不改运行时代码、 schema、SDK 和功能矩阵状态。 基线与现状 - 盘点基线:Lithe 更新为 preview e8c4e9fc9;#1517 记为已合并;新增 #1515 (阶段性实现,范围与缺口见 API-40);记录插件文档仓库 main 仍是 be779d2000e3,且其契约锁落后主仓库 4 个触及 plugins/v1 的提交 - 能力版本:对照 PluginTypes.swift 与 capabilities.rs,补上 buildProviders 1–6、buildModelInputs 1、debugAdapters 1/2; Core 与 Mac 唯一差异是 serverProcess(Core 1/2,Mac 1) - API-29、API-31、API-35、M4、DOC-12、5.1 SDK 行:去掉对 #1517 在途的依赖, 改为已合并后的文档、SDK 对齐与端到端验收 新增条目(编号接续 API-39,不改动既有编号) - API-40 工作区 authority(M0):远程工作区仍是本机空占位根,backend:1 与 Web bridge 不识别远程绑定;未实现的 authority 必须明确拒绝。API-05、 API-35 同步指向它 - API-41 激活事件与启动预算(M0/M1):现有 activationEvents 只与已声明的 Provider/窗口命令一一对应;应用没有工作区信任概念,M0 需决定 - API-42 内置 Git 只读快照(M3):Git 不在统一 VCS 层,API-31 只覆盖插件 VCS - API-43 documents.onWillSave(M3):保存路径没有保存前钩子 - API-44 Agent 侧贡献(后续):Skills/Hooks/MCP 是用户管理的原生配置, 没有插件来源 - 同步 2.1 现状表、4.1/4.2 评审清单、阶段表 M0/M1/M3c/M4/后续、 DOC-02/04/07/13 以及新增 DOC-18/19、来源索引 S20–S22 公共扩展契约提案(proposed Note) - 修正"内置 Git 与插件 VCS 共用同一统一模型",与 2026-10-22 评审一致: Git 暂不接入统一层 - #1517 改为已合并,补 #1515 的范围;新增 authority/激活/保存前事件/ Agent 侧贡献一节,说明本 Note 不预设答案 --- ...lic-extension-contract-and-provider-api.md | 16 +++-- docs/development/macos-plugin-api-plan.md | 70 ++++++++++++------- 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md b/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md index c6c92c5b9c..ac5fbb4ad5 100644 --- a/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md +++ b/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md @@ -4,13 +4,13 @@ ## 先说结论 -PR #37 建立了第一阶段 UI Contribution(插件声明的界面入口);到 2026-10-28,Mac 已有原生 Tree/List/Form、包内网页和独立后端,不再只是静态文本窗口。下一步先走 macOS 路线,把已有编辑器、文件和命令功能开放成有授权、版本与生命周期的公共 API,再接续领域 Provider(提供业务数据、行为或进程的实现)。不恢复任意第三方 Swift Bundle 动态加载,也不把 WASM 运行时作为补齐 API 的前置条件;任务与公开文档待办见 [macOS 插件 API 实施计划](../../../../docs/development/macos-plugin-api-plan.md)。 +PR #37 建立了第一阶段 UI Contribution(插件声明的界面入口);到 2026-10-30,Mac 已有原生 Tree/List/Form、包内网页和独立后端,不再只是静态文本窗口。下一步先走 macOS 路线,把已有编辑器、文件和命令功能开放成有授权、版本与生命周期的公共 API,再接续领域 Provider(提供业务数据、行为或进程的实现)。不恢复任意第三方 Swift Bundle 动态加载,也不把 WASM 运行时作为补齐 API 的前置条件;任务与公开文档待办见 [macOS 插件 API 实施计划](../../../../docs/development/macos-plugin-api-plan.md)。 ## 问题 最初的 API level 2 要求窗口使用 `text`,命令只能打开自身窗口,插件校验依赖语言或 UI。这是第一阶段的历史背景,不是今天的完整能力表:当前 Core 已允许纯 Build/VCS/Debug/项目生成器插件,Mac 的 `ui:3` 也已经支持原生树、列表和表单。 -当前缺口在于通用接入:`ui.commands` 仍绑定自身窗口;原生动态数据源绑定自身构建系统和唯一实例;`backend:1` 是每插件一个应用级进程,只由自身网页调用。函数协议 v2 已有本插件凭据服务,但尚不是通用工作区/编辑器 SDK。开发者不能因为内部 Swift 模块能操作文件或编辑器,就直接从第三方后端调用它。领域能力的进一步迁移也不能与通用宿主 API 混为一谈。 +当前缺口在于通用接入:`ui.commands` 仍绑定自身窗口;原生动态数据源绑定自身构建系统和唯一实例;`backend:1` 是每插件一个应用级本机进程,只由自身网页调用,不识别远程工作区。函数协议 v2 已有本插件凭据服务,但尚不是通用工作区/编辑器 SDK。开发者不能因为内部 Swift 模块能操作文件或编辑器,就直接从第三方后端调用它。领域能力的进一步迁移也不能与通用宿主 API 混为一谈。 ## 提案 @@ -46,11 +46,11 @@ PR #37 建立了第一阶段 UI Contribution(插件声明的界面入口); 公共 Extension Contract(插件和宿主交换能力声明及调用结果的协议)覆盖 Language/LSP、BuildSystem/Task、RunConfiguration/Test、DebugAdapter 和 ServiceProvider。Provider 提供业务行为、进程协议或数据,宿主负责发现、调度、权限、展示、取消、超时、失效结果和资源生命周期。纯 Build/Debug 等入口已存在;尚待定义的通用 Services 与独立命令也不应为了通过校验而造空语言或空窗口。 -版本控制(VCS)也作为领域扩展点接入,内置 Git 与插件 VCS 共用同一统一模型,见[统一 VCS 模型与 Provider 接入](2026-10-22-unified-vcs-provider-model.md)。 +版本控制(VCS)也作为领域扩展点接入,但第一期统一模型只服务插件 Provider:内置 Git 按 2026-10-22 评审暂不接入,仍由 Git 模块持有,见[统一 VCS 模型与 Provider 接入](2026-10-22-unified-vcs-provider-model.md)。因此插件目前没有读取内置 Git 状态的公共入口,是否开放只读快照见实施计划 API-42。 内置模块和插件尽量实现相同领域 Provider 契约。Build/Run/Test/Debug 等采用类型化 Registry(按能力类型登记和查询实现的目录),组合根负责装配通用适配器,不按新增插件名追加分支。先盘点并复用已有语言服务器、构建工具、运行/测试和 DAP(调试适配器协议)链路;上游拥有的项目、任务和依赖事实仍由原工具提供,避免再造一套解释器、工程模型或调试 UI。 -2026-10-28 盘点时,[PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517) 正在扩展原生 Build/Run/Test/Debug 准备与只读工程模型输入,尚未合并。相关工作先跟踪其最终版本,不另建协议,也不把该 PR 的能力记成当前 Mac 已发布支持。 +[PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517) 已于 2026-10-28 合并,扩展了原生 Build/Run/Test/Debug 准备与只读工程模型输入;Mac 已声明 `buildProviders:1–6`、`buildModelInputs:1`、`debugAdapters:1/2`(以 `PluginTypes.swift` 的 `hostCapabilities` 为准)。后续只补文档、SDK 对齐和端到端验收,不另建协议。[PR #1515](https://github.com/1lck/Lithe-IDEA/pull/1515) 于 2026-10-29 合并,把远程工作区的文件、工作区、终端、Build 以及 Build/VCS/语言 Provider 启动路由到远程适配器,但它是阶段性实现,范围见下一节。 Debug 的统一内核、三层能力、插件声明与迁移验收见[统一调试内核与调试适配器 Registry](2026-10-25-unified-debug-engine-and-adapter-registry.md),跟踪当前仓库的 [#1210](https://github.com/1lck/Lithe-IDEA/issues/1210)。寄存器、内存、反汇编等可选能力另见[可选调试扩展](2026-10-25-optional-debug-extensions.md),插件依赖与导出另见[平台级独立提案](2026-10-25-plugin-dependencies-and-exports.md);两者都不作为 #1210 的前置条件。这些提案没有开放新字段,也不改变其他领域 Provider 的设计。 @@ -60,6 +60,14 @@ Services 接入容器、Spring Boot、数据库和本地服务。后续统一设 宿主必须区分插件创建并拥有的资源与用户独立运行的外部服务。禁用插件时注销贡献、关闭它拥有的连接/监听/进程和任务;对外部服务释放观察或连接,不能默认停止外部实例。用户显式调用停止动作时另行核对授权与目标。权限、归属与取消语义先进入契约和 fixtures,再接入真实服务。 +### 工作区 authority、激活与保存前事件 + +远程工作区(见[远程工作区按根目录路由](../../implemented/architecture/2026-10-24-remote-workspace-routing.md))在本机以空占位根目录打开,由 `RemoteWorkspaceRegistry` 绑定会话和远程根;`backend:1` 与网页 bridge 目前只认本机工作区。公共 API 的资源标识因此需要显式 authority:首版只承诺本机,未实现的 authority 明确拒绝,不把占位根或远程路径当本机路径,也不静默在本机执行。 + +激活事件目前只能与已声明的 Provider 或窗口命令一一对应,没有独立命令、工作区匹配事件和统一的启动预算;应用也没有工作区信任概念,只有逐操作确认。这两点在实施计划 M0 定稿(API-40、API-41),本 Note 不预设答案。保存前事件(`documents.onWillSave`,API-43)依赖版本化编辑与严格截止时间,晚于单文档编辑落地。 + +Agent 的 Skills、Hooks、MCP 是用户管理的 Codex/Claude 原生配置,插件没有贡献来源。是否允许插件提供候选(API-44)与 IDE broker 的威胁建模一起评审:候选只能经用户确认写入明确来源,不能自动启用。 + ### 版本与兼容性 已发布 API level 和能力版本保持既有字段、校验和行为,包括 `pluginAPIVersion=2`。交互组件使用单独版本化的 UI Schema;独立动作和新增领域扩展点进入明确的新契约版本,具体版本号及协商字段在后续任务中确定。文档中的未来能力不会被旧校验器默许,也不能让已发布包因新增版本而必须改写。未知版本和未实现能力继续由宿主与市场兼容性检查明确拒绝;共享 fixtures 验证各版本的接受、拒绝与迁移。 diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index 5dee8ed40d..4408038edc 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -1,6 +1,6 @@ # macOS 插件公共 API 与开发文档实施计划 -状态:待实施计划。盘点日期:2026-10-28。 +状态:待实施计划。盘点日期:2026-10-28;2026-10-30 按 preview `e8c4e9fc9` 刷新(#1517、#1515 已合并)。 ## 1. 目标与范围 @@ -16,10 +16,11 @@ | 对象 | 固定版本与证据 | 使用限制 | | --- | --- | --- | -| Lithe | [preview `2cc32e60b2a4`](https://github.com/1lck/Lithe-IDEA/tree/2cc32e60b2a4129d4a4c19350e97a7ae62440232) | 本文件的本地源码路径均相对此版本;源码存在不代表本次做过实机验收 | -| 插件文档/SDK | [main `be779d2000e3`](https://github.com/Lithe-IDEA/lithe-plugin-docs/tree/be779d2000e38415ea047fc2e2b2b7973e7d8e07) | `contract.lock.json` 锁定主仓库 `32021a86f0c6c4ad2964dbbe3c2bb7854d84ad67`,不能与本次 preview 基线混用 | +| Lithe | [preview `e8c4e9fc933a`](https://github.com/1lck/Lithe-IDEA/tree/e8c4e9fc933a85f789d603371e77efd219214452) | 本文件的本地源码路径均相对此版本,已包含下面两个合并的 PR;源码存在不代表本次做过实机验收 | +| 插件文档/SDK | [main `be779d2000e3`](https://github.com/Lithe-IDEA/lithe-plugin-docs/tree/be779d2000e38415ea047fc2e2b2b7973e7d8e07) | 2026-10-30 复核仍是该 commit。`contract.lock.json` 锁定主仓库 `32021a86f0c6c4ad2964dbbe3c2bb7854d84ad67`,不能与本次 preview 基线混用;该锁之后 `shared/contracts/plugins/v1` 又有 4 个提交(含 #1517),副本尚未同步 | | OpenIDEA | [`8ff8f229407d`](https://github.com/l0ng-ai/openidea/tree/8ff8f229407df73e722d0a933f8e0aebd48f516c) | 比较其公开源码,不推断全量功能成熟度或产品安全结论 | -| 原生 Provider 工作流 | [PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517),head `360255a6721680f0ae54bbedf3a64e7667a2c774` | 盘点时 OPEN、未合并;不得计入当前 Mac 已支持能力 | +| 原生 Provider 工作流 | [PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517),合并提交 `659739011591`(2026-10-28 已合并) | 已在上面的 preview 中;Mac 已声明 `buildProviders:1–6`、`buildModelInputs:1`、`debugAdapters:1/2`,见 2.1 | +| 远程工作区适配 | [PR #1515](https://github.com/1lck/Lithe-IDEA/pull/1515),合并提交 `823fe428384d`(2026-10-29 已合并,关联 #1507) | 已在上面的 preview 中,但 PR 自述为阶段性实现,功能矩阵保持 partial / pending;不得据此认定远程工作区已完整可用,范围与缺口见 API-40 | 后续认领任务时先刷新这些基线和 PR 状态,避免重做已合并能力。这里的“需补”表示在列出的公共入口中没有对应通用契约,不表示 IDE 内部完全没有该功能。 @@ -33,13 +34,17 @@ | 原生插件界面 | `ui:3` 已有 Tree/List/Form、工具栏、右键和本地动作,不只是静态文本 [S3] | 动态数据源目前绑定自身 `buildSystemID` 和唯一匹配实例;需通用后端来源、懒加载和分页 | | 命令 | `ui.commands` 打开自己的工具窗口;面板内动作可以调用函数 [S1/S3] | 不依赖窗口/构建系统的命令处理器,以及语义事件、上下文菜单、快捷键接入 | | 自有网页 | 包内页面可走白名单 Web bridge [S4] | 不是完整文件/编辑器 API;新能力必须分别授权,不能开放 AppModel、DOM 或任意脚本入口 | -| 独立后端 | `backend:1` 是每插件一个应用级外部进程,只服务自己的网页;`${stateDir}` 可用,`${workspaceStateDir}` 被拒绝 [S2] | 没有窗口的命令/事件插件不能直接复用该声明;需新版本上下文和激活模型,不能悄悄改 v1 的作用域 | +| 独立后端 | `backend:1` 是每插件一个应用级本机外部进程,只服务自己的网页;`${stateDir}` 可用,`${workspaceStateDir}` 被拒绝;后端会话未接入远程工作区注册表 [S2/S20] | 没有窗口的命令/事件插件不能直接复用该声明;需新版本上下文和激活模型,不能悄悄改 v1 的作用域 | | 函数与宿主服务 | 私有函数已有注册、进度、取消、截止时间、撤回和失效结果处理;函数协议 v2 的宿主服务目前只有本插件凭据读写删除 [S2] | 编辑器、工作区、通知等宿主 API 尚需逐项开放;不能把私有函数当跨插件公共服务 | -| 领域插件 | Core 已允许没有 UI/语言的纯 Build、VCS、Debug、项目生成器插件 [S1] | 纯命令插件仍是独立缺口;Build/Run/Test/Debug 工作流按 #1517 接续,不从零另造 Provider | +| 领域插件 | Core 已允许没有 UI/语言的纯 Build、VCS、Debug、项目生成器插件;#1517 已合并,Mac 已声明 `buildProviders:1–6`、`buildModelInputs:1`、`debugAdapters:1/2` [S1/S11] | 纯命令插件仍是独立缺口;Build/Run/Test/Debug 缺的是文档、SDK 对齐和真实项目端到端验收,不从零另造 Provider;内置 Git 不在 VCS Provider 层 [S21] | | IDE 自动化 | 已有单独授权的 IDE/MCP broker,含项目/环境、Maven、Run 和操作输出等 13 个工具 [S15] | 它不是声明式后端默认可用的 SDK;原生 `IDECapabilitiesProviding` 不等于公共插件授权 | | 生命周期 | 外部进程、网页、原生面板已有自己的所有权和清理路径 [S2/S3/S4] | 不等于所有插件都能热加载;当前纯 Debug Adapter 的热激活范围不能扩大宣传 [S18] | +| 工作区 authority | 远程文件夹在本机是空的占位根目录,`RemoteWorkspaceRegistry` 按 URL 绑定会话和远程根;文件、工作区、终端、Build 以及 Build/VCS/语言 Provider 启动已路由到远程适配器 [S20] | 公共 API 还没有 authority 概念;`backend:1` 与 Web bridge 不识别远程绑定;远程 Java 原生集成、Agent 执行位置和 SSH 调试仍待完成 | +| 激活与信任 | `activationEvents` 与 Provider 一一对应并由 Core 校验(`plugin.activation.invalid`);`onCommand` 只对应 `ui.commands` 的窗口命令 [S1] | 没有独立命令、工作区匹配等事件,也没有按激活事件统一限制并发数和冷启动时间的预算;应用没有工作区信任概念,只有逐操作的进程启动确认 [S4] | +| 内置 Git | Git 在 `LitheGitModule`,按 2026-10-22 评审不接入统一 VCS 层;IDE broker 的 13 个工具没有 Git 工具 [S15/S21] | API-31 只覆盖插件提供的 VCS,插件没有读取内置 Git 状态的公共入口 | +| Agent 侧 | Skills、Hooks、MCP 是用户管理的 Codex/Claude 原生配置,宿主只读取并编辑用户选定的来源 [S22] | 插件没有贡献来源;不能让插件绕过用户确认写入这些配置 | -Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSystems:1/2`、`debugAdapters:1`、`executionProviders:1`、`executableResolution:1`、`languageServers:1/2`、`languages:1`、`packageTrust:1`、`projectGenerators:1`、`serverProcess:1`、`settings:1/2`、`ui:1/2/3`、`vcsProviders:1`。Core 能校验某版本不等于 Mac 会执行它,例如不能因为 Core 有 `serverProcess:2` 就给 Mac 标记支持。 +Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`buildProviders:1–6`、`buildSystems:1/2`、`debugAdapters:1/2`、`executionProviders:1`、`executableResolution:1`、`languageServers:1/2`、`languages:1`、`packageTrust:1`、`projectGenerators:1`、`serverProcess:1`、`settings:1/2`、`ui:1/2/3`、`vcsProviders:1`。Core 能校验某版本不等于 Mac 会执行它:Core 的 `serverProcess` 声明 1/2,而 Mac 只声明 1,不能因此给 Mac 标记支持 `serverProcess:2`。以上以 `PluginTypes.swift` 的 `hostCapabilities` 为准,与 Core 的 `SUPPORTED_CAPABILITIES` 逐项比对过,唯一差异是 `serverProcess`。 当前 Web bridge 的方法白名单为 `build.instances/refresh/runTask/runTests/cancel/dependencies/setOptions`、`workspace.openFile`、`ui.getState/setState`、`functions.list/invoke/cancel`、`dialog.openFile/saveFile`。后两个选择文件方法已在 Mac bridge v2 实现;盘点不能只依据较早 README 的列表。网页调用权限也不自动转授给后端进程。 @@ -57,7 +62,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy 记号:**H** = 插件调用宿主服务;**P** = 插件提供业务数据/行为,由宿主调用;**C** = 清单声明贡献;**E** = 宿主推送语义事件。一个 SDK 可以封装这四类,但不能把它们合成绕过权限的 `invokeAnything`。 -阶段:**M0** 协商与作用域;**M1** 独立命令/只读编辑器;**M2** 安全文本修改;**M3** 文件树/面板/预览;**M4** 领域接续;**后续** = 有真实需求再立项,不阻塞首版。每行都有稳定任务 ID;正式签名、参数和数字预算在实现 PR 定稿。权限栏是评审分类,不是现有清单字段名。 +阶段:**M0** 协商、作用域与 authority;**M1** 独立命令/只读编辑器;**M2** 安全文本修改;**M3** 文件树/面板/预览;**M4** 领域接续;**后续** = 有真实需求再立项,不阻塞首版。每行都有稳定任务 ID;正式签名、参数和数字预算在实现 PR 定稿。权限栏是评审分类,不是现有清单字段名。 ### 3.1 激活、命令与事件 @@ -67,6 +72,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy | API-02 C/P/H | `commands.register`、受限 `commands.execute`;复用命令目录/快捷键 [S8],补独立 handler | 命令 ID 归插件;只允许自身命令及明确列出的宿主命令;后端回调不得绕回自身 Provider。无窗口无构建系统的命令能出现、调用并卸载 | M1 | | API-03 C/H | `menus.contribute`、`keybindings.contribute`、`context.set`、状态栏项;已有内部入口 [S8] | 上下文键命名空间化、条件表达式有白名单;不截获任意按钮或系统快捷键。冲突可见,用户快捷键优先;菜单隐藏不是权限检查 | M1:命令入口;M3:其他贡献 | | API-04 E | `workspace.onDidChange`、`documents.onDidOpen/Change/Save/Close`、`editor.onDidChangeActive/Selection`;内部观察不等于公开事件 [S5/S6] | 订阅前授权;事件带工作区/文档版本;高频事件合并、限流、有丢失后重取快照规则。切换/关闭项目及 dispose 后不再交付旧事件 | M1:活动编辑器/选区;M3:其余 | +| API-41 C/H | 激活事件与启动预算:独立命令、工作区内容匹配等候选事件;现有 `activationEvents` 只能对应已声明的 Provider/窗口命令(`validate_activation_events`)[S1] | 事件集合由宿主枚举,不接受任意表达式;每个事件有并发激活数、超时、失败退避和冷启动总预算,慢或崩溃的插件不拖慢打开窗口与输入。激活不绕过授权和逐操作确认;默认不提供“启动即激活”。是否引入工作区信任由 M0 决定,见 4.2 | M0:事件集合与预算;M1:命令事件 | “监听按钮”的对外模型应当是“我贡献的命令被执行”“我的树节点被激活”“文档已保存”,而不是监听任意 SwiftUI 控件的 click。界面布局变化不应使插件失效,也不能借监听入口窃取其他插件输入。 @@ -74,7 +80,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy | ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | | --- | --- | --- | --- | -| API-05 H/E | `workspace.getFolders/getContext`、工作区变更;复用工程模型 [S6] | 只读元数据授权;返回宿主签发的上下文和相对资源标识。即使 v1 仅一个根,也显式表达根 ID;无工程时明确为空 | M0/M1 | +| API-05 H/E | `workspace.getFolders/getContext`、工作区变更;复用工程模型 [S6],远程工作区的本机占位根不能直接当资源标识 [S20] | 只读元数据授权;返回宿主签发的上下文和相对资源标识。即使 v1 仅一个根,也显式表达根 ID 和 authority(见 API-40);无工程时明确为空 | M0/M1 | | API-06 H | `workspace.readDirectory/stat/readFile`;已有文件端口 [S6] | 文件读取授权;目录分页、深度/字节限额、编码/二进制结果;隐藏/忽略规则和符号链接策略写入契约。不会整棵树一次读入或逃逸根目录 | M3 | | API-07 H/E | `workspace.watch`;复用目录/文件观察端口 [S6] | watcher 归订阅与工作区,关闭/禁用后释放;溢出报告需重扫,事件不是每次 OS 变更的精确日志。旧工作区事件不能刷新新树 | M3 | | API-08 H | `workspace.findFiles/findText`;已有搜索 owner [S7] | 读取授权、取消、分页/截断、忽略规则;明确搜索磁盘还是包含未保存缓冲区。复用索引,不让每个插件再扫描全盘 | M3 | @@ -82,6 +88,8 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy | API-10 H | `editor.applyEdits/replaceSelections/insertText`;已有编辑与 LSP WorkspaceEdit 链路 [S5] | 编辑授权、明确文档/预期版本;先做单文档完整校验再应用,形成一个撤销组。版本冲突、越界、重叠范围或输入法组合中安全拒绝/延后,不覆盖用户新输入 | M2 | | API-11 H | `documents.save/saveAs/close`、`workspace.create/rename/copy/trash`;文件端口已有部分保护 [S5/S6] | 保存/文件变更单独授权;打开文档走文档 owner,校验磁盘身份及用户确认,不以直接写盘绕过 dirty/编码。首版不提供无确认永久删除 | M3:保存;后续:文件变更 | | API-12 H/P | `workspace.applyEdit`、`documents.registerContentProvider`、`editor.showDiff`;已有虚拟文档和 diff [S5] | 多文件预览、逐项版本和只读校验;明确部分失败/恢复语义,不承诺现有 LSP 实现已具备跨文件原子回滚。只读虚拟资源先行 | M2:复用 diff 预览;后续:虚拟/多文件 | +| API-40 H/E | 工作区 authority:`workspace.getContext` 和资源标识携带 authority(本机或某个远程连接)。现状:远程文件夹是本机空占位根,文件、工作区、终端、Build 和 Build/VCS/语言 Provider 启动按 URL 路由到远程适配器;`backend:1` 与 Web bridge 只按本机 `workspaceURL` 工作,不识别远程绑定 [S20] | 首版只承诺 `local`;对未实现 authority 的任何新 API 必须返回稳定的“不支持”错误,不得把占位根或远程路径当本机路径读写,也不得静默在本机执行。占位根路径不是可持久化的资源 ID。远程 Java 原生集成、Agent、SSH 调试按远程 Note 推进,不借本项宣称完成。验收:本机与远程两个工作区同时打开时不串数据 | M0 | +| API-43 E/P | `documents.onWillSave`:保存前事件,可选返回编辑。现状:保存经 `DocumentFeatureModel` 的保存入口落盘,没有保存前钩子;编辑器只有手动格式化动作 [S5] | 依赖 API-10 的版本化编辑授权与 API-04 的文档事件;带保存原因(手动/全部保存/关闭前/自动保存)。保存前等待有严格截止时间,超时则放弃该插件结果并继续保存,不卡住保存与主线程;保存期间有新输入则丢弃旧结果;只读/大文件/自动保存的规则和多插件顺序、冲突在契约 PR 定义。不能用它读取磁盘或阻止保存 | M3,晚于 M2 | 文件树至少区分两件事:读取授权目录的数据,以及取得工作台 Project 视图的选中项。前者是文件 API,后者是受窗口约束的 UI 上下文;不把整套内部树模型或原生视图对象暴露出去。 @@ -119,14 +127,16 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy | ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | | --- | --- | --- | --- | -| API-29 P/C | Build/Task/Test/Run Preparation、只读工程模型输入;已有 buildProviders 1 和执行模型 [S11] | 接续 #1517 的 buildProviders 2–6、debugAdapters 2,不重复定义。原生 Build/Test/Run 展示、准备、取消共用宿主;快照带源/版本,不能成为第二份 classpath/工程真相 | M4,依赖 #1517 状态 | +| API-29 P/C | Build/Task/Test/Run Preparation、只读工程模型输入;#1517 已合并,Mac 已声明 `buildProviders:1–6`、`buildModelInputs:1`、`debugAdapters:2` [S1/S11] | 本项只补文档、SDK 对齐和真实项目端到端验收,不重复定义协议。原生 Build/Test/Run 展示、准备、取消共用宿主;快照带源/版本,不能成为第二份 classpath/工程真相;远程工作区下的 Provider 启动限制见 API-40 | M4 | | API-30 P/C/E | Debug Adapter 注册、配置和会话事件;已有 DAP 宿主 [S14] | 进程/连接授权、launch 与 attach 所有权、终止和断开区别;复用断点/调试 UI。高级内存/反汇编按独立提案,不阻塞基础 API | M4 | -| API-31 P/C | VCS 仓库/状态/diff/提交等领域能力;已有 vcsProviders 1 [S14] | 读取与修改仓库分权;危险操作有明确确认,结果有仓库/操作身份。插件私有函数不能代替宿主可理解的 VCS 协议 | M4 | +| API-31 P/C | VCS 仓库/状态/diff/提交等领域能力;已有 vcsProviders 1 [S14],只覆盖插件提供的 VCS,内置 Git 见 API-42 | 读取与修改仓库分权;危险操作有明确确认,结果有仓库/操作身份。插件私有函数不能代替宿主可理解的 VCS 协议 | M4 | | API-32 P/C/E | Services 的实例/状态/动作/日志;数据库已有自身路径,通用 ServiceProvider 仍为提案 [S17] | 插件拥有的连接/进程与用户外部服务分开;停用只清理前者,不默认停用户数据库/容器。服务接入现有 Services,不再画一套工作台 | 后续 | | API-33 P/C | 项目生成器发现、参数、预览与生成;已有 projectGenerators 1 [S14] | 写目标目录需确认,禁止路径逃逸/无提示覆盖;只生成选择的项目,不下载未声明程序。先补现有文档,再评估新增 hooks | M4 | | API-34 H/P | 受限 IDE/AI/MCP 能力;已有单独 IDE broker [S15] | 项目 opt-in 和 read/configure/execute 分权不变;不转交 broker token、全量 dispatcher 或 Agent 历史。是否开放、哪些调用方可用需独立威胁建模 | 后续 | -| API-35 H/P/C | 插件依赖、显式导出服务、远程工作区适配 [S17] | 跨插件调用需要新授权/版本/循环依赖规则;远程资源标明 authority,不能把远程路径当本机路径。首版对未实现环境明确拒绝,不静默本地执行 | 后续 | +| API-35 H/P/C | 插件依赖、显式导出服务;远程 Provider 适配的剩余部分(原生 Java 集成、Agent、SSH 调试)[S17/S20] | 跨插件调用需要新授权/版本/循环依赖规则。远程 authority 基线已移到 API-40(M0),本项只在其上扩展;未实现的环境继续明确拒绝,不静默本地执行 | 后续 | | API-36 运行时 | 可选 WASM Component/WASI 宿主;当前能力表无 WASM [S1] | 复用相同公共 API/权限;独立验证 WIT 版本、fuel/内存/时间预算、取消、沙盒可达性、签名、工具链和分发。不得预挂载整个 HOME/工作区或绕过主进程隔离 | 后续,非 M0–M4 前置 | +| API-42 H/E | 内置 Git 只读快照:`scm.getRepositories/getHead/getStatus/onDidChange` 一类候选。现状:Git 在 `LitheGitModule`(`GitFeatureModel`、`package` 级 `GitOperations`),不在统一 VCS 层;`VcsProvidersFeatureModel` 只用 Git 根判断归属,IDE broker 无 Git 工具 [S21] | 只读仓库元数据授权;经小型适配器返回仓库根 ID、当前分支/HEAD、ahead/behind 和有页大小上限的变更路径,不暴露 `GitOperations`/`GitChange` 等内部类型,不含 diff 内容、凭据或带令牌的远程 URL。暂存、提交、切分支等写操作不在本项,等统一 VCS 提案第 10 节的 Git 接入决策。事件合并限流;远程工作区按 API-40 的 authority 返回 | M3 | +| API-44 C | Agent 侧贡献:插件提供 Skill、Hook、MCP 服务器的候选清单。现状:Skills、Hooks、MCP 是用户管理的 Codex/Claude 原生配置,宿主只读取并编辑用户选定的来源,没有插件来源 [S22] | 插件只能提供可审阅的候选,不自动写入或启用;用户确认后才写入明确来源并重读结果。Hook 与 MCP 会执行命令,需单独授权、标注来源并可撤销;卸载插件不静默改动用户已导入的配置。不转交 IDE broker token 或 Agent 历史,与 API-34 一并做威胁建模 | 后续,独立安全评审 | ### 3.6 通用配套能力 @@ -146,7 +156,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy - 谁能调用:包内网页、命令处理器、原生面板数据源和领域 Provider 分别列授权矩阵。声明能力不等于已授权,隐藏菜单不等于拒绝调用。 - 谁发起、操作哪里:插件 ID/包版本、后端 session、窗口、工作区及 generation、文档版本、调用/订阅 ID 由宿主核验。ID 不是可自行拼造的授权凭证;禁止缺少上下文时回退到“当前活动窗口”。 -- 如何复用:扩展已有 JSON-RPC、函数注册、取消及进程管理。新无窗口激活入口与工作区能力单独协商;M0 在“按工作区会话”和“共享进程、隔离调用上下文”之间作出有测试依据的选择,明确身份与授权句柄寿命,不能把应用级 `backend:1` 悄悄解释为每工程一进程。 +- 如何复用:扩展已有 JSON-RPC、函数注册、取消及进程管理。新无窗口激活入口与工作区能力单独协商;M0 在“按工作区会话”和“共享进程、隔离调用上下文”之间作出有测试依据的选择,明确身份与授权句柄寿命,不能把应用级 `backend:1` 悄悄解释为每工程一进程;同时规定 authority:`backend:1` 现在只在本机运行,远程工作区的新调用要显式拒绝(API-40)。 - 哪些回调会递归:初期 `commands.execute` 只覆盖明确允许的用户交互入口;不能让 Provider 后端借它回调 `lithe/build/*`、私有函数或完整 IDE dispatcher。现有递归拒绝规则保持有效,新调用方向先证明无循环等待。 - 哪些旧行为不变:API level、`requires` 能力版本、函数协议版本和 SDK 版本分别列出。未知/未实现能力明确拒绝;不靠更新 SDK 或提升一个大版本宣称全量可用。 @@ -154,7 +164,8 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy - 分开评审工程元数据、磁盘读取、编辑器读取/修改、文件变更、执行、网络、外部打开、剪贴板和凭据。使用现有授权时明确复用关系,新增权限要解释用户如何理解、授予和撤销。 - 文件标识使用根 ID 与 `/` 分隔的工作区相对路径。平台 adapter 校验符号链接、大小写/Unicode 归一、路径穿越及检查后替换的竞态;不能仅用字符串前缀或一次 `realpath` 认定安全。 -- 工作区外选择文件使用单独授权句柄;OS 绝对路径不作为可持久化公共资源 ID。远程/虚拟资源需要独立 scheme/authority 和访问策略。 +- 工作区外选择文件使用单独授权句柄;OS 绝对路径不作为可持久化公共资源 ID。远程/虚拟资源需要独立 scheme/authority 和访问策略;Mac 的远程工作区现在是本机空占位根加路由绑定,占位根路径不能当作资源 ID(API-40)。 +- 工作区信任:应用现在没有工作区信任概念,只有逐操作确认(例如 Web bridge 启动进程前的确认)。M0 需决定是否引入信任状态;决定前,激活或命令入口不能借“已激活”绕过逐操作确认。契约 README 里“workspace trust”的措辞也要随决定校正。 - 进程分离、包签名和 capability grant 都不等于操作系统沙盒。原生后端本来可以使用自己的 OS 权限;文档必须区分“宿主代理拒绝访问”和“阻止恶意程序的一切文件/网络访问”。 - 发布 app、插件包及包内语言服务器只读。状态、日志、索引、锁和下载物走平台 storage/cache adapter;新增资源同时遵循 worktree 资源清单规则,不在此计划新增资源目录。 @@ -181,7 +192,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy | SDK | 源码已见能力 | 仍需核对/补齐 | 已核实发布物 | | --- | --- | --- | --- | -| [Node 源码](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/be779d2000e38415ea047fc2e2b2b7973e7d8e07/sdk/node/src/backend.mjs) | Build、私有 Functions v1、VCS;开发中 Build 2–6 的执行、测试、发现、Run/Debug 准备、模型输入 | 通用 host API、Functions v2/credentials 等不能因 Rust SDK 有就宣称 Node 也有;开发态 Build 扩展依赖新宿主 | [sdk-node-v0.1.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.1.0),2026-10-23;源码新功能不等于已在此包发布 | +| [Node 源码](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/be779d2000e38415ea047fc2e2b2b7973e7d8e07/sdk/node/src/backend.mjs) | Build、私有 Functions v1、VCS;开发中 Build 2–6 的执行、测试、发现、Run/Debug 准备、模型输入 | 通用 host API、Functions v2/credentials 等不能因 Rust SDK 有就宣称 Node 也有;宿主侧 Build 2–6 已随 #1517 合入 preview,但文档仓库的契约副本早于该合并,需先同步契约再对齐 SDK 与 fixtures | [sdk-node-v0.1.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.1.0),2026-10-23;源码新功能不等于已在此包发布 | | [Java 源码](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/be779d2000e38415ea047fc2e2b2b7973e7d8e07/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java) | Build、私有 Functions v1;开发中 Build 2–6 | 本次源码未见 VCS/credentials 实现,不能写成与 Node/Rust 全量对等 | [历史 sdk-java-v0.1.0](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0),2026-10-20,JAR 与 SHA-256 | | [Rust 说明](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/be779d2000e38415ea047fc2e2b2b7973e7d8e07/sdk/rust/README.md) | Functions v1/v2、credentials,独立后端示例 | 明确尚不实现 Build/VCS;文档描述 release 方案不等于该 tag 已发布 | 本次未核实到 Rust SDK 正式 release,不提供可用发布承诺 | @@ -194,22 +205,24 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy | 文档 ID | 拟交付页面/更新位置 | 必须讲清楚什么 | 对应任务/阶段 | | --- | --- | --- | --- | | DOC-01 | `README.md`、`guides/macos-plugin-quickstart.md` | 声明式/原生面板/网页/后端怎么选;安装、运行依赖、最小命令插件;Mac-only 支持范围 | API-01/02,M0–M1 | -| DOC-02 | `reference/capabilities-and-versions.md` | API level、requires、协议、SDK 与 release 四维矩阵;未知能力和兼容性错误;已支持/开发中分开 | 全部,M0 起 | +| DOC-02 | `reference/capabilities-and-versions.md` | API level、requires、协议、SDK 与 release 四维矩阵;未知能力和兼容性错误;已支持/开发中分开;激活事件集合与版本 | 全部,M0 起 | | DOC-03 | `guides/security-and-permissions.md` | 用户授权/撤销、工作区/窗口范围、路径与网络边界、原生进程非沙盒事实、密钥与日志 | 全部,M0 起 | -| DOC-04 | `reference/lifecycle-and-errors.md` | 激活条件、无窗口后端、dispose、取消/超时、失败、崩溃恢复、重试和错误表 | API-01/04/20/22,M0–M1 | +| DOC-04 | `reference/lifecycle-and-errors.md` | 激活条件与事件、启动预算、无窗口后端、dispose、取消/超时、失败、崩溃恢复、重试和错误表 | API-01/04/20/22/41,M0–M1 | | DOC-05 | `reference/commands-and-events.md` | handler/贡献/事件区别;菜单、快捷键、上下文键;事件过滤、初始快照和背压 | API-02–04,M1/M3 | | DOC-06 | `reference/workspace-and-files.md` | 目录分页/懒加载、读取/编码、watch、搜索、授权资源句柄、文件修改 | API-05–08/11,M3 | -| DOC-07 | `reference/documents-and-editor.md` | dirty 与磁盘、版本/范围/UTF-16、选区、单次 undo、安全保存、diff/只读/多文件限制 | API-09–12/15,M1–M3 | +| DOC-07 | `reference/documents-and-editor.md` | dirty 与磁盘、版本/范围/UTF-16、选区、单次 undo、安全保存、保存前事件与超时、diff/只读/多文件限制 | API-09–12/15/43,M1–M3 | | DOC-08 | `guides/native-ui-and-webviews.md` | 原生 Tree/List/Form 与包内网页的选择;数据源、动作、页面消息、恢复/释放、限制 | API-13/14,M3 | | DOC-09 | `guides/browser-and-preview.md` | 编辑器/内嵌 HTML/自有网页/外部浏览器四分法;未保存内容、网络及 bridge 隔离 | API-15/16,M3 | | DOC-10 | `reference/settings-storage-and-secrets.md` | 真实工具配置与偏好区别、scope、Apply/冲突、状态迁移/卸载、凭据授权 | API-19–21,M1/M3 | | DOC-11 | `reference/window-output-and-execution.md` | 通知/选择/进度/输出、剪贴板、toolchain/process/terminal 权限和所有权 | API-17/18/22–24,随对应阶段 | -| DOC-12 | 更新 `guides/lsp-plugin-lifecycle.md`、`guides/build-tool-integration.md`、`guides/native-build-workflows.md`、`guides/debug-adapter-plugin.md`;补 VCS/项目生成器参考 | 领域 Provider 接入原生工作流、协议最低版本、#1517 合并状态、上游复用和端到端链路 | API-25/26/29–31/33,M4 | -| DOC-13 | `reference/optional-contributions.md` 与独立提案链接 | 主题/片段/装饰/自定义编辑器/Services/依赖/远程/AI/WASM;未实现项标为提案,不能混入稳定 API 表 | API-27/28/32/34–36,后续 | +| DOC-12 | 更新 `guides/lsp-plugin-lifecycle.md`、`guides/build-tool-integration.md`、`guides/native-build-workflows.md`、`guides/debug-adapter-plugin.md`;补 VCS/项目生成器参考 | 领域 Provider 接入原生工作流、已合并的 buildProviders 2–6/debugAdapters 2 协议最低版本、远程工作区下的启动限制、上游复用和端到端链路 | API-25/26/29–31/33,M4 | +| DOC-13 | `reference/optional-contributions.md` 与独立提案链接 | 主题/片段/装饰/自定义编辑器/Services/依赖/远程/AI/Agent 侧贡献/WASM;未实现项标为提案,不能混入稳定 API 表 | API-27/28/32/34–36/44,后续 | | DOC-14 | 各 `sdk/*/README.md`、类型/API 文档、`guides/plugin-development-and-debugging.md` | 脚手架、打包校验、development 安装、日志/断点、错误定位、锁版本与 SHA-256、签名/上架、迁移/弃用 | 每个可交付切片 | | DOC-15 | `examples/` 或现有各 SDK examples 目录 | 选区转换、文件树、HTML 预览三例;从干净目录仅靠已发布 SDK/文档即可构建,不能偷偷依赖主仓库源码 | M2–M3 | | DOC-16 | `reference/environment-and-localization.md` | 必要环境信息、能力发现与插件语言资源、回退/切语言事件;禁止导出完整环境变量 | API-37,随对应阶段 | | DOC-17 | `reference/network-and-authentication.md` | 受管联网/下载与原生进程边界,账号/权限 scope、用户同意、撤销、密钥不继承 | API-38/39,后续 | +| DOC-18 | `reference/workspace-authority-and-remote.md` | 工作区 authority 模型;远程占位根不是本机路径;未实现 authority 的拒绝错误;`backend:1` 与网页 bridge 的本机限定;已适配与未适配的远程范围 | API-35/40,M0 | +| DOC-19 | `reference/source-control-state.md` | 插件 VCS Provider 与内置 Git 只读快照的区别;字段、分页、事件合并;写操作不在范围 | API-31/42,M3–M4 | 每个正式 API 页统一包含:稳定 ID/方法签名,调用方向,参数/结果/示例,最低宿主能力与 SDK 发布版本,权限与授权方式,窗口/工作区/插件作用域,dirty/路径/编码/位置语义,取消与截止时间,事件/释放,限制/分页,稳定错误,安全注意事项,以及成功、拒绝、旧版本和失效结果的测试链接。没有实现与发布证据的候选名不得出现在“可用 API”导航中。 @@ -227,16 +240,16 @@ Mac 当前声明的能力版本为:`backend:1`、`buildProviders:1`、`buildSy | 阶段 | 建议切片 | 最小交付与退出条件 | | --- | --- | --- | -| M0 | 作用域/授权威胁模型;无网页激活和能力协商 | API-01/05/20 的必要部分;旧 backend/UI/Provider fixture 不变,新增调用方向和身份隔离有正反用例;DOC-01–04 有可评审草稿 | -| M1 | 独立命令;只读文档与选区;最小事件/通知/进度 | 一个不声明假窗口、假语言、假构建系统的插件能通过命令入口读取当前未保存选区并显示结果;换窗口/关闭工作区/撤权后旧上下文无效;Node SDK 有类型和示例 | +| M0 | 作用域/授权威胁模型、工作区 authority;无网页激活、激活事件和能力协商 | API-01/05/20/40/41 的必要部分;旧 backend/UI/Provider fixture 不变,新增调用方向和身份隔离有正反用例;未实现 authority(远程占位根、`backend:1`、bridge)对新 API 明确拒绝而非本机执行;已决定是否引入工作区信任;DOC-01–04/18 有可评审草稿 | +| M1 | 独立命令(含命令激活事件);只读文档与选区;最小事件/通知/进度 | 一个不声明假窗口、假语言、假构建系统的插件能通过命令入口读取当前未保存选区并显示结果;换窗口/关闭工作区/撤权后旧上下文无效;Node SDK 有类型和示例 | | M2 | 安全单文档编辑;预览与一次撤销 | 选区转换例完成“读取版本 → 计算 → 校验版本 → 编辑 → Undo”;用户同时输入时拒绝旧结果。无权限/只读/IME/多选区/Unicode 场景通过;不包含通用多文件事务 | | M3a | 目录/搜索/watch;通用原生 Tree 数据源 | 分页文件浏览器不借 BuildProvider 启动;展开按需加载,刷新可取消,关闭/重开有明确状态规则;巨型树和符号链接边界有预算测试 | | M3b | 打开/HTML 预览/系统浏览器;网页与对话框边界 | 三种打开行为按目标与 dirty 状态可预测,工程 HTML 无法调用插件桥;原有包内页面功能不回归 | -| M3c | 设置/状态/独立输出;菜单/表单/事件完善 | 真实配置 Apply/冲突/重读可验收;订阅与页面反复启停无泄漏;公共 UI 保持 Mac 原生控件及可访问性 | -| M4 | #1517 合并后重盘点,逐个完成 Build/Run/Test/Debug/VCS/语言/生成器文档与缺口 | 不重写在途 Provider 协议;真实项目完成 save → 同步模型 → build → run/test/debug → cancel/cleanup;未合并能力不作为稳定前提 | -| 后续 | 主题/自定义编辑器/Services/终端/跨插件/远程/AI/WASM、完整语言包/网络/账号各自立项 | 有实际插件需求、资源预算、许可/分发与风险证据后再进入实施;不是 M1–M3 发布门槛 | +| M3c | 设置/状态/独立输出;菜单/表单/事件完善;内置 Git 只读快照、保存前事件 | 真实配置 Apply/冲突/重读可验收;订阅与页面反复启停无泄漏;公共 UI 保持 Mac 原生控件及可访问性;保存前事件超时不阻塞保存,Git 快照不泄漏凭据且不暴露内部类型(API-42/43 依赖 M2 的编辑与 M1 的事件) | +| M4 | 在 #1517 已合并的协议上,逐个完成 Build/Run/Test/Debug/VCS/语言/生成器文档与缺口 | 不重写已合并的 Provider 协议;真实项目完成 save → 同步模型 → build → run/test/debug → cancel/cleanup;远程工作区只承诺 #1515 已适配范围,其余按 API-40 明确拒绝 | +| 后续 | 主题/自定义编辑器/Services/终端/跨插件/远程剩余适配/AI/Agent 侧贡献/WASM、完整语言包/网络/账号各自立项 | 有实际插件需求、资源预算、许可/分发与风险证据后再进入实施;不是 M1–M3 发布门槛 | -M1–M3 与 #1517 的领域扩展可独立推进;若修改同一协议/入口,需要按其最终合并版本协调,不能复制一份 Provider manager。领域迁移另由[Maven/Java 迁移提案](../../.agents/notes/proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md)跟踪,其特定版本匹配策略不能不加区分地变成整个公共 API 的兼容政策。 +#1517、#1515 已合并,M1–M3 与领域扩展可独立推进;若修改同一协议/入口,以 preview 当前代码为准协调,不能复制一份 Provider manager,也不能绕开 `RemoteWorkspaceRegistry` 的路由。领域迁移另由[Maven/Java 迁移提案](../../.agents/notes/proposed/architecture/2026-10-26-maven-java-domain-plugin-migration.md)跟踪,其特定版本匹配策略不能不加区分地变成整个公共 API 的兼容政策。 ## 7. Mac 验收样例与发布检查 @@ -264,7 +277,7 @@ M1–M3 与 #1517 的领域扩展可独立推进;若修改同一协议/入口 | 索引 | 所有者与证据 | | --- | --- | -| S1 | [插件契约](../../shared/contracts/plugins/v1/README.md)、[Core 能力](../../rust/lithe-core/src/plugins/capabilities.rs)、[清单校验](../../rust/lithe-core/src/plugins/declarative.rs)、[Mac 能力表](../../macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift) | +| S1 | [插件契约](../../shared/contracts/plugins/v1/README.md)、[Core 能力](../../rust/lithe-core/src/plugins/capabilities.rs)、[清单校验与激活事件](../../rust/lithe-core/src/plugins/declarative.rs)、[清单 schema](../../shared/contracts/plugins/v1/plugin-manifest.schema.json)、[Mac 能力表](../../macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift) | | S2 | [backend 1](../../rust/lithe-core/src/plugins/backend.rs)、[函数协议](../../rust/lithe-core/src/lsp/interface/plugin_function.rs)、[Mac 后端会话](../../macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift)、[宿主凭据服务](../../macos/Sources/Lithe/Application/Features/PluginHostServices.swift) | | S3 | [原生 UI 功能](../../macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift)、[Core UI 校验](../../rust/lithe-core/src/plugins/native_ui.rs)、[已实现原生 UI Note](../../.agents/notes/implemented/feature/2026-10-21-declarative-native-plugin-ui.md) | | S4 | [Web bridge](../../macos/Sources/Lithe/Application/Features/PluginWebBridge.swift)、[包内内容](../../macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebContent.swift)、[页面生命周期](../../macos/Sources/Lithe/Application/Features/PluginWebPageLifecycle.swift)、[状态存储](../../macos/Sources/Lithe/Platform/MacOS/Plugins/Web/MacPluginWebStateStore.swift) | @@ -283,3 +296,6 @@ M1–M3 与 #1517 的领域扩展可独立推进;若修改同一协议/入口 | S17 | [Services/公共扩展提案](../../.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md)、[数据库后端](../../.agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md)、[依赖与导出提案](../../.agents/notes/proposed/architecture/2026-10-25-plugin-dependencies-and-exports.md) | | S18 | [有限热激活范围](../../.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md)、[既有函数调用方向](../../.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md) | | S19 | [应用语言](../../macos/Sources/LitheModuleAPI/Localization/InterfaceLanguage.swift)、[GitHub HTTP adapter](../../macos/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubHTTPTransport.swift)、[GitHub 服务](../../macos/Sources/Lithe/Services/GitHub/GitHubService.swift)、[Agent 登录端口](../../macos/Sources/Lithe/Core/Ports/AgentAccountSignIn.swift) | +| S20 | [远程工作区路由 Note](../../.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md)、[占位根](../../macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteWorkspaceRoots.swift)、[绑定与路由](../../macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift)、[远程插件启动](../../macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift)、[远程主机提案](../../.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md)、[SSH 调试提案](../../.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md) | +| S21 | [Git 功能](../../macos/Sources/LitheGitModule/Application/GitFeatureModel.swift)、[Git 服务端口](../../macos/Sources/LitheGitModule/Services/GitService.swift)、[统一 VCS 提案](../../.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md) | +| S22 | [Skills](../../macos/Sources/Lithe/Application/Features/AgentSkillsFeatureModel.swift)、[Hooks](../../macos/Sources/Lithe/Application/Features/AgentHooksFeatureModel.swift)、[MCP 注册](../../macos/Sources/Lithe/Application/Features/McpRegistryFeatureModel.swift)、[MCP 服务器库](../../macos/Sources/Lithe/Application/Features/McpServerLibraryFeatureModel.swift)、[Skill 管理 Note](../../.agents/notes/implemented/feature/2026-10-20-agent-skill-management.md)、[Hooks Note](../../.agents/notes/implemented/feature/2026-10-24-agent-hooks.md)、[MCP 服务器库 Note](../../.agents/notes/implemented/feature/2026-10-17-mcp-server-library.md) | From a87dcd66246c6eaf8287616373053d94dd0ebbbe Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 00:41:08 +0800 Subject: [PATCH 02/16] =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=90=8E=E7=AB=AF=202?= =?UTF-8?q?=20=E4=B8=8E=E5=87=BD=E6=95=B0=E5=8D=8F=E8=AE=AE=203=EF=BC=9A?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E4=B8=8A=E4=B8=8B=E6=96=87=E3=80=81?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=8F=91=E7=8E=B0=E4=B8=8E=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requires.backend 新增版本 2:可由 onWorkspaceOpen 或插件命令启动,不再必须有网页;后端可以是插件唯一内容;backend 1 语义不变 - 新权限 workspace.metadata(仅 backend 2)授予 workspace 宿主服务;拒绝 onStartupFinished 等常驻启动事件 - 插件函数协议新增版本 3:lithe/host/getCapabilities 由 Core 直接回答,subscriptions/dispose,workspace/listContexts、getContext、getFolders - 宿主方法改为表驱动(HOST_METHODS),后续文件、编辑器、窗口服务按行接入 - lsp.functionRequest 可携带 context,仅 v3 后端收到 params.context - lsp.hostResponse.error 可带 code,按白名单映射为 contextExpired、authorityUnsupported、versionConflict、limitExceeded、cancelled、timeout 等 - 后端可用 $/cancelRequest 取消自己的宿主请求,Core 立即回复 cancelled 并发出 hostRequestCancelled 事件 - 同步契约 README、manifest schema、正反例 fixture 与 Rust 单元测试 --- rust/lithe-core/src/lsp/interface/client.rs | 33 +- rust/lithe-core/src/lsp/interface/engine.rs | 271 ++++++++++- .../src/lsp/interface/plugin_function.rs | 448 ++++++++++++++++-- rust/lithe-core/src/plugins/backend.rs | 73 ++- rust/lithe-core/src/plugins/capabilities.rs | 8 +- rust/lithe-core/src/plugins/declarative.rs | 63 ++- .../src/tests/declarative_plugins.rs | 48 ++ shared/contracts/plugins/v1/README.md | 159 ++++++- .../v1/fixtures/backend-workspace.plugin.json | 34 ++ .../v1/fixtures/function-protocol.json | 197 +++++++- .../v1/fixtures/invalid-manifests.json | 38 +- .../plugins/v1/plugin-manifest.schema.json | 14 +- 12 files changed, 1275 insertions(+), 111 deletions(-) create mode 100644 shared/contracts/plugins/v1/fixtures/backend-workspace.plugin.json diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 3e118affc6..334c3ffde6 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -565,20 +565,25 @@ pub(super) fn client_apply_server_message_for_plugin( ) { // The engine answers once the host has; the request // carries the backend's own ID back to it. - Ok(service) => events.push(LspClientEvent { - kind: "hostRequest".to_string(), - request_id: None, - method: Some(method.to_string()), - uri: None, - version: None, - diagnostics: None, - result: Some(json!({ - "id": id, - "service": service, - "params": message.get("params").cloned().unwrap_or(Value::Null), - })), - error: None, - }), + Ok(plugin_function::HostRoute::Answer(result)) => { + responses.push(json_rpc_result(id, result)?) + } + Ok(plugin_function::HostRoute::Forward(service)) => { + events.push(LspClientEvent { + kind: "hostRequest".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ + "id": id, + "service": service, + "params": message.get("params").cloned().unwrap_or(Value::Null), + })), + error: None, + }) + } Err(refusal) => responses.push(json_rpc_error_with_code( id, refusal.rpc_code, diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 1989c25f27..ccf7b18ca7 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -447,7 +447,9 @@ pub struct PluginHostResponse { #[serde(default)] pub result: Option, /// The host could not perform the service. Its message reaches the - /// backend, bounded; the code is always `plugin.host.failed`. + /// backend, bounded. Its code passes only when it is one a host may + /// report (`plugin_function::host_failure`); otherwise, and before + /// protocol version 3, it is `plugin.host.failed`. #[serde(default)] pub error: Option, } @@ -457,6 +459,9 @@ pub struct PluginHostResponse { /// Why the host could not perform a forwarded host-service request. pub struct PluginHostFailure { pub message: String, + /// Stable `plugin.host.*` code of the failure, from protocol version 3. + #[serde(default)] + pub code: Option, } #[derive(Debug, Clone, Deserialize)] @@ -476,6 +481,11 @@ pub struct PluginFunctionRequest { /// session's request timeout when absent. #[serde(default)] pub timeout_milliseconds: Option, + /// Workspace context `{ contextID, generation }` the call belongs to. + /// It reaches a backend that negotiated protocol version 3 or later as + /// `params.context`, and is dropped for an older backend. + #[serde(default)] + pub context: Option, } fn empty_object() -> Value { @@ -2341,6 +2351,12 @@ impl RuntimeSession { &wire_operation_id, &request.params, )?; + let mut params = params; + super::plugin_function::attach_context( + &mut params, + state.client.plugin_function_version.unwrap_or(0), + request.context.as_ref(), + )?; let timeout = request .timeout_milliseconds .map(|milliseconds| { @@ -2402,12 +2418,20 @@ impl RuntimeSession { ) })?; match (response.result, response.error) { - (_, Some(failure)) => json_rpc_error_with_code( - &backend_id, - -32603, - &super::plugin_function::bounded_host_message(&failure.message), - super::plugin_function::codes::HOST_FAILED, - )?, + (_, Some(failure)) => { + let version = state.client.plugin_function_version.unwrap_or(0); + let (code, rpc_code) = if version >= super::plugin_function::CONTEXT_VERSION { + super::plugin_function::host_failure(failure.code.as_deref()) + } else { + (super::plugin_function::codes::HOST_FAILED, -32603) + }; + json_rpc_error_with_code( + &backend_id, + rpc_code, + &super::plugin_function::bounded_host_message(&failure.message), + code, + )? + } (result, None) => json_rpc_result(&backend_id, result.unwrap_or(Value::Null))?, } }; @@ -3942,6 +3966,17 @@ impl RuntimeSession { event.diagnostics.unwrap_or_default(), ); } + } else if event.kind == "notification" + && self.plugin_id.is_some() + && event.method.as_deref() == Some("$/cancelRequest") + && cancel_host_request_locked( + self, + &mut state, + event.result.as_ref(), + &mut outbound, + )? + { + // The backend withdrew one of its own host requests. } else if event.kind == "notification" && !(suppress_structured_java_preparation_notification && is_structured_import_notification( @@ -5016,10 +5051,9 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> // know could never be forwarded. if !request.host_services.is_empty() && (request.plugin_id.is_none() - || request - .host_services - .iter() - .any(|service| service != super::plugin_function::HOST_SERVICE_CREDENTIALS)) + || request.host_services.iter().any(|service| { + !super::plugin_function::GRANTABLE_HOST_SERVICES.contains(&service.as_str()) + })) { return Err(invalid_field("hostServices")); } @@ -5780,6 +5814,72 @@ fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, r ); } +/// Handles a backend's `$/cancelRequest` for one of its own pending host +/// requests: the backend gets `plugin.host.cancelled` at once, so the +/// pending slot frees without waiting for the host, and the host gets a +/// `hostRequestCancelled` event naming the `hostRequestID` so it can stop +/// the work. A later answer from the host for that ID is refused like any +/// unknown ID. Returns false when the notification names no pending host +/// request, so it is handled like any other notification. +fn cancel_host_request_locked( + session: &RuntimeSession, + state: &mut SessionState, + params: Option<&Value>, + outbound: &mut Vec, +) -> Result { + if state.client.plugin_function_version.unwrap_or(0) < super::plugin_function::CONTEXT_VERSION { + return Ok(false); + } + let Some(backend_id) = params.and_then(|params| params.get("id")) else { + return Ok(false); + }; + let Some(host_request_id) = state + .host_requests + .iter() + .find(|(_, pending)| *pending == backend_id) + .map(|(host_request_id, _)| host_request_id.clone()) + else { + return Ok(false); + }; + let backend_id = state + .host_requests + .remove(&host_request_id) + .unwrap_or(Value::Null); + outbound.push(json_rpc_error_with_code( + &backend_id, + -32800, + "The plugin process cancelled this host-service request.", + super::plugin_function::codes::HOST_CANCELLED, + )?); + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "hostRequestCancelled".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ "hostRequestID": host_request_id })), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); + Ok(true) +} + /// Publishes the session's registered functions after a registry change. /// Publishes a backend's well-formed host-service request as `hostRequest`, /// or answers it at once when the session already has the most requests the @@ -5792,11 +5892,16 @@ fn forward_host_request_locked( ) -> Result, CoreError> { let backend_id = request.get("id").cloned().unwrap_or(Value::Null); if state.host_requests.len() >= super::plugin_function::MAX_PENDING_HOST_REQUESTS { + let version = state.client.plugin_function_version.unwrap_or(0); return Ok(vec![json_rpc_error_with_code( &backend_id, -32603, "Too many host-service requests are waiting for the host.", - super::plugin_function::codes::HOST_FAILED, + if version >= super::plugin_function::CONTEXT_VERSION { + super::plugin_function::codes::HOST_LIMIT_EXCEEDED + } else { + super::plugin_function::codes::HOST_FAILED + }, )?]); } let host_request_id = format!("host-{}", state.next_host_request_id); @@ -12104,6 +12209,7 @@ public class Main { }, params: json!({ "project": "app" }), timeout_milliseconds, + context: None, }, operation_id.clone(), ) @@ -12209,7 +12315,7 @@ public class Main { initialize["params"]["capabilities"]["experimental"], json!({ "litheBuild": { "versions": [6, 5, 4, 3, 2, 1] }, - "litheFunctions": { "versions": [1, 2] }, + "litheFunctions": { "versions": [1, 2, 3] }, "litheVcs": { "versions": [1] } }) ); @@ -12991,7 +13097,10 @@ public class Main { wire["result"] = result; } if let Some(message) = error { - wire["error"] = json!({ "message": message }); + wire["error"] = match message.split_once('|') { + Some((code, message)) => json!({ "message": message, "code": code }), + None => json!({ "message": message }), + }; } let response: PluginHostResponse = serde_json::from_value(wire).expect("documented wire shape"); @@ -13098,4 +13207,138 @@ public class Main { limit ); } + + /// From version 3 Core answers capability discovery itself, listing + /// only the session's grants; the host never sees the request. + #[test] + fn capability_discovery_is_answered_by_core() { + let mut harness = host_service_backend(3, &["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "caps", + "method": "lithe/host/getCapabilities" + })); + let reply = await_reply(&harness, "caps"); + assert_eq!(reply["result"]["protocolVersion"], 3); + assert_eq!(reply["result"]["backendAuthority"], "local"); + assert!(reply["result"]["services"]["workspace"].is_object()); + assert!(reply["result"]["services"]["credentials"].is_null()); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); + } + + /// From version 3 a host failure's code reaches the backend when it is + /// one a host may report; an unknown code is `plugin.host.failed`. + #[test] + fn typed_host_failures_reach_a_version_3_backend() { + let mut harness = host_service_backend(3, &["workspace"]); + for (id, code, expected, rpc_code) in [ + ( + "a", + "plugin.host.contextExpired", + "plugin.host.contextExpired", + -32603, + ), + ("b", "plugin.function.unknown", "plugin.host.failed", -32603), + ] { + harness.server.send(json!({ + "jsonrpc": "2.0", "id": id, + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w1", "generation": 1 } } + })); + let event = harness + .await_event(|event| event.kind == "hostRequest") + .clone(); + let host_request_id = event.result.unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + harness.events.clear(); + host_response( + &harness, + &host_request_id, + None, + Some(&format!("{code}|The workspace closed.")), + ) + .expect("the host reports its failure"); + let reply = await_reply(&harness, id); + assert_eq!(reply["error"]["data"]["code"], expected, "{code}"); + assert_eq!(reply["error"]["code"], rpc_code, "{code}"); + } + } + + /// A backend that cancels its own host request is answered at once, the + /// host learns which request to stop, and its late answer is refused. + #[test] + fn a_backend_can_cancel_its_host_request() { + let mut harness = host_service_backend(3, &["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "list", + "method": "lithe/host/workspace/listContexts" + })); + let host_request_id = harness + .await_event(|event| event.kind == "hostRequest") + .clone() + .result + .unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + harness.server.send(json!({ + "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": "list" } + })); + let cancelled = harness + .await_event(|event| event.kind == "hostRequestCancelled") + .clone(); + assert_eq!( + cancelled.result.unwrap()["hostRequestID"], + json!(host_request_id) + ); + let reply = await_reply(&harness, "list"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.cancelled"); + assert!(host_response(&harness, &host_request_id, Some(json!({})), None).is_err()); + } + + /// A call's workspace context reaches a version 3 backend as + /// `params.context`, and a malformed reference never leaves Core. + #[test] + fn invocations_carry_the_workspace_context_from_version_3() { + let harness = plugin_backend(json!({ + "litheFunctions": { "version": 3, "functions": [{ "id": "snapshot.refresh" }] } + })); + let call = |context: Value| { + let operation_id = harness.engine.next_operation_id(); + harness.session().function_request( + PluginFunctionRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + function: super::super::plugin_function::FunctionReference { + plugin_id: FUNCTION_PLUGIN.to_string(), + function_id: "snapshot.refresh".to_string(), + }, + params: json!({}), + timeout_milliseconds: None, + context: Some(context), + }, + operation_id, + ) + }; + let error = call(json!({ "contextID": "w/1", "generation": 1 })) + .expect_err("a malformed context is refused"); + assert_eq!( + error.details.as_deref(), + Some("plugin.function.invalidParams") + ); + call(json!({ "contextID": "w1", "generation": 2 })).expect("sent"); + let request_id = harness + .server + .await_request("lithe/function/invoke") + .expect("the call should reach the backend"); + assert_eq!( + harness.written_request(&request_id)["params"]["context"], + json!({ "contextID": "w1", "generation": 2 }) + ); + } } diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index e10f0f8960..7bbdd84c91 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -22,9 +22,15 @@ const EXPERIMENTAL_KEY: &str = "litheFunctions"; /// Plugin-function protocol versions this client speaks. Version 2 keeps /// every rule of version 1 and adds the host services of `host_request`. -const SUPPORTED_VERSIONS: [u64; 2] = [1, 2]; +/// Version 3 keeps version 2 and adds `lithe/host/getCapabilities`, +/// subscription disposal, workspace contexts, a workspace context on +/// invocations, and typed host failure codes. +const SUPPORTED_VERSIONS: [u64; 3] = [1, 2, 3]; /// First protocol version that offers host services. const HOST_SERVICES_VERSION: u64 = 2; +/// First protocol version whose invocations may carry a workspace context +/// and whose host offers capability discovery and workspace contexts. +pub(crate) const CONTEXT_VERSION: u64 = 3; /// Feature name recorded in the session's server capabilities once a backend /// answered a supported version with a valid function list. @@ -54,9 +60,28 @@ pub(crate) const MAX_FUNCTION_TIMEOUT_MS: u64 = 60 * 60_000; /// Host service that keeps secrets for the plugin, private to its ID. pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; +/// Host service listing open workspace contexts and their folders, granted +/// by `workspace.metadata` to a `backend` 2 process. +pub(crate) const HOST_SERVICE_WORKSPACE: &str = "workspace"; +/// Capability discovery. Every plugin-owned session of version 3 has it; +/// Core answers it without involving the host. +const HOST_SERVICE_HOST: &str = "host"; +/// Ending subscriptions other services created. Every plugin-owned session +/// of version 3 has it, because only the session that holds a subscription +/// can name it. +const HOST_SERVICE_SUBSCRIPTIONS: &str = "subscriptions"; +/// Services a launch plan may grant through `lsp.startServer.hostServices`. +/// The implicit `host` and `subscriptions` services are never listed. +pub(crate) const GRANTABLE_HOST_SERVICES: &[&str] = + &[HOST_SERVICE_CREDENTIALS, HOST_SERVICE_WORKSPACE]; const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; +const GET_CAPABILITIES: &str = "lithe/host/getCapabilities"; +const SUBSCRIPTIONS_DISPOSE: &str = "lithe/host/subscriptions/dispose"; +const WORKSPACE_LIST_CONTEXTS: &str = "lithe/host/workspace/listContexts"; +const WORKSPACE_GET_CONTEXT: &str = "lithe/host/workspace/getContext"; +const WORKSPACE_GET_FOLDERS: &str = "lithe/host/workspace/getFolders"; const MAX_CREDENTIAL_KEY_BYTES: usize = 128; /// Bound of one stored secret, in UTF-8 bytes; enough for a password, a /// token, or a private key, not for documents. @@ -100,6 +125,24 @@ pub(crate) mod codes { /// The host could not perform the service, or too many requests of /// the session were already waiting for it. pub const HOST_FAILED: &str = "plugin.host.failed"; + /// The request names a workspace context that was closed or replaced + /// by a newer generation. + pub const HOST_CONTEXT_EXPIRED: &str = "plugin.host.contextExpired"; + /// The context's authority (for example a remote workspace) does not + /// implement the requested file or process operation. The host never + /// falls back to the local placeholder directory. + pub const HOST_AUTHORITY_UNSUPPORTED: &str = "plugin.host.authorityUnsupported"; + /// A versioned write found a newer version than the one it names. + pub const HOST_VERSION_CONFLICT: &str = "plugin.host.versionConflict"; + /// The request exceeds a host limit: size, count, or rate. + pub const HOST_LIMIT_EXCEEDED: &str = "plugin.host.limitExceeded"; + /// The backend cancelled the request, or the host stopped it. + pub const HOST_CANCELLED: &str = "plugin.host.cancelled"; + /// The host's own deadline for the operation passed. + pub const HOST_TIMEOUT: &str = "plugin.host.timeout"; + /// A page's call or a host operation ended because the backend process + /// exited. Reported by hosts to pages, never sent to a backend. + pub const BACKEND_EXITED: &str = "plugin.backend.exited"; } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -346,7 +389,108 @@ pub(crate) struct HostRequestRefusal { pub message: &'static str, } -/// The host service a backend request asks for, or why it is refused. +/// Where an accepted `lithe/host/*` request goes. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum HostRoute { + /// To the host, as a `hostRequest` event of this service. + Forward(&'static str), + /// Answered by Core with this result; the host never sees the request. + Answer(Value), +} + +/// How Core checks the params of one host method before the host sees them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HostParams { + /// Absent, `null`, or `{}`. + Empty, + /// `{ key }`, or `{ key, secret }` when `with_secret`. + Credential { with_secret: bool }, + /// `{ subscriptionID }`. + Subscription, + /// Exactly `{ context }`, a well-formed context reference. + Context, + /// Any object whose `context`, when present, is a well-formed + /// reference. The service checks the rest and answers + /// `plugin.host.invalidParams` itself; for methods whose params Core + /// cannot type without owning their domain. + // No row of version 3 uses it yet; the file, editor, and window services + // that follow add their rows with it. + #[allow(dead_code)] + Object, +} + +/// One row of the host-method table. +pub(crate) struct HostMethod { + pub method: &'static str, + /// Service whose grant admits the method. + pub service: &'static str, + /// First protocol version that defines the method. + pub since: u64, + pub params: HostParams, +} + +/// Every backend-to-host method Core admits. A method missing here is +/// `plugin.host.unavailable` whatever the host could do, so a new host +/// service adds its rows here, its grant to `GRANTABLE_HOST_SERVICES` and +/// the manifest permission, and its handler to the host's dispatcher. +pub(crate) const HOST_METHODS: &[HostMethod] = &[ + HostMethod { + method: CREDENTIALS_GET, + service: HOST_SERVICE_CREDENTIALS, + since: 2, + params: HostParams::Credential { with_secret: false }, + }, + HostMethod { + method: CREDENTIALS_SET, + service: HOST_SERVICE_CREDENTIALS, + since: 2, + params: HostParams::Credential { with_secret: true }, + }, + HostMethod { + method: CREDENTIALS_DELETE, + service: HOST_SERVICE_CREDENTIALS, + since: 2, + params: HostParams::Credential { with_secret: false }, + }, + HostMethod { + method: GET_CAPABILITIES, + service: HOST_SERVICE_HOST, + since: CONTEXT_VERSION, + params: HostParams::Empty, + }, + HostMethod { + method: SUBSCRIPTIONS_DISPOSE, + service: HOST_SERVICE_SUBSCRIPTIONS, + since: CONTEXT_VERSION, + params: HostParams::Subscription, + }, + HostMethod { + method: WORKSPACE_LIST_CONTEXTS, + service: HOST_SERVICE_WORKSPACE, + since: CONTEXT_VERSION, + params: HostParams::Empty, + }, + HostMethod { + method: WORKSPACE_GET_CONTEXT, + service: HOST_SERVICE_WORKSPACE, + since: CONTEXT_VERSION, + params: HostParams::Context, + }, + HostMethod { + method: WORKSPACE_GET_FOLDERS, + service: HOST_SERVICE_WORKSPACE, + since: CONTEXT_VERSION, + params: HostParams::Context, + }, +]; + +/// Services every plugin-owned session of their version has without a +/// permission: they reveal nothing beyond the session's own grants. +fn is_implicit_service(service: &str) -> bool { + service == HOST_SERVICE_HOST || service == HOST_SERVICE_SUBSCRIPTIONS +} + +/// Where a backend request for a host service goes, or why it is refused. /// `version` is the negotiated protocol version (0 when not negotiated) and /// `granted` the services the plugin's permissions grant the session. The /// params are checked here, so the host only ever sees a well-formed @@ -356,65 +500,205 @@ pub(crate) fn host_request( params: Option<&Value>, version: u64, granted: &BTreeSet, -) -> Result<&'static str, HostRequestRefusal> { - let unavailable = HostRequestRefusal { - rpc_code: -32601, - code: codes::HOST_UNAVAILABLE, - message: "The host offers no such service to this plugin process.", - }; - if version < HOST_SERVICES_VERSION { - return Err(unavailable); - } - let (service, with_secret) = match method { - CREDENTIALS_GET | CREDENTIALS_DELETE => (HOST_SERVICE_CREDENTIALS, false), - CREDENTIALS_SET => (HOST_SERVICE_CREDENTIALS, true), - _ => return Err(unavailable), +) -> Result { + let Some(entry) = HOST_METHODS + .iter() + .find(|entry| entry.method == method && version >= entry.since.max(HOST_SERVICES_VERSION)) + else { + return Err(HostRequestRefusal { + rpc_code: -32601, + code: codes::HOST_UNAVAILABLE, + message: "The host offers no such service to this plugin process.", + }); }; - if !granted.contains(service) { + if !is_implicit_service(entry.service) && !granted.contains(entry.service) { return Err(HostRequestRefusal { rpc_code: -32601, code: codes::HOST_PERMISSION_DENIED, message: "The plugin does not declare the permission for this host service.", }); } + check_host_params(entry.params, params)?; + if entry.method == GET_CAPABILITIES { + return Ok(HostRoute::Answer(capabilities_answer(version, granted))); + } + Ok(HostRoute::Forward(entry.service)) +} + +/// The answer to `lithe/host/getCapabilities`: the negotiated version, the +/// services and methods this session may call, where the backend runs, and +/// the bounds Core enforces. It lists only what the session is granted, so +/// it reveals nothing about other plugins. +pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> Value { + let mut services = serde_json::Map::new(); + for entry in HOST_METHODS.iter().filter(|entry| { + version >= entry.since + && (is_implicit_service(entry.service) || granted.contains(entry.service)) + }) { + let service = services + .entry(entry.service.to_string()) + .or_insert_with(|| json!({ "methods": [] })); + if let Some(methods) = service["methods"].as_array_mut() { + methods.push(json!(entry.method)); + } + } + json!({ + "protocolVersion": version, + // The host starts every plugin backend on the machine that runs + // Lithe, also for remote workspaces. + "backendAuthority": "local", + "services": services, + "limits": { + "pendingHostRequests": MAX_PENDING_HOST_REQUESTS, + "functionResultBytes": MAX_RESULT_BYTES, + "messageBytes": MAX_MESSAGE_BYTES, + }, + }) +} + +fn check_host_params(rule: HostParams, params: Option<&Value>) -> Result<(), HostRequestRefusal> { let invalid = |message| HostRequestRefusal { rpc_code: -32602, code: codes::HOST_INVALID_PARAMS, message, }; - let params = params - .and_then(Value::as_object) - .ok_or(invalid("Params must be an object."))?; - let allowed: &[&str] = if with_secret { - &["key", "secret"] - } else { - &["key"] + let empty = serde_json::Map::new(); + let params = match params { + None | Some(Value::Null) if rule == HostParams::Empty => &empty, + Some(Value::Object(params)) => params, + _ => return Err(invalid("Params must be an object.")), + }; + let allowed: &[&str] = match rule { + HostParams::Empty => &[], + HostParams::Credential { with_secret: true } => &["key", "secret"], + HostParams::Credential { with_secret: false } => &["key"], + HostParams::Subscription => &["subscriptionID"], + HostParams::Context => &["context"], + HostParams::Object => { + return match params.get("context") { + Some(context) if !is_context_reference(context) => { + Err(invalid(CONTEXT_REFERENCE_MESSAGE)) + } + _ => Ok(()), + }; + } }; if params.keys().any(|key| !allowed.contains(&key.as_str())) { return Err(invalid("Params name a field this service does not define.")); } - if !params - .get("key") - .and_then(Value::as_str) - .is_some_and(is_credential_key) - { - return Err(invalid( - "A credential key is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", - )); + match rule { + HostParams::Credential { with_secret } => { + if !params + .get("key") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + { + return Err(invalid( + "A credential key is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", + )); + } + if with_secret + && !params + .get("secret") + .and_then(Value::as_str) + .is_some_and(|secret| secret.len() <= MAX_CREDENTIAL_SECRET_BYTES) + { + return Err(invalid("A secret is a string of at most 16 KiB.")); + } + } + HostParams::Subscription => { + if !params + .get("subscriptionID") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + { + return Err(invalid( + "A subscription ID is 1-128 ASCII letters, digits, '.', '_', ':' or '-'.", + )); + } + } + HostParams::Context => { + if !params.get("context").is_some_and(is_context_reference) { + return Err(invalid(CONTEXT_REFERENCE_MESSAGE)); + } + } + HostParams::Empty | HostParams::Object => {} } - if with_secret - && !params - .get("secret") + Ok(()) +} + +const CONTEXT_REFERENCE_MESSAGE: &str = + "A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, '.', '_', ':' or '-', and a positive generation."; + +/// `{ contextID, generation }` naming one generation of one workspace +/// context. Only the shape is checked here; whether it is still current is +/// the host's answer (`plugin.host.contextExpired`). +pub(crate) fn is_context_reference(value: &Value) -> bool { + let Some(reference) = value.as_object() else { + return false; + }; + reference.len() == 2 + && reference + .get("contextID") .and_then(Value::as_str) - .is_some_and(|secret| secret.len() <= MAX_CREDENTIAL_SECRET_BYTES) - { - return Err(invalid("A secret is a string of at most 16 KiB.")); + .is_some_and(is_credential_key) + && reference + .get("generation") + .and_then(Value::as_u64) + .is_some_and(|generation| generation >= 1) +} + +/// Adds the workspace context the host named to the params of one +/// `lithe/function/invoke`. A malformed reference is refused. A backend +/// that negotiated a version without contexts receives the call unchanged, +/// so a page works with an older backend exactly as before. +pub(crate) fn attach_context( + invoke_params: &mut Value, + version: u64, + context: Option<&Value>, +) -> Result<(), CoreError> { + let Some(context) = context else { + return Ok(()); + }; + if !is_context_reference(context) { + return Err(refusal( + ErrorCode::InvalidRequest, + "The workspace context reference is malformed.", + codes::INVALID_PARAMS, + )); } - Ok(service) + if version >= CONTEXT_VERSION { + if let Some(params) = invoke_params.as_object_mut() { + params.insert("context".to_string(), context.clone()); + } + } + Ok(()) } -/// Key of a stored secret. The alphabet excludes path separators so a host -/// may use the key in a store item name without escaping. +/// The code and JSON-RPC error code the backend receives when the host +/// failed a forwarded request. Only the codes a host may report pass +/// through; anything else, including none, is `plugin.host.failed`, so a +/// host cannot impersonate Core's own refusals of other kinds. +pub(crate) fn host_failure(code: Option<&str>) -> (&'static str, i64) { + match code { + Some(codes::HOST_UNAVAILABLE) => (codes::HOST_UNAVAILABLE, -32601), + Some(codes::HOST_PERMISSION_DENIED) => (codes::HOST_PERMISSION_DENIED, -32601), + Some(codes::HOST_INVALID_PARAMS) => (codes::HOST_INVALID_PARAMS, -32602), + Some(codes::HOST_CONTEXT_EXPIRED) => (codes::HOST_CONTEXT_EXPIRED, -32603), + Some(codes::HOST_AUTHORITY_UNSUPPORTED) => (codes::HOST_AUTHORITY_UNSUPPORTED, -32603), + Some(codes::HOST_VERSION_CONFLICT) => (codes::HOST_VERSION_CONFLICT, -32603), + Some(codes::HOST_LIMIT_EXCEEDED) => (codes::HOST_LIMIT_EXCEEDED, -32603), + Some(codes::HOST_CANCELLED) => (codes::HOST_CANCELLED, -32800), + Some(codes::HOST_TIMEOUT) => (codes::HOST_TIMEOUT, -32603), + // A backend exit is reported to pages, never to the backend itself. + Some(codes::BACKEND_EXITED) => (codes::HOST_FAILED, -32603), + _ => (codes::HOST_FAILED, -32603), + } +} + +/// Key of a stored secret, also the alphabet of subscription and context +/// IDs. It excludes path separators so a host may use the value in a store +/// item or file name without escaping. fn is_credential_key(value: &str) -> bool { !value.is_empty() && value.len() <= MAX_CREDENTIAL_KEY_BYTES @@ -710,8 +994,30 @@ mod tests { &granted, ); let expected = &case["expected"]; - match expected["forward"].as_str() { - Some(service) => assert_eq!(outcome, Ok(service), "{}", name(&case)), + if let Some(service) = expected["forward"].as_str() { + assert_eq!( + outcome, + Ok(HostRoute::Forward( + HOST_METHODS + .iter() + .map(|entry| entry.service) + .find(|known| *known == service) + .expect("a known service") + )), + "{}", + name(&case) + ); + continue; + } + match expected.get("answer") { + Some(answer) => { + assert_eq!( + outcome, + Ok(HostRoute::Answer(answer.clone())), + "{}", + name(&case) + ) + } None => { let refusal = outcome.expect_err(name(&case)); assert_eq!(refusal.code, expected["code"], "{}", name(&case)); @@ -721,6 +1027,64 @@ mod tests { } } + #[test] + fn invocation_contexts_follow_the_fixture() { + for case in cases("invocationContexts") { + let mut params = case["params"].clone(); + let outcome = attach_context( + &mut params, + case["version"].as_u64().unwrap(), + case.get("context"), + ); + let expected = &case["expected"]; + match expected["code"].as_str() { + Some(code) => assert_eq!( + outcome.expect_err(name(&case)).details.as_deref(), + Some(code), + "{}", + name(&case) + ), + None => { + outcome.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))); + assert_eq!(params, expected["params"], "{}", name(&case)); + } + } + } + } + + #[test] + fn host_failures_follow_the_fixture() { + for case in cases("hostFailures") { + let (code, rpc_code) = host_failure(case["code"].as_str()); + assert_eq!(code, case["expected"]["code"], "{}", name(&case)); + assert_eq!(rpc_code, case["expected"]["rpcCode"], "{}", name(&case)); + } + } + + /// Capability discovery lists only what the session may call. + #[test] + fn capabilities_list_only_granted_services() { + let granted = BTreeSet::from([HOST_SERVICE_WORKSPACE.to_string()]); + let answer = capabilities_answer(3, &granted); + let services = answer["services"].as_object().expect("services"); + assert!(services.contains_key(HOST_SERVICE_WORKSPACE)); + assert!(services.contains_key(HOST_SERVICE_HOST)); + assert!(services.contains_key(HOST_SERVICE_SUBSCRIPTIONS)); + assert!(!services.contains_key(HOST_SERVICE_CREDENTIALS)); + } + + /// Every grantable service has at least one method, so a grant never + /// admits nothing. + #[test] + fn grantable_services_have_methods() { + for service in GRANTABLE_HOST_SERVICES { + assert!( + HOST_METHODS.iter().any(|entry| entry.service == *service), + "{service}" + ); + } + } + /// The secret bound is inclusive and measured in UTF-8 bytes. #[test] fn credential_secrets_are_bounded() { diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs index 5c4972ed30..1e6a38d38c 100644 --- a/rust/lithe-core/src/plugins/backend.rs +++ b/rust/lithe-core/src/plugins/backend.rs @@ -1,20 +1,29 @@ //! `backend` of an API level 3 plugin manifest: one long-lived process a -//! plugin starts for its own pages, independent of any build system, -//! language, or workspace. +//! plugin starts for itself, independent of any build system, language, or +//! workspace. //! //! A build provider or VCS provider exists for one domain contract and one //! workspace. A backend serves only the plugin itself: its web pages call the //! backend's [plugin functions](crate::lsp::interface::plugin_function), and //! the backend may ask the host for the primitive services its permissions -//! grant, such as private credential storage. The host starts it on the first -//! call from one of the plugin's pages, keeps one process per plugin, and ends -//! it when the plugin is disabled, updated, or uninstalled. +//! grant, such as private credential storage. The host keeps one process per +//! plugin on the machine that runs Lithe, and ends it when the plugin is +//! disabled, updated, or uninstalled. +//! +//! `backend` 1 starts on the first call from one of the plugin's pages, so +//! it needs a web tool window. `backend` 2 may also start when a workspace +//! opens (`onWorkspaceOpen`) or when one of the plugin's commands runs, so it +//! needs only some way to start. Version 2 is also the only one that can be +//! granted the `workspace` host service. Every workspace reaches the one +//! process as an explicit context in each call, never as process state, so +//! `${workspaceStateDir}` stays refused in both versions; the per-workspace +//! state directory travels in the context instead. //! //! The process shares the launch fields, templates, and install review of -//! every plugin server process. It is not bound to a workspace, so -//! `${workspaceStateDir}` is refused. +//! every plugin server process. //! -//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md +//! Note: the decisions are recorded in .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md +//! and .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md use super::declarative::{invalid, ServerProcess, ServerRole, Validation}; use super::settings::DeclaredSettings; @@ -32,14 +41,31 @@ pub(crate) struct BackendDeclaration { /// Primitive host services a backend may request, each granted by one /// manifest permission. pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; +/// Workspace contexts and their folders (`workspace.metadata`, `backend` 2). +pub(crate) const HOST_SERVICE_WORKSPACE: &str = "workspace"; + +/// First `backend` version that may start without a page and may use +/// workspace contexts. +pub(crate) const WORKSPACE_CONTEXT_VERSION: u32 = 2; + +/// Activation event of a `backend` 2 process that starts once a workspace +/// is open. It names no ID: the backend is one process for every workspace. +pub(crate) const ON_WORKSPACE_OPEN: &str = "onWorkspaceOpen"; /// What backend validation needs from the rest of the manifest. pub(crate) struct DeclarationContext<'a> { pub(crate) plugin_api_version: u32, + /// `requires.backend`; 1 when absent, which only happens below level 3. + pub(crate) version: u32, pub(crate) declared_settings: &'a DeclaredSettings, /// Whether a tool window of the plugin shows web content, the only /// caller of a backend in `backend` 1. pub(crate) has_web_window: bool, + /// Whether the plugin declares a UI command, whose `onCommand` event + /// starts a `backend` 2 process. + pub(crate) has_command: bool, + /// Whether the manifest lists `onWorkspaceOpen`. + pub(crate) activates_on_workspace_open: bool, } impl BackendDeclaration { @@ -51,11 +77,22 @@ impl BackendDeclaration { )); } // Only the plugin's own pages call a backend in version 1, so one - // without a web window could never start. - if !context.has_web_window { + // without a web window could never start. Version 2 may also start + // with a workspace or a command, but still needs one of the three. + if context.version < WORKSPACE_CONTEXT_VERSION { + if !context.has_web_window { + return Err(invalid( + "backend", + "A plugin backend serves the plugin's own web pages; declare a web tool window.", + )); + } + } else if !context.has_web_window + && !context.has_command + && !context.activates_on_workspace_open + { return Err(invalid( "backend", - "A plugin backend serves the plugin's own web pages; declare a web tool window.", + "A plugin backend needs a way to start: a web tool window, a command, or onWorkspaceOpen.", )); } self.process.validate( @@ -64,6 +101,8 @@ impl BackendDeclaration { context.declared_settings, ServerRole::Backend, )?; + // One process serves every workspace, so a launch argument cannot + // name one of them; version 2 receives the directory per context. if self.process.uses_workspace_state_directory() { return Err(invalid( "backend", @@ -82,7 +121,13 @@ pub(crate) fn programs(backend: &BackendDeclaration) -> Vec { } /// The `backend` member of a launch plan: the host services the session may -/// request, which the host passes to `lsp.startServer` unchanged. -pub(crate) fn plan_descriptor(host_services: &[&str]) -> Value { - json!({ "hostServices": host_services }) +/// request, which the host passes to `lsp.startServer` unchanged. Version 2 +/// also names its version, so the host knows it may start the process +/// without a page; a version 1 plan keeps its original shape. +pub(crate) fn plan_descriptor(version: u32, host_services: &[&str]) -> Value { + if version >= WORKSPACE_CONTEXT_VERSION { + json!({ "hostServices": host_services, "version": version }) + } else { + json!({ "hostServices": host_services }) + } } diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 4a08226861..27606442cc 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -50,8 +50,10 @@ pub(crate) const BUILD_MODEL_INPUTS: &str = "buildModelInputs"; /// process; see `crate::vcs`. pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; -/// `backend`: the plugin's own long-lived process serving its pages; see -/// `super::backend`. +/// `backend`: the plugin's own long-lived process; see `super::backend`. +/// Version 1 serves only the plugin's web pages. Version 2 may start without +/// a page (`onWorkspaceOpen`, a plugin command) and may be granted the +/// `workspace` host service; it needs plugin-function protocol 3 for that. pub(crate) const BACKEND: &str = "backend"; /// `projectGenerators[]`: entries of the New Project window. @@ -70,7 +72,7 @@ pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ - (BACKEND, &[1]), + (BACKEND, &[1, 2]), (BUILD_MODEL_INPUTS, &[1]), (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6]), (BUILD_SYSTEMS, &[1, 2]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 43122b5314..01d9cb2f4d 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -55,6 +55,9 @@ const PERMISSION_BACKEND: &str = "backend.launch"; /// Letting the backend keep secrets in the host's credential store, private /// to the plugin. `backend` 1. const PERMISSION_CREDENTIALS: &str = "credentials.store"; +/// Letting the backend list the open workspaces and read their folders and +/// its per-workspace state directory. `backend` 2. +const PERMISSION_WORKSPACE_METADATA: &str = "workspace.metadata"; /// Starting a debug session the plugin's adapter serves. `debugAdapters` 1. const PERMISSION_DEBUG_LAUNCH: &str = "debug.launch"; /// Attaching the plugin's adapter to a running process. `debugAdapters` 1. @@ -72,6 +75,7 @@ const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_UI, PERMISSION_UI_WEB, PERMISSION_VCS, + PERMISSION_WORKSPACE_METADATA, ]; /// Runtime tools the host knows how to discover and pass to a language /// server. The host exports each as `LITHE__PATH` and puts its @@ -842,6 +846,8 @@ impl DeclarativePluginManifest { && self.vcs_providers.is_empty() && self.debug_adapters.is_empty() && self.project_generators.is_empty() + // A backend 2 may be the whole plugin: it starts on a workspace. + && self.backend.is_none() { return Err(invalid("languages", "The plugin declares no languages.")); } @@ -971,6 +977,7 @@ impl DeclarativePluginManifest { && self.vcs_providers.is_empty() && self.debug_adapters.is_empty() && self.project_generators.is_empty() + && self.backend.is_none() { return Err(invalid( "languages", @@ -1010,11 +1017,17 @@ impl DeclarativePluginManifest { if let Some(backend) = &self.backend { backend.validate(&super::backend::DeclarationContext { plugin_api_version: self.plugin_api_version, + version: self.backend_version(), declared_settings: &declared_settings, has_web_window: self .ui .as_ref() .is_some_and(|ui| ui.tool_windows.iter().any(|window| window.web.is_some())), + has_command: self.ui.as_ref().is_some_and(|ui| !ui.commands.is_empty()), + activates_on_workspace_open: self + .activation_events + .iter() + .any(|event| event == super::backend::ON_WORKSPACE_OPEN), })?; } self.validate_permissions()?; @@ -1340,6 +1353,20 @@ impl DeclarativePluginManifest { "credentials.store is used by a plugin backend; declare one or remove the permission.", )); } + // Only a backend 2 process receives workspace contexts; an older + // backend could never exercise the grant the user reviewed. + if self + .permissions + .iter() + .any(|p| p == PERMISSION_WORKSPACE_METADATA) + && (self.backend.is_none() + || self.backend_version() < super::backend::WORKSPACE_CONTEXT_VERSION) + { + return Err(invalid( + "permissions", + "workspace.metadata is used by a plugin backend 2; require backend 2 or remove the permission.", + )); + } for permission in required { if !self .permissions @@ -1387,7 +1414,15 @@ impl DeclarativePluginManifest { } } self.activation_events = sorted_unique(std::mem::take(&mut self.activation_events)); - let declared: BTreeSet = self.activation_events.iter().cloned().collect(); + let mut declared: BTreeSet = self.activation_events.iter().cloned().collect(); + // A backend 2 may start once a workspace opens. The event is optional + // because pages and commands can start it too, so it is checked here + // and kept out of the exact provider match below. + if self.backend.is_some() + && self.backend_version() >= super::backend::WORKSPACE_CONTEXT_VERSION + { + declared.remove(super::backend::ON_WORKSPACE_OPEN); + } // Every provider needs an event, and every event needs a provider: // an event without one would promise activation the host cannot do. if let Some(missing) = expected.difference(&declared).next() { @@ -1466,12 +1501,31 @@ impl DeclarativePluginManifest { /// a backend. Each is granted by one permission. pub(crate) fn backend_host_services(&self) -> Vec<&'static str> { let mut services = Vec::new(); - if self.backend.is_some() && self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { + if self.backend.is_none() { + return services; + } + if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { services.push(super::backend::HOST_SERVICE_CREDENTIALS); } + if self.backend_version() >= super::backend::WORKSPACE_CONTEXT_VERSION + && self + .permissions + .iter() + .any(|p| p == PERMISSION_WORKSPACE_METADATA) + { + services.push(super::backend::HOST_SERVICE_WORKSPACE); + } services } + /// `requires.backend`, or 1 for a manifest that does not list it. + pub(crate) fn backend_version(&self) -> u32 { + self.requires + .get(capabilities::BACKEND) + .copied() + .unwrap_or(1) + } + /// Checks the declared settings, sorts them by ID, and returns the IDs /// launch templates may refer to. fn validate_settings(&mut self) -> Validation { @@ -3079,7 +3133,10 @@ fn backend_launch_plan( ) })?; let mut plan = server_launch_plan(manifest, &backend.process, None, request)?; - plan["backend"] = super::backend::plan_descriptor(&manifest.backend_host_services()); + plan["backend"] = super::backend::plan_descriptor( + manifest.backend_version(), + &manifest.backend_host_services(), + ); Ok(plan) } diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 989116174f..ac97e9a9d4 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -31,6 +31,7 @@ const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider-language-server.plugin.json"); const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings-v2.plugin.json"); const BACKEND_FIXTURE: &str = contract_file!("fixtures/backend.plugin.json"); +const WORKSPACE_BACKEND_FIXTURE: &str = contract_file!("fixtures/backend-workspace.plugin.json"); const DEBUG_ADAPTER_FIXTURE: &str = contract_file!("fixtures/debug-adapter.plugin.json"); const DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE: &str = contract_file!("fixtures/debug-adapter-language-server.plugin.json"); @@ -61,6 +62,7 @@ fn fixture(name: &str) -> Value { "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, "settings-v2.plugin.json" => SETTINGS_V2_FIXTURE, "backend.plugin.json" => BACKEND_FIXTURE, + "backend-workspace.plugin.json" => WORKSPACE_BACKEND_FIXTURE, "debug-adapter.plugin.json" => DEBUG_ADAPTER_FIXTURE, "debug-adapter-language-server.plugin.json" => DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE, "debug-adapter-remote-attach.plugin.json" => DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, @@ -84,6 +86,7 @@ fn contract_fixtures_and_official_plugins_validate() { NODE_RUNTIME_FIXTURE, PUBLISHER_BUNDLED_FIXTURE, BACKEND_FIXTURE, + WORKSPACE_BACKEND_FIXTURE, DEBUG_ADAPTER_FIXTURE, DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, OFFICIAL_JAVA, @@ -764,6 +767,51 @@ fn plugin_backend_is_planned_without_a_workspace_and_names_its_host_services() { ); } +/// A `backend` 2 process starts without a page, its plan names its version +/// so the host may start it on `onWorkspaceOpen`, and `workspace.metadata` +/// grants the `workspace` host service. A host that only runs `backend` 1 +/// refuses the manifest instead of starting a process it would misuse. +#[test] +fn workspace_backend_is_planned_with_its_version_and_workspace_service() { + let outcome = validate(fixture("backend-workspace.plugin.json")); + assert_eq!(outcome["status"], "ok", "{outcome}"); + let launch = language_server_launch_plan_command(json!({ + "manifest": fixture("backend-workspace.plugin.json"), + "backend": true, + "request": { + "pluginDirectory": "/plugins/workspace-backend", + "stateDirectory": "/state/workspace-backend", + "architecture": "arm64" + }, + })) + .expect("payload is well formed"); + assert_eq!(launch["status"], "ok", "{launch}"); + assert_eq!( + launch["plan"]["backend"], + json!({ "hostServices": ["workspace"], "version": 2 }) + ); + + let hosted = |host: Value| { + validate_command(json!({ + "manifest": fixture("backend-workspace.plugin.json"), + "hostCapabilities": host, + })) + .expect("payload is well formed") + }; + let old_host = hosted(json!({ "backend": [1], "serverProcess": [1] })); + assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); + assert_eq!(old_host["error"]["field"], "requires.backend"); + assert_eq!( + hosted(json!({ "backend": [1, 2], "serverProcess": [1] }))["status"], + "ok" + ); + + // A page-only backend 1 manifest stays valid as backend 2. + let mut manifest = fixture("backend.plugin.json"); + manifest["requires"]["backend"] = json!(2); + assert_eq!(validate(manifest)["status"], "ok"); +} + /// Icon trust boundaries are shared; native decoding remains with the macOS host. #[test] fn ui_icon_declarations_validate_and_preserve_theme_resources() { diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 00cc79412f..57f4df7e0b 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -219,7 +219,7 @@ process does not use `${workspaceStateDir}`; otherwise the request is | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | | `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | -| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant. The host passes them to `lsp.startServer.hostServices` unchanged. | +| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant, plus `version: 2` for a [`backend` 2](#backend-2-workspace-contexts) process. The host passes `hostServices` to `lsp.startServer.hostServices` unchanged. | A language without a server, an undeclared build system, a build system without a provider, an undeclared VCS provider, and a plugin without a @@ -412,7 +412,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `vcsProviders` | `vcsProviders[]` | `1` | | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | -| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process for its pages | `1` | +| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -501,6 +501,7 @@ Rules for publishing across levels and capability versions: | `ui.web` | a tool window shows [web content](#web-content-ui-2) | | `backend.launch` | `backend` is declared | | `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | +| `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | | Activation event | Meaning | | --- | --- | @@ -510,8 +511,12 @@ Rules for publishing across levels and capability versions: | `onBuildSystem:` | Activate the build system before its first plan, model, or decode. | | `onVcsRoot:` | Start the VCS provider once a root of its VCS is detected in the workspace. | | `onDebug:` | Start the debug adapter when a debug session needs it (API level 4). | +| `onWorkspaceOpen` | Start the [`backend` 2](#backend-2-workspace-contexts) process once a workspace is open. Optional, names no ID, refused without `backend` 2. | -Each provider needs its event and each event needs a provider. Enabling a +Each provider needs its event and each event needs a provider; the only +optional event is `onWorkspaceOpen`. There is no event that starts a plugin +on every launch (`onStartupFinished`, `*`): it is +`plugin.activation.invalid`. Enabling a plugin only permits activation; nothing starts until its event occurs. ## Trust @@ -1508,6 +1513,38 @@ language, or workspace: - A plugin publishes one archive per platform, so its packaged path does not vary by `${arch}`; a macOS archive may carry a universal binary. +### Backend 2: workspace contexts + +`requires.backend: 2` keeps every rule of version 1 and changes when the +process starts and what it learns about workspaces. +`fixtures/backend-workspace.plugin.json` is the example. + +- It may start without a page. It needs at least one way to start: a web + tool window (first page call, as in version 1), a `ui.commands[]` entry + (its `onCommand:` also starts the backend), or the `onWorkspaceOpen` + event. A backend 2 with none of them is `plugin.manifest.invalid`. +- It is still one process per plugin, shared by every open workspace and + window, on the machine that runs Lithe, also for remote workspaces. No + workspace is process state: each call names its workspace explicitly + (a context, below), so `${workspaceStateDir}` stays refused. +- Starting on `onWorkspaceOpen` never delays opening the workspace. The host + starts such backends after the window is shown, one plugin at a time, and + a backend that does not finish `initialize` within its initialize timeout + is stopped and reported like any failed server start. A failed or + disabled backend never blocks the workspace. +- The `workspace.metadata` permission grants the + [`workspace` host service](#workspace-contexts-and-capability-discovery-protocol-version-3). + It reveals the open workspaces' names, root folders and IDs, their + authority, and the plugin's per-workspace state directory; it reads no + file content. +- Activation is gated only by the plugin being installed, enabled, and + reviewed. Lithe has no workspace trust state at the moment: an operation + that runs or changes something asks the user per operation, as the host's + own controls do. +- A host that offers only `backend: [1]` refuses the manifest with + `plugin.api.unsupported` (`requires.backend`), so an older host never + starts a process that expects contexts. + ## Project generators `projectGenerators` (requires `projectGenerators` 1 and the `project.generate` @@ -1699,7 +1736,7 @@ Two rules keep these directions from forming a loop: ### Negotiation For a session owned by a plugin (`lsp.startServer.pluginId`), the host offers -`capabilities.experimental.litheFunctions: { "versions": [1, 2] }` in +`capabilities.experimental.litheFunctions: { "versions": [1, 2, 3] }` in `initialize`, next to and independent of `litheBuild`. Host-owned sessions do not offer or accept this protocol, retain no plugin-function registry, and reject register/unregister with `-32601`. A plugin backend that offers @@ -1749,7 +1786,8 @@ backend that did not negotiate the protocol gets `-32601` for both. ### Invocation `lsp.functionRequest { sessionId, operationId?, function, params?, -timeoutMilliseconds? }` returns `{ operationId }` and sends: +timeoutMilliseconds?, context? }` (`context` from protocol version 3, see +[workspace contexts](#workspace-contexts-and-capability-discovery-protocol-version-3)) returns `{ operationId }` and sends: ```json { "method": "lithe/function/invoke", "params": { "functionID": "snapshot.refresh", "operationID": "…", "params": { … } } } @@ -1833,8 +1871,89 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's `hostRequestID`, including one already answered, is `invalid_request`. - A session holds at most 16 unanswered host requests; a further one is refused with `plugin.host.failed`. Unanswered requests end with the session. -- `hostServices` needs `pluginId` and names only services this Core defines; - otherwise `lsp.startServer` is `invalid_request`. +- `hostServices` needs `pluginId` and names only services this Core defines + (`credentials`, `workspace`); otherwise `lsp.startServer` is + `invalid_request`. + +### Workspace contexts and capability discovery (protocol version 3) + +Version 3 keeps every rule of version 2 and adds capability discovery, +subscription disposal, workspace contexts, a context on each call, typed host +failures, and cancellation of host requests. + +| Request | Params | Result | Granted by | +| --- | --- | --- | --- | +| `lithe/host/getCapabilities` | none or `{}` | `{ protocolVersion, backendAuthority, services, limits }` | every plugin-owned session | +| `lithe/host/subscriptions/dispose` | `{ subscriptionID }` | `null`, also for an unknown or already disposed ID | every plugin-owned session | +| `lithe/host/workspace/listContexts` | none or `{}` | `{ revision, contexts: [] }` | `workspace.metadata` | +| `lithe/host/workspace/getContext` | `{ context: { contextID, generation } }` | `` | `workspace.metadata` | +| `lithe/host/workspace/getFolders` | `{ context: { contextID, generation } }` | `{ folders: [] }` | `workspace.metadata` | + +- Core answers `getCapabilities` itself. `services` maps each service the + session may call (the implicit `host` and `subscriptions`, and the granted + ones) to `{ methods }`; it lists nothing the session may not call. + `backendAuthority` is `"local"`: the backend runs on the machine that runs + Lithe. `limits` gives `pendingHostRequests`, `functionResultBytes`, and + `messageBytes`. A backend must check `services` instead of assuming a + method from the version. +- A context is one open workspace in one window: + + ```json + { + "contextID": "w-3f2a9c", + "generation": 2, + "name": "orders", + "authority": { "kind": "local" }, + "folders": [{ "rootID": "r0c1d2e3f4a5b6c7d", "name": "orders", "path": "/Users/me/orders" }], + "stateDirectory": "/Users/me/Library/Application Support/Lithe/PluginState//backend/workspaces/" + } + ``` + + `contextID` uses the key alphabet and lives as long as the window shows + that workspace. `generation` is a positive integer that grows every time + the context's folders or authority change; a request naming an older + generation, or a closed context, is `plugin.host.contextExpired`, and the + backend asks again. `revision` of `listContexts` grows whenever a context + opens, closes, or changes. +- `rootID` identifies one root folder stably for the same path, across + restarts, without revealing the path. `path` is present only for a + `local` authority. +- `authority.kind` is `local` or `remote` (a remote workspace, which also + has `authority.id`, an opaque connection ID). For a `remote` context the + folders carry no `path`: the local directory the host keeps for a remote + workspace is an empty placeholder, not the workspace. File, search, and + process services answer a request for a context whose authority they do + not implement with `plugin.host.authorityUnsupported`; they never read the + placeholder or run the operation locally instead. +- `stateDirectory` is private to the plugin and the workspace, outside the + plugin package and the application bundle, and created on first use. + `${stateDir}` is the plugin-wide state. Both survive disabling and + updating the plugin and are deleted when it is uninstalled. +- A page call may name its context: `lsp.functionRequest.context` + (`{ contextID, generation }`) reaches a version 3 backend as + `params.context` of `lithe/function/invoke`. A malformed reference is + `plugin.function.invalidParams`; a backend that negotiated version 1 or 2 + receives the call without it. The host fills it from the page's window. +- A host failure may carry a code: + `lsp.hostResponse.error: { message, code? }`. Core passes + `plugin.host.unavailable`, `.permissionDenied` (`-32601`), + `.invalidParams` (`-32602`), `.contextExpired`, `.authorityUnsupported`, + `.versionConflict`, `.limitExceeded`, `.timeout` (`-32603`), and + `.cancelled` (`-32800`) to a version 3 backend; any other code, no code, + or a version 2 backend gets `plugin.host.failed`. `fixtures/function-protocol.json` + (`hostFailures`) lists the mapping. +- A backend may cancel its own pending host request with `$/cancelRequest`. + Core answers it at once with `-32800` and `plugin.host.cancelled`, frees + the slot, and publishes `hostRequestCancelled { hostRequestID }` so the host + stops the work; a later `lsp.hostResponse` for that ID is + `invalid_request`. The 17th pending request of a version 3 session is + `plugin.host.limitExceeded`. +- A service that creates a subscription returns a `subscriptionID` of the key + alphabet; its notifications name it. Ending the session, disabling the + plugin, or closing the subscription's context disposes it. +- A backend still cannot ask the host to call a function or a domain + operation (`plugin.function.recursiveCall`), so a page call never waits on + itself through the host. ### Compatibility @@ -1845,9 +1964,14 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's - A new host capability or wire field the other side must not ignore needs a new protocol version, offered next to the old one. - A backend that answers `version: 1` keeps working unchanged: version 2 - only adds host services. + only adds host services. A backend that answers `version: 2` keeps + working unchanged: version 3 only adds methods, the call context, and + failure codes, all of which it never sees. +- `requires.backend: 1` packages keep their meaning; a new behavior of the + backend capability is `backend` 2, never a silent change of version 1. - `fixtures/function-protocol.json` holds the golden cases, including the - host requests (`hostRequests`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, + host requests (`hostRequests`), call contexts (`invocationContexts`), and + host failure codes (`hostFailures`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, deadline, late reply, revocation, and compatibility with a build-only backend. @@ -1951,7 +2075,14 @@ command payload is a Core `invalid_request` failure instead. | `plugin.host.unavailable` | Core | A backend asked for a host capability the negotiated protocol does not offer | | `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services-protocol-version-2) its plugin's permissions do not grant | | `plugin.host.invalidParams` | Core | A host-service request has malformed or unknown params | -| `plugin.host.failed` | Core | The host could not complete a host-service request, or too many were unanswered | +| `plugin.host.failed` | Core | The host could not complete a host-service request, or (before version 3) too many were unanswered | +| `plugin.host.contextExpired` | Host | The request names a workspace context that closed or has a newer generation | +| `plugin.host.authorityUnsupported` | Host | The context's authority (for example a remote workspace) does not implement the operation | +| `plugin.host.versionConflict` | Host | A versioned write names an older version than the current one | +| `plugin.host.limitExceeded` | Core, Host | A host limit on size, count, or rate was exceeded | +| `plugin.host.cancelled` | Core, Host | The backend cancelled the host request, or the host stopped it | +| `plugin.host.timeout` | Host | The host's own deadline for the operation passed | +| `plugin.backend.exited` | Host | A page's call, or a host operation for the backend, ended because the backend process exited | Host-facing plugin states are `notInstalled`, `installed` (enabled or disabled), `running`, `restartRequired`, `updateRequired`, and `failed`. @@ -2052,8 +2183,9 @@ them), `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, `theme`), `lithe.request(method, params)`, which returns a promise of the result or rejects with `{ code, message }`, and `lithe.on(event, listener)`, which returns an unsubscribe function. A page reaches only its own plugin's -build systems and the open workspace. Requests that run anything go through -the same workspace trust and consent prompts as the host's own controls, and +build systems and the open workspace. Requests that run anything ask for the +same per-operation consent as the host's own controls (Lithe has no workspace +trust state at the moment), and their output appears in the host's Build output window. | Method | Parameters | Result | @@ -2310,7 +2442,8 @@ receive the selected build tool as `tool.executable`; their ServerProcess protoc is unchanged. Language Servers, Execution Providers, and other ServerProcess launch forms can later adapt into this same resolver without changing their manifest structure all at once: preserve package-realpath/executable checks, -workspace trust, auto-start restrictions, runtime ownership and cancellation. +per-operation consent (there is no workspace trust state yet), auto-start +restrictions, runtime ownership and cancellation. They do **not** advertise support for new candidate declarations until their adapter is implemented; this capability currently authorizes the Build candidate positions only. Windows does not advertise it yet. diff --git a/shared/contracts/plugins/v1/fixtures/backend-workspace.plugin.json b/shared/contracts/plugins/v1/fixtures/backend-workspace.plugin.json new file mode 100644 index 0000000000..8799688bed --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/backend-workspace.plugin.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.workspacebackend", + "displayName": "Workspace Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "workspace.metadata" + ], + "activationEvents": [ + "onWorkspaceOpen" + ], + "languages": [], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index 98d1326320..dfee71a319 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "protocol": "litheFunctions", - "offer": { "versions": [1, 2] }, + "offer": { "versions": [1, 2, 3] }, "negotiation": [ { "name": "no answer leaves the protocol off", @@ -41,9 +41,14 @@ "capabilities": { "experimental": { "litheFunctions": { "version": 2, "functions": [{ "id": "query.run" }] } } }, "expected": { "functions": ["query.run"], "version": 2 } }, + { + "name": "version 3 negotiates the same registry and adds contexts and capability discovery", + "capabilities": { "experimental": { "litheFunctions": { "version": 3, "functions": [{ "id": "query.run" }] } } }, + "expected": { "functions": ["query.run"], "version": 3 } + }, { "name": "a version the host never offered is not negotiated", - "capabilities": { "experimental": { "litheFunctions": { "version": 3 } } }, + "capabilities": { "experimental": { "litheFunctions": { "version": 4 } } }, "expected": { "functions": null } }, { @@ -352,6 +357,194 @@ "method": "lithe/host/credentials/get", "params": { "key": "connection.prod", "scope": "global" }, "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "capability discovery needs version 3", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/getCapabilities", + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "Core answers capability discovery with only the session's grants", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/getCapabilities", + "params": {}, + "expected": { + "answer": { + "protocolVersion": 3, + "backendAuthority": "local", + "services": { + "host": { "methods": ["lithe/host/getCapabilities"] }, + "subscriptions": { "methods": ["lithe/host/subscriptions/dispose"] }, + "workspace": { + "methods": [ + "lithe/host/workspace/listContexts", + "lithe/host/workspace/getContext", + "lithe/host/workspace/getFolders" + ] + } + }, + "limits": { "pendingHostRequests": 16, "functionResultBytes": 1048576, "messageBytes": 1024 } + } + } + }, + { + "name": "capability discovery takes no params", + "version": 3, + "hostServices": [], + "method": "lithe/host/getCapabilities", + "params": { "services": ["credentials"] }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "listing workspace contexts is forwarded when workspace.metadata is granted", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/listContexts", + "expected": { "forward": "workspace" } + }, + { + "name": "workspace contexts need the workspace.metadata grant", + "version": 3, + "hostServices": ["credentials"], + "method": "lithe/host/workspace/listContexts", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.permissionDenied" } + }, + { + "name": "workspace contexts need version 3 even when granted", + "version": 2, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/listContexts", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a context's folders are asked for by context ID and generation", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w-3f2a", "generation": 2 } }, + "expected": { "forward": "workspace" } + }, + { + "name": "one context is asked for by context ID and generation", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "w-3f2a", "generation": 2 } }, + "expected": { "forward": "workspace" } + }, + { + "name": "a context without a generation is invalid", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w-3f2a" } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a generation is positive", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "w-3f2a", "generation": 0 } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a context ID outside the alphabet is invalid", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "../w", "generation": 1 } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a context reference has no other fields", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "w-3f2a", "generation": 1, "path": "/tmp" } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a subscription is disposed by its ID without any grant", + "version": 3, + "hostServices": [], + "method": "lithe/host/subscriptions/dispose", + "params": { "subscriptionID": "watch-1" }, + "expected": { "forward": "subscriptions" } + }, + { + "name": "disposing needs a subscription ID", + "version": 3, + "hostServices": [], + "method": "lithe/host/subscriptions/dispose", + "params": {}, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "credentials keep their version 2 rules in version 3", + "version": 3, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a method no version defines is unavailable", + "version": 3, + "hostServices": ["credentials", "workspace"], + "method": "lithe/host/process/run", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } } + ], + "invocationContexts": [ + { + "name": "a version 3 backend receives the call's context", + "version": 3, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "context": { "contextID": "w-3f2a", "generation": 2 }, + "expected": { + "params": { "functionID": "query.run", "operationID": "7", "params": {}, "context": { "contextID": "w-3f2a", "generation": 2 } } + } + }, + { + "name": "an older backend receives the call unchanged", + "version": 2, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "context": { "contextID": "w-3f2a", "generation": 2 }, + "expected": { "params": { "functionID": "query.run", "operationID": "7", "params": {} } } + }, + { + "name": "a call without a context is unchanged", + "version": 3, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "expected": { "params": { "functionID": "query.run", "operationID": "7", "params": {} } } + }, + { + "name": "a malformed context never leaves Core", + "version": 3, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "context": { "contextID": "w-3f2a", "generation": -1 }, + "expected": { "code": "plugin.function.invalidParams" } + } + ], + "hostFailures": [ + { "name": "no code is a generic failure", "code": null, "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } }, + { "name": "a closed or replaced context", "code": "plugin.host.contextExpired", "expected": { "code": "plugin.host.contextExpired", "rpcCode": -32603 } }, + { "name": "a remote authority without the operation", "code": "plugin.host.authorityUnsupported", "expected": { "code": "plugin.host.authorityUnsupported", "rpcCode": -32603 } }, + { "name": "a stale versioned edit", "code": "plugin.host.versionConflict", "expected": { "code": "plugin.host.versionConflict", "rpcCode": -32603 } }, + { "name": "a host limit", "code": "plugin.host.limitExceeded", "expected": { "code": "plugin.host.limitExceeded", "rpcCode": -32603 } }, + { "name": "the host stopped the request", "code": "plugin.host.cancelled", "expected": { "code": "plugin.host.cancelled", "rpcCode": -32800 } }, + { "name": "the host's deadline passed", "code": "plugin.host.timeout", "expected": { "code": "plugin.host.timeout", "rpcCode": -32603 } }, + { "name": "params the service itself refuses", "code": "plugin.host.invalidParams", "expected": { "code": "plugin.host.invalidParams", "rpcCode": -32602 } }, + { "name": "the user withheld consent", "code": "plugin.host.permissionDenied", "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32601 } }, + { "name": "a service the host turned off", "code": "plugin.host.unavailable", "expected": { "code": "plugin.host.unavailable", "rpcCode": -32601 } }, + { "name": "a host cannot borrow a function code", "code": "plugin.function.unknown", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } }, + { "name": "a host cannot report a backend exit to the backend", "code": "plugin.backend.exited", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } } ] } diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index 79b27a9fc7..dd82092e47 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -969,7 +969,7 @@ { "name": "backend capability version this Core does not know", "base": "backend.plugin.json", - "edits": [{ "op": "set", "path": "/requires/backend", "value": 2 }], + "edits": [{ "op": "set", "path": "/requires/backend", "value": 99 }], "expectedCode": "plugin.api.unsupported" }, { @@ -1013,6 +1013,42 @@ "base": "backend.plugin.json", "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend 2 still needs a way to start", + "base": "backend-workspace.plugin.json", + "edits": [{ "op": "set", "path": "/activationEvents", "value": [] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "onWorkspaceOpen needs backend 2", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onView:dev.lithe.fixture.backend.window", "onWorkspaceOpen"] }], + "expectedCode": "plugin.activation.invalid" + }, + { + "name": "workspace.metadata needs backend 2", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute", "ui.web", "workspace.metadata"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "workspace.metadata without a backend", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["ui.contribute", "ui.web", "workspace.metadata"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend 2 does not start on every launch", + "base": "backend-workspace.plugin.json", + "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onStartupFinished", "onWorkspaceOpen"] }], + "expectedCode": "plugin.activation.invalid" + }, + { + "name": "backend 2 is still one process for every workspace", + "base": "backend-workspace.plugin.json", + "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], + "expectedCode": "plugin.manifest.invalid" } ] } diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 64bf9bc3df..96126938a8 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -27,7 +27,7 @@ "minProperties": 1, "additionalProperties": false, "properties": { - "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, + "backend": { "description": "Required exactly when backend is present: the plugin's own process. Version 1 serves only its web pages. Version 2 may also start on onWorkspaceOpen or a plugin command, receives workspace contexts through plugin-function protocol 3, and may be granted workspace.metadata.", "enum": [1, 2] }, "buildModelInputs": {"description": "Optional public model sharing between already enabled workspace plugins; no installation, private function, or process authority. Required exactly when modelSharing or modelInputs is used. Consumers require buildProviders 6. Version 1 producers cannot also consume inputs.", "enum": [1]}, "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs.", "enum": [1, 2, 3, 4, 5, 6] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, @@ -60,11 +60,15 @@ }, "permissions": { "type": "array", - "items": { "enum": ["backend.launch", "credentials.store", "debug.attach", "debug.launch", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web"] } + "items": { "enum": ["backend.launch", "credentials.store", "debug.attach", "debug.launch", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web", "workspace.metadata"] } }, "activationEvents": { "type": "array", - "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot|onDebug):[a-z0-9.+_-]+$" } + "items": { + "type": "string", + "description": "onWorkspaceOpen names no ID and needs requires.backend 2; it starts the backend once a workspace is open.", + "pattern": "^((onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot|onDebug):[a-z0-9.+_-]+|onWorkspaceOpen)$" + } }, "languages": { "type": "array", @@ -86,7 +90,7 @@ "items": { "$ref": "#/$defs/buildSystem" } }, "backend": { - "description": "pluginAPIVersion 3, requires.backend 1: the plugin's own LSP base-protocol process. The host starts it on the first plugin-function call from one of the plugin's web pages, keeps one process per plugin independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Requires backend.launch and a web tool window; credentials.store lets it use the host's private credential store.", + "description": "pluginAPIVersion 3, requires.backend 1 or 2: the plugin's own LSP base-protocol process. The host keeps one process per plugin on the machine that runs Lithe, independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Version 1 starts on the first plugin-function call from one of the plugin's web pages and requires a web tool window. Version 2 also starts on onWorkspaceOpen or one of the plugin's commands and requires one of the three. Requires backend.launch; credentials.store lets it use the host's private credential store; workspace.metadata (version 2) lets it list workspace contexts.", "$ref": "#/$defs/backend" }, "vcsProviders": { @@ -1175,7 +1179,7 @@ ] }, "backend": { - "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because the backend is not bound to a workspace.", + "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because one backend process serves every workspace; version 2 receives each workspace's state directory in its context instead.", "type": "object", "$ref": "#/$defs/serverProcess", "unevaluatedProperties": false From db85a3e427fe3dc55bcd94db7d9a8548037d6eae Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 10:18:29 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(macos):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20backend=202=20=E7=9A=84=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E4=B8=8A=E4=B8=8B=E6=96=87=E3=80=81=E6=BF=80=E6=B4=BB?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=B8=8E=E5=AE=BF=E4=B8=BB=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=88=86=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hostCapabilities 提供 backend [1, 2] - 新增 PluginWorkspaceContexts:每窗口一个上下文,contextID 加 generation,本地/远程 authority,rootID 与状态目录摘要 - 新增 PluginHostServiceDispatcher 与 PluginHostServiceHandling 扩展点,统一错误码、授权与上下文核验、订阅回收与宿主请求取消;凭据与 workspace 服务接入 - MacPluginBackendSessions 支持 onWorkspaceOpen 与 onCommand 激活(串行、不阻塞),调用携带工作区上下文 - RustCoreBridge 传递 lsp.functionRequest.context 与 hostResponse.error.code,会话处理 hostRequestCancelled - 新增界面文案词条并重新生成;新增 Agent Note 记录设计、第 4 节回答与威胁模型,写明工作区信任暂不引入 --- ...-10-30-plugin-backend-workspace-context.md | 167 ++++++++ macos/Resources/en.lproj/Localizable.strings | 12 + .../zh-Hans.lproj/Localizable.strings | 12 + .../Application/Composition/AppServices.swift | 6 + .../Features/PluginHostServiceDispatch.swift | 371 ++++++++++++++++++ .../Features/PluginWebBridge.swift | 26 +- .../Features/PluginWorkspaceContexts.swift | 173 ++++++++ .../Lithe/Core/Rust/RustCoreBridge.swift | 18 +- .../RustLanguageServerRuntimeAdapter.swift | 24 +- .../Models/AppModel/AppModel+PluginWeb.swift | 32 ++ .../AppModel/AppModel+UIContributions.swift | 5 + .../Lithe/Models/AppModel/AppModel.swift | 4 + .../Platform/MacOS/MacServiceContainer.swift | 23 +- .../Plugins/MacPluginBackendSessions.swift | 105 ++++- .../LanguageServerRuntimeContracts.swift | 45 ++- .../Runtime/LanguageServerSession.swift | 13 +- .../LitheModuleAPI/Plugins/PluginTypes.swift | 2 +- .../PluginHostServiceDispatchTests.swift | 279 +++++++++++++ shared/i18n/locales/en-US.json | 8 +- shared/i18n/locales/zh-CN.json | 8 +- shared/i18n/messages.json | 76 ++++ 21 files changed, 1383 insertions(+), 26 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift create mode 100644 macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md new file mode 100644 index 0000000000..893c152b54 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md @@ -0,0 +1,167 @@ +# Agent 笔记:插件后端的工作区上下文、激活与宿主服务分发 + +状态:已实现 + +## 先说结论 + +插件后端新增能力版本 `backend: 2`,同时函数协议升到 3。`backend: 1` 的含义一个字都没改:仍然是“网页第一次调用时才启动、整个应用一个进程”。`backend: 2` 可以在没有网页的情况下启动,触发条件是打开工作区(`onWorkspaceOpen`)或执行插件命令(`onCommand:`)。它仍是每个插件一个进程,被所有窗口共用;每次调用都要带上“工作区上下文”(contextID 加 generation),后端靠它知道这次操作的是哪个工作区。 + +开发时记住三条。第一,宿主服务一律注册到 `PluginHostServiceDispatcher`,不要在 `MacPluginBackendSessions` 里按服务名加分支。第二,凡是涉及文件或进程的服务,遇到不支持的远程 authority 都要返回 `plugin.host.authorityUnsupported`,不能去读本机的空占位目录。第三,工作区信任暂不引入,后续单独立项;现在仍沿用按操作确认。 + +## 问题 + +M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件后端先有一个稳固的身份和工作区模型,后续的编辑器、文件、打开/通知等切片才能接上来。现状有四个缺口: + +- `backend: 1` 只能由网页唤起。没有网页的插件(例如只提供命令或后台索引)根本启动不了。 +- 后端不知道有哪些工作区打开,也不知道调用来自哪个窗口。如果靠“当前活动窗口”去猜,窗口切换后操作会落到错误的工作区。 +- 宿主服务只有凭据一种,写死在 `PluginHostServices.answer` 里。三个并行切片没有统一的注册点,也没有统一的错误分类。 +- 远程工作区在本机只有一个空的占位目录。如果服务把它当作真实路径,就会读到空目录,或者在本机执行本该在远端执行的操作。 + +## 决策 + +### 新能力版本,不悄悄改 `backend:1` + +- `requires.backend: 2` 是新版本;宿主现在提供 `backend: [1, 2]`(`PluginTypes.swift` 的 hostCapabilities 与 Rust `capabilities.rs`)。 +- 只认识 `[1]` 的旧宿主会以 `plugin.api.unsupported` 拒绝 v2 清单,因此旧宿主不会误把 v2 当 v1 运行。 +- v2 至少要有一种启动方式:网页工具窗口、`ui.commands[]`,或 `onWorkspaceOpen`。三者都没有时清单无效。 +- v2 可以是整个插件的全部内容,不需要同时声明语言或 UI。 +- 函数协议在 Core 里协商出 `[1, 2, 3]`。版本 3 加入的内容:能力发现、订阅释放、工作区上下文、调用上下文、带错误码的宿主失败、宿主请求取消。backend 1 的插件也能协商到协议 3,网页调用的行为不变。 + +### 共享进程、隔离调用上下文,而不是每个工作区一个会话 + +我们选的是“共享进程 + 每次调用显式带上下文”:仍然每个插件一个进程,工作区不再是进程状态,而是每次调用参数里的 `{contextID, generation}`。 + +理由如下: + +- 后端持有的东西(数据库连接、索引缓存、登录态)属于用户,不属于某个工程。按工作区拆进程,就要为每个窗口重复启动、重复认证,内存也会成倍增长。 +- 同一个进程里,靠上下文参数隔离已经足够。宿主对每个请求都会核验上下文是否仍然有效,后端无法自己拼出一个上下文来越权。 +- `${workspaceStateDir}` 在 v2 里继续拒绝,因为进程启动时没有“当前工作区”可言。每个工作区的状态目录改由上下文对象里的 `stateDirectory` 给出。 + +正确的做法是:网页调用时,宿主从该网页所在窗口取上下文(`PluginWebBridgeHost.pluginWebWorkspaceContext`),再经 `lsp.functionRequest.context` 送到后端。不要这样做:后端在收不到上下文时,去取“最近打开的工作区”。 + +### 身份与授权句柄的寿命 + +| 句柄 | 谁发 | 寿命 | 失效后 | +| --- | --- | --- | --- | +| 插件 ID | 安装记录 | 安装期间 | 卸载即消失 | +| 后端会话与授权(hostServices) | Rust 启动计划 + `dispatcher.beginSession` | 一个进程 | 停止、禁用、替换包、崩溃后重启时 `endSession`,未完成的宿主请求不再回答,订阅全部释放 | +| contextID | `PluginWorkspaceContexts`,形如 `w-` | 窗口显示该工作区期间;同一窗口换工作区时 ID 不变 | 窗口关闭工作区、切到单文件或关窗即关闭 | +| generation | 同上,从 1 开始 | 文件夹或 authority 每变一次加 1 | 旧 generation 一律 `plugin.host.contextExpired`,后端需要重新获取 | +| rootID | `"r"` + 位置 SHA-256 的前 16 位十六进制 | 同一位置跨重启保持稳定 | 不含路径,不能当作授权凭证 | +| subscriptionID | `PluginHostSubscriptions`,形如 `s-` | 直到被 dispose、上下文关闭或会话结束 | 未知 ID 的 dispose 也返回成功 | +| hostRequestID | Core | 一次宿主请求 | 后端用 `$/cancelRequest` 取消后,Core 立即以 `-32800` 回答;宿主停止工作,之后的回答会被 Core 拒收 | + +宿主对每个请求再做一次核验:服务是否在该会话的授权里(隐式服务 `host`、`subscriptions` 除外),以及参数里的上下文是否仍是当前 generation。这一步和 Core 做的检查是重复的,属于纵深防御。 + +### Authority(工作区在哪台机器上) + +- 上下文的 `authority` 只有两种:`local`,或 `remote`(带不透明的连接 ID)。 +- 远程工作区的文件夹不带 `path`。本机目录只是占位,服务不能读它,也不能改在本机执行。 +- 文件、搜索、进程类服务遇到自己没实现的 authority,返回 `plugin.host.authorityUnsupported`。 +- 后端进程永远跑在运行 Lithe 的这台 Mac 上,远程工作区也一样。`getCapabilities.backendAuthority` 固定为 `"local"`。 + +### 存储范围与卸载策略 + +- 全局状态是 `${stateDir}`,即 `Application Support/Lithe/PluginState//backend`。 +- 工作区状态是 `${stateDir}/workspaces/`。digest 对本地工作区取路径的 SHA-256,对远程工作区取 `remote:<连接ID>:<远端路径>` 的 SHA-256,目录名里不出现用户路径。 +- 工作区状态目录在第一次 `getContext` 或 `listContexts` 时才创建。两种状态都在平台可写目录里,不在 app bundle 或插件包内,所以不影响代码签名和 Sparkle 增量包的基线。 +- 禁用和更新插件时保留这些目录。卸载时随插件目录一起删除,沿用 `MacPluginStateDirectories.pluginDirectory` 的现有清理。 + +### 激活事件与启动预算 + +- `onWorkspaceOpen`:窗口打开工作区后,在下一个主线程任务里启动,不阻塞打开流程。多个插件按 ID 排序后串行启动,一次只启动一个。每个插件沿用 60 秒的 initialize 超时,超时或失败只记日志(`plugins.backend`),不会挡住工作区。 +- `onCommand:`:在 `performToolWindowContribution` 里,命令贡献的 `actionID` 就是命令 ID;只启动所属 UI 模块为 `.ui` 的那个插件。窗口照常打开,不等待后端。 +- 不接受 `onStartupFinished` 和 `*`。“应用一启动就跑”的后端没有用户可感知的触发点,会拖慢所有用户的启动。 +- 激活不以信任状态为前提。只要插件已安装、已启用、经过安装审查,激活事件就会生效。 + +### 拒绝递归调用 + +后端仍然不能通过宿主去调用函数或领域操作(`plugin.function.recursiveCall`)。新增的宿主方法都是只读查询或宿主本地动作,不会回调后端。因此一次网页调用不可能经由宿主等待它自己。 + +### 宿主服务分发扩展点 + +- 每个切片实现 `@MainActor protocol PluginHostServiceHandling`(属性 `service`,方法 `answer(_ call: PluginHostCall) async -> Result`),在组合根 `MacServiceContainer` 里用 `hostDispatcher.register(_:)` 注册。 +- `PluginHostServiceError.code` 用 `PluginHostErrorCode` 表示:未支持、未授权、上下文失效、authority 不支持、版本冲突、参数错误、超限、取消、超时、后端退出、其他失败。 +- Core 只把白名单里的错误码透传给协议 3 的后端,其余一律变成 `plugin.host.failed`。 + +### 工作区信任 + +工作区信任暂不引入,后续单独立项。现阶段沿用按操作确认,例如网页启动进程前的 `pluginWebConfirmProcessStart`。契约 README 里原来写着“workspace trust”的地方,已经改成如实描述:按操作确认,目前没有工作区信任状态。 + +### 对计划第 4 节的回答 + +- **4.1 谁能调用。** + - 网页调用:由窗口的上下文决定。 + - 命令激活:只启动所属插件。 + - 宿主请求:只针对发出请求的那个插件的会话。 + - 协议版本:协议 3、`backend` 2、`workspace.metadata` 权限各自独立协商,`backend:1` 不变。 + - 递归:仍然拒绝。 +- **4.2 权限和数据保护。** + - `workspace.metadata` 只暴露工作区名、根目录、rootID、authority 和状态目录,不读取任何文件内容。 + - 路径只对 local 给出。 + - 凭据服务的规则不变。 + - 进程分离不等于沙盒:原生后端本来就拥有用户的 OS 权限,宿主能做的只是拒绝代理越权的请求。 +- **4.3 异步与预算。** + - 宿主请求最多同时挂起 16 个(第 17 个返回 `limitExceeded`),单个结果不超过 1 MiB,错误消息不超过 1024 字节。这些上限通过 `getCapabilities.limits` 公布。 + - 后端可以取消自己的宿主请求。会话结束时,所有进行中的宿主任务都会被取消。 + - 订阅的创建方是服务,释放方是 dispatcher(dispose、上下文关闭、会话结束三条路径)。`listContexts.revision` 让后端判断工作区列表是否变了,需要重新获取快照。 +- **4.4 编辑器一致性。** M0 不涉及文档内容。编辑器读取和带版本编辑由 API-09/10 切片通过同一扩展点接入,版本冲突用 `plugin.host.versionConflict`。 + +### 威胁模型 + +| 威胁 | 处理 | +| --- | --- | +| 后端伪造 contextID 或使用旧 generation,操作别的工作区 | 宿主按注册表核验,旧的或不存在的一律 `contextExpired`;ID 不是授权,授权仍看会话的 hostServices | +| 后端请求未授权的服务 | Core 按授权表拒绝;宿主 dispatcher 再查一次,返回 `permissionDenied` | +| 插件 A 读取插件 B 的状态或订阅 | 状态目录按插件 ID 分开,订阅按插件 ID 登记;凭据按插件 ID 隔离 | +| 远程工作区被当作本地读写 | 远程上下文不带路径;文件和进程服务必须返回 `authorityUnsupported` | +| 慢后端或恶意后端堆积宿主请求 | 最多 16 个挂起,可取消,会话结束时全部清理 | +| 路径从状态目录名或 rootID 泄露 | 两者都只用 SHA-256 摘要 | +| 后端退出后,宿主仍把结果送给新进程 | 新进程启动前先 `endSession`;旧任务被取消,不再回答 | +| 原生后端绕过宿主直接读盘或联网 | 不在本设计防护范围内;由安装审查和用户的 OS 权限承担 | + +## 考虑过的备选方案 + +- **每个工作区一个后端会话。** 隔离最直观。被否的原因:连接和缓存都要按窗口重复建立;`backend:1` 已经是应用级进程,两套模型并存会让插件作者困惑;而且远程工作区也只能在本机跑后端,按工作区拆进程得不到任何隔离收益。 +- **把 `backend:1` 直接扩展为带工作区。** 被否的原因:已经发布的插件会在不知情的情况下收到新的启动时机和参数,违背“未知能力明确拒绝、不靠升版本宣称全量可用”的原则。 +- **用“当前活动窗口”推断工作区。** 被否的原因:后台调用和多窗口场景下会落到错误的工作区,计划 4.1 也明确禁止这样做。 +- **在 M0 引入工作区信任状态,并以它作为激活前提。** 用户决定暂不引入;先沿用按操作确认,信任模型以后单独立项。 +- **把 `${workspaceStateDir}` 开放给 v2 的启动参数。** 被否的原因:进程不属于某个工作区,启动时没有正确的值可填。 + +## 后果 + +收益: + +- 没有网页的插件也能运行后端。 +- 三个切片只需要实现一个协议并注册一次,不改契约,也不改 Core 的能力表。 +- 后端能明确知道每次调用属于哪个工作区,并在工作区过期时得到提示。 +- 远程工作区不会被误读。 + +代价: + +- 后端必须自己处理 `contextExpired`,重新获取上下文后再试。 +- 在 v2 里 `${workspaceStateDir}` 不可用,插件作者需要从上下文对象里取状态目录。 +- 每加一个新服务,都要同时更新 Core 的 `HOST_METHODS` 和契约 README。 +- `onWorkspaceOpen` 后端串行启动。插件多的时候,最后一个插件就绪得较晚,但这不影响工作区本身。 + +## 验证 + +- `cargo test -p lithe-core`(覆盖下列 Rust 用例): + - `capability_discovery_is_answered_by_core` + - `typed_host_failures_reach_a_version_3_backend` + - `a_backend_can_cancel_its_host_request` + - `invocations_carry_the_workspace_context_from_version_3` + - `invocation_contexts_follow_the_fixture` + - `host_failures_follow_the_fixture` + - `capabilities_list_only_granted_services` + - `grantable_services_have_methods` + - `workspace_backend_is_planned_with_its_version_and_workspace_service` + - 以及 `fixtures/invalid-manifests.json` 新增的 6 个拒绝用例 +- `./scripts/macos/test/test-macos.sh --filter "PluginHostServiceDispatchTests|MacPluginBackendSessionsTests|PluginWebBridgeTests"`:上下文 generation、rootID、授权核验、本地与远程描述、过期与格式错误、订阅的三条释放路径、错误码透传。M0 提交时尚未在本机编译运行,合入前必须跑通。 +- `zsh scripts/architecture/verify-shared-contracts.sh`:契约 schema、fixture 与 README。 +- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`:运行时不向发行目录写入。 +- 合入前,在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本仓库路径> --ref <分支>`,确认 Node 和 Java SDK 的示例后端在协议 3 下仍能跑通。 + +## 适用范围 + +适用于 macOS 宿主和 Rust Core 的插件后端:`backend` 2、函数协议 3、`workspace` 宿主服务,以及后续所有接入 `PluginHostServiceDispatcher` 的宿主服务(编辑器、文件、打开/预览/通知等切片)。Windows/Tauri 宿主目前不提供 `backend` 能力(`host_capabilities` 中没有该项),会以 `plugin.api.unsupported` 拒绝任何声明后端的清单;本次不改 Windows。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 40734d2a1c..113e6dc714 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3872,6 +3872,7 @@ "The global instruction file must use UTF-8 encoding." = "The global instruction file must use UTF-8 encoding."; "The host cannot provide an execution session for %@." = "The host cannot provide an execution session for %@."; "The host could not be reached. Check the host name, the port, and your network." = "The host could not be reached. Check the host name, the port, and your network."; +"The host could not create the plugin's workspace state directory." = "The host could not create the plugin's workspace state directory."; "The host does not serve %@." = "The host does not serve %@."; "The host has no bridge method %@." = "The host has no bridge method %@."; "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts." = "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts."; @@ -3901,13 +3902,16 @@ "The official plugin package signature is invalid: %@" = "The official plugin package signature is invalid: %@"; "The official plugin package signature is missing." = "The official plugin package signature is missing."; "The official plugin signing key is unavailable." = "The official plugin signing key is unavailable."; +"The params of %@ must be an object." = "The params of %@ must be an object."; "The patch changes the base commit's tree into the target commit's tree." = "The patch changes the base commit's tree into the target commit's tree."; "The plugin archive expands beyond the allowed size: %@" = "The plugin archive expands beyond the allowed size: %@"; "The plugin archive is larger than %@ MB." = "The plugin archive is larger than %@ MB."; "The plugin backend failed to start." = "The plugin backend failed to start."; +"The plugin backend is no longer running." = "The plugin backend is no longer running."; "The plugin backend stopped before it was ready." = "The plugin backend stopped before it was ready."; "The plugin does not provide the debug adapter %@." = "The plugin does not provide the debug adapter %@."; "The plugin download address is invalid." = "The plugin download address is invalid."; +"The plugin is not allowed to use the host service %@." = "The plugin is not allowed to use the host service %@."; "The plugin launch refers to %@, which is not a file inside its package." = "The plugin launch refers to %@, which is not a file inside its package."; "The plugin package changed while it was being copied. Choose it again." = "The plugin package changed while it was being copied. Choose it again."; "The plugin package contains an unsupported file: %@." = "The plugin package contains an unsupported file: %@."; @@ -4018,6 +4022,8 @@ "The working copy was cleaned up." = "The working copy was cleaned up."; "The working tree could not be refreshed. Keep your local files and inspect Git status before continuing." = "The working tree could not be refreshed. Keep your local files and inspect Git status before continuing."; "The workspace" = "The workspace"; +"The workspace context is no longer current. Ask the host for it again." = "The workspace context is no longer current. Ask the host for it again."; +"The workspace context reference is malformed." = "The workspace context reference is malformed."; "The workspace dependency configuration in %@ is invalid." = "The workspace dependency configuration in %@ is invalid."; "The workspace dependency configuration uses an unsupported version." = "The workspace dependency configuration uses an unsupported version."; "The workspace is not a supported %@ test project." = "The workspace is not a supported %@ test project."; @@ -8456,6 +8462,7 @@ "native.theHookLibraryResponseWasNotRecognised" = "The Hook library response was not recognised."; "native.theHostCannotProvideAnExecutionSessionFor" = "The host cannot provide an execution session for %1$@."; "native.theHostCouldNotBeReachedCheckTheHostNameThePortAndYourNetwork" = "The host could not be reached. Check the host name, the port, and your network."; +"native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory" = "The host could not create the plugin's workspace state directory."; "native.theHostDoesNotServe" = "The host does not serve %1$@."; "native.theHostHasNoBridgeMethod" = "The host has no bridge method %1$@."; "native.theHostKeyWasNotAcceptedIfTheServerWasReinstalledRemoveItsOldEntryFromSshKnownHosts" = "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts."; @@ -8509,13 +8516,16 @@ "native.theOfficialPluginPackageSignatureIsInvalid" = "The official plugin package signature is invalid: %1$@"; "native.theOfficialPluginPackageSignatureIsMissing" = "The official plugin package signature is missing."; "native.theOfficialPluginSigningKeyIsUnavailable" = "The official plugin signing key is unavailable."; +"native.theParamsOfMustBeAnObject" = "The params of %1$@ must be an object."; "native.thePatchChangesTheBaseCommitSTreeIntoTheTargetCommitSTree" = "The patch changes the base commit's tree into the target commit's tree."; "native.thePluginArchiveExpandsBeyondTheAllowedSize" = "The plugin archive expands beyond the allowed size: %1$@"; "native.thePluginArchiveIsLargerThanLldMb" = "The plugin archive is larger than %1$@ MB."; "native.thePluginBackendFailedToStart" = "The plugin backend failed to start."; +"native.thePluginBackendIsNoLongerRunning" = "The plugin backend is no longer running."; "native.thePluginBackendStoppedBeforeItWasReady" = "The plugin backend stopped before it was ready."; "native.thePluginDoesNotProvideTheDebugAdapter" = "The plugin does not provide the debug adapter %1$@."; "native.thePluginDownloadAddressIsInvalid" = "The plugin download address is invalid."; +"native.thePluginIsNotAllowedToUseTheHostService" = "The plugin is not allowed to use the host service %1$@."; "native.thePluginLaunchRefersToWhichIsNot" = "The plugin launch refers to %1$@, which is not a file inside its package."; "native.thePluginPackageChangedWhileItWasBeing" = "The plugin package changed while it was being copied. Choose it again."; "native.thePluginPackageContainsAnUnsupportedFile" = "The plugin package contains an unsupported file: %1$@."; @@ -8620,6 +8630,8 @@ "native.theWorkingCopyWasCleanedUp" = "The working copy was cleaned up."; "native.theWorkingTreeCouldNotBeRefreshedKeepYourLocalFilesAndInspectGitStatusBeforeContinuing" = "The working tree could not be refreshed. Keep your local files and inspect Git status before continuing."; "native.theWorkspace" = "The workspace"; +"native.theWorkspaceContextIsNoLongerCurrent" = "The workspace context is no longer current. Ask the host for it again."; +"native.theWorkspaceContextReferenceIsMalformed" = "The workspace context reference is malformed."; "native.theWorkspaceDependencyConfigurationInIsInvalid" = "The workspace dependency configuration in %1$@ is invalid."; "native.theWorkspaceDependencyConfigurationUsesAnUnsupportedVersion" = "The workspace dependency configuration uses an unsupported version."; "native.theWorkspaceIsNotASupportedTestProject" = "The workspace is not a supported %1$@ test project."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 284cb78df5..ff12c68612 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3872,6 +3872,7 @@ "The global instruction file must use UTF-8 encoding." = "全局指令文件必须使用 UTF-8 编码。"; "The host cannot provide an execution session for %@." = "宿主无法为 %@ 提供执行会话。"; "The host could not be reached. Check the host name, the port, and your network." = "无法连接到主机,请检查主机名、端口和网络。"; +"The host could not create the plugin's workspace state directory." = "宿主无法创建插件的工作区状态目录。"; "The host does not serve %@." = "宿主不提供 %@。"; "The host has no bridge method %@." = "宿主没有桥接方法 %@。"; "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts." = "主机密钥未被接受。如果服务器重装过,请从 ~/.ssh/known_hosts 中删除它的旧记录。"; @@ -3901,13 +3902,16 @@ "The official plugin package signature is invalid: %@" = "官方插件包签名无效:%@"; "The official plugin package signature is missing." = "缺少官方插件包签名。"; "The official plugin signing key is unavailable." = "官方插件签名密钥不可用。"; +"The params of %@ must be an object." = "%@ 的参数必须是对象。"; "The patch changes the base commit's tree into the target commit's tree." = "补丁将基准提交的文件内容变为目标提交的文件内容。"; "The plugin archive expands beyond the allowed size: %@" = "插件压缩包解压后超出允许的大小:%@"; "The plugin archive is larger than %@ MB." = "插件压缩包超过 %@ MB。"; "The plugin backend failed to start." = "插件后端启动失败。"; +"The plugin backend is no longer running." = "插件后端已不在运行。"; "The plugin backend stopped before it was ready." = "插件后端在就绪前已停止。"; "The plugin does not provide the debug adapter %@." = "插件没有提供调试适配器 %@。"; "The plugin download address is invalid." = "插件下载地址无效。"; +"The plugin is not allowed to use the host service %@." = "插件无权使用宿主服务 %@。"; "The plugin launch refers to %@, which is not a file inside its package." = "插件启动项引用了 %@,它不是插件包内的文件。"; "The plugin package changed while it was being copied. Choose it again." = "插件包在复制过程中发生了变化。请重新选择。"; "The plugin package contains an unsupported file: %@." = "插件包包含不受支持的文件:%@。"; @@ -4018,6 +4022,8 @@ "The working copy was cleaned up." = "工作副本已清理。"; "The working tree could not be refreshed. Keep your local files and inspect Git status before continuing." = "工作树刷新失败。请保留本地文件,检查 Git 状态后再继续。"; "The workspace" = "工作区"; +"The workspace context is no longer current. Ask the host for it again." = "工作区上下文已失效,请重新向宿主获取。"; +"The workspace context reference is malformed." = "工作区上下文引用格式不正确。"; "The workspace dependency configuration in %@ is invalid." = "%@ 中的工作区依赖配置无效。"; "The workspace dependency configuration uses an unsupported version." = "工作区依赖配置使用了不受支持的版本。"; "The workspace is not a supported %@ test project." = "该工作区不是受支持的 %@ 测试项目。"; @@ -8456,6 +8462,7 @@ "native.theHookLibraryResponseWasNotRecognised" = "无法识别 Hook 资源库响应。"; "native.theHostCannotProvideAnExecutionSessionFor" = "宿主无法为 %1$@ 提供执行会话。"; "native.theHostCouldNotBeReachedCheckTheHostNameThePortAndYourNetwork" = "无法连接到主机,请检查主机名、端口和网络。"; +"native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory" = "宿主无法创建插件的工作区状态目录。"; "native.theHostDoesNotServe" = "宿主不提供 %1$@。"; "native.theHostHasNoBridgeMethod" = "宿主没有桥接方法 %1$@。"; "native.theHostKeyWasNotAcceptedIfTheServerWasReinstalledRemoveItsOldEntryFromSshKnownHosts" = "主机密钥未被接受。如果服务器重装过,请从 ~/.ssh/known_hosts 中删除它的旧记录。"; @@ -8509,13 +8516,16 @@ "native.theOfficialPluginPackageSignatureIsInvalid" = "官方插件包签名无效:%1$@"; "native.theOfficialPluginPackageSignatureIsMissing" = "缺少官方插件包签名。"; "native.theOfficialPluginSigningKeyIsUnavailable" = "官方插件签名密钥不可用。"; +"native.theParamsOfMustBeAnObject" = "%1$@ 的参数必须是对象。"; "native.thePatchChangesTheBaseCommitSTreeIntoTheTargetCommitSTree" = "补丁将基准提交的文件内容变为目标提交的文件内容。"; "native.thePluginArchiveExpandsBeyondTheAllowedSize" = "插件压缩包解压后超出允许的大小:%1$@"; "native.thePluginArchiveIsLargerThanLldMb" = "插件压缩包超过 %1$@ MB。"; "native.thePluginBackendFailedToStart" = "插件后端启动失败。"; +"native.thePluginBackendIsNoLongerRunning" = "插件后端已不在运行。"; "native.thePluginBackendStoppedBeforeItWasReady" = "插件后端在就绪前已停止。"; "native.thePluginDoesNotProvideTheDebugAdapter" = "插件没有提供调试适配器 %1$@。"; "native.thePluginDownloadAddressIsInvalid" = "插件下载地址无效。"; +"native.thePluginIsNotAllowedToUseTheHostService" = "插件无权使用宿主服务 %1$@。"; "native.thePluginLaunchRefersToWhichIsNot" = "插件启动项引用了 %1$@,它不是插件包内的文件。"; "native.thePluginPackageChangedWhileItWasBeing" = "插件包在复制过程中发生了变化。请重新选择。"; "native.thePluginPackageContainsAnUnsupportedFile" = "插件包包含不受支持的文件:%1$@。"; @@ -8620,6 +8630,8 @@ "native.theWorkingCopyWasCleanedUp" = "工作副本已清理。"; "native.theWorkingTreeCouldNotBeRefreshedKeepYourLocalFilesAndInspectGitStatusBeforeContinuing" = "工作树刷新失败。请保留本地文件,检查 Git 状态后再继续。"; "native.theWorkspace" = "工作区"; +"native.theWorkspaceContextIsNoLongerCurrent" = "工作区上下文已失效,请重新向宿主获取。"; +"native.theWorkspaceContextReferenceIsMalformed" = "工作区上下文引用格式不正确。"; "native.theWorkspaceDependencyConfigurationInIsInvalid" = "%1$@ 中的工作区依赖配置无效。"; "native.theWorkspaceDependencyConfigurationUsesAnUnsupportedVersion" = "工作区依赖配置使用了不受支持的版本。"; "native.theWorkspaceIsNotASupportedTestProject" = "该工作区不是受支持的 %1$@ 测试项目。"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 3a543ecc07..4ac4ba58e5 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -36,6 +36,12 @@ final class AppServices { /// Fires after plugins that take effect without a restart were installed, /// updated, removed, enabled or disabled; `nil` when the platform has none. var pluginRegistrationChanges: AnyPublisher? + /// The workspace contexts plugin backends see, shared by every window; + /// `nil` when the platform runs no plugin backends. + var pluginWorkspaceContexts: PluginWorkspaceContexts? + /// Starts plugin backends on `onWorkspaceOpen` and `onCommand:`; + /// `nil` when the platform runs no plugin backends. + var pluginBackendActivation: (any PluginBackendActivating)? let moduleRuntime: ModuleRuntime let pluginManager: any PluginManaging let pluginMarketplace: any PluginMarketplaceServicing diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift new file mode 100644 index 0000000000..4b9e0a42a4 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift @@ -0,0 +1,371 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 插件后端的宿主服务分发、错误分类与订阅回收的决策见 .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md + +/// Starts plugin backends on their activation events (`backend` 2). +@MainActor +protocol PluginBackendActivating: AnyObject { + /// Returns at once; the backends start in the background. `owner` + /// limits `onCommand:` to the plugin whose UI module declares it. + func activate(event: String, owner: ModuleID?) +} + +/// How a host service failed, as a function protocol 3 backend sees it. +/// Core passes these codes on; any other reaches the backend as +/// `plugin.host.failed`, and a protocol 2 backend sees `failed` for all. +enum PluginHostErrorCode: String, Sendable, CaseIterable { + /// No service, or no method of it, on this host or for this authority. + case unavailable = "plugin.host.unavailable" + /// The plugin is not authorized for the service or this resource. + case permissionDenied = "plugin.host.permissionDenied" + /// The context named is closed or of an older generation. + case contextExpired = "plugin.host.contextExpired" + /// The context's authority is one this service does not implement. + case authorityUnsupported = "plugin.host.authorityUnsupported" + /// The resource changed since the version the request named. + case versionConflict = "plugin.host.versionConflict" + case invalidParams = "plugin.host.invalidParams" + /// Too large, too many, or too often. + case limitExceeded = "plugin.host.limitExceeded" + case cancelled = "plugin.host.cancelled" + case timeout = "plugin.host.timeout" + /// The backend went away while the host worked on its request. + case backendExited = "plugin.backend.exited" + case failed = "plugin.host.failed" +} + +/// A failed host service request. The message reaches the backend, so it +/// never carries a secret or a path the plugin may not see. +struct PluginHostServiceError: Error, Equatable, Sendable { + let code: PluginHostErrorCode + let message: String + + init(_ code: PluginHostErrorCode, _ message: String) { + self.code = code + self.message = message + } + + var failure: PluginHostRequestFailure { + PluginHostRequestFailure(message: message, code: code.rawValue) + } +} + +/// One host request a service answers. Core has already checked the +/// method, the grant, and the shape of the params; the dispatcher has +/// resolved the context the params name, so `context` is current. +struct PluginHostCall { + let pluginID: String + let request: PluginHostRequest + /// The current context the params named under `context`; nil when they + /// name none. + let context: PluginWorkspaceContext? + let params: [String: ToolingJSONValue] + /// Where a service records what it must stop when the session ends or + /// the context closes. + let subscriptions: PluginHostSubscriptions +} + +/// A host service plugin backends reach through `lithe/host//*`. +/// Each slice of the plugin API registers one with the dispatcher; it +/// answers only for the plugin in the call and refuses a context whose +/// authority it does not implement with `.authorityUnsupported`, never +/// acting on the local placeholder of a remote workspace. +@MainActor +protocol PluginHostServiceHandling: AnyObject { + /// The service name of the methods, such as `workspace`. + var service: String { get } + /// Blocking work belongs off the main actor. A cancelled task's result + /// is dropped: the backend was already answered. + func answer(_ call: PluginHostCall) async -> Result +} + +/// Subscriptions services created for backends. Each belongs to one plugin +/// and possibly one context; ending the session, disabling the plugin, +/// `lithe/host/subscriptions/dispose`, or closing the context disposes it. +@MainActor +final class PluginHostSubscriptions { + private struct Entry { + let contextID: String? + let dispose: @MainActor () -> Void + } + + private var entries: [String: [String: Entry]] = [:] + private var nextNumber = 1 + + /// Records a subscription and returns its ID for the backend. + func add(pluginID: String, contextID: String?, dispose: @escaping @MainActor () -> Void) -> String { + let id = "s-\(nextNumber)" + nextNumber += 1 + entries[pluginID, default: [:]][id] = Entry(contextID: contextID, dispose: dispose) + return id + } + + func count(pluginID: String) -> Int { + entries[pluginID]?.count ?? 0 + } + + /// Unknown and already disposed IDs succeed, as the contract says. + func dispose(pluginID: String, subscriptionID: String) { + entries[pluginID]?.removeValue(forKey: subscriptionID)?.dispose() + } + + func disposeAll(pluginID: String) { + guard let removed = entries.removeValue(forKey: pluginID) else { return } + for entry in removed.values { entry.dispose() } + } + + func disposeAll(contextID: String) { + for pluginID in Array(entries.keys) { + guard let owned = entries[pluginID] else { continue } + for (id, entry) in owned where entry.contextID == contextID { + entries[pluginID]?[id] = nil + entry.dispose() + } + } + } +} + +/// Routes the host requests of plugin backends to the services registered +/// here. A request is handled for the plugin whose session sent it, inside +/// the context its params name and only for the services that session was +/// granted; Core checked the same, and this is the host's own check. +@MainActor +final class PluginHostServiceDispatcher { + /// Services every plugin-owned session may call without a grant. + static let implicitServices: Set = ["host", "subscriptions"] + + let contexts: PluginWorkspaceContexts + let subscriptions = PluginHostSubscriptions() + private var handlers: [String: any PluginHostServiceHandling] = [:] + private var grants: [String: Set] = [:] + private var inFlight: [String: [String: Task]] = [:] + private var contextObserver: UUID? + + init(contexts: PluginWorkspaceContexts) { + self.contexts = contexts + contextObserver = contexts.observeClose { [weak self] contextID in + self?.subscriptions.disposeAll(contextID: contextID) + } + } + + /// Adds or replaces the handler of its service. + func register(_ handler: any PluginHostServiceHandling) { + handlers[handler.service] = handler + } + + var registeredServices: [String] { + handlers.keys.sorted() + } + + /// Starts serving a session with the host services it was granted. + func beginSession(pluginID: String, hostServices: [String]) { + grants[pluginID] = Set(hostServices) + } + + /// Ends serving a session: the work in flight stops unanswered and its + /// subscriptions are disposed. + func endSession(pluginID: String) { + grants[pluginID] = nil + if let tasks = inFlight.removeValue(forKey: pluginID) { + for task in tasks.values { task.cancel() } + } + subscriptions.disposeAll(pluginID: pluginID) + } + + /// Answers a request through `respond`, unless the backend cancels it + /// first (`cancel`) or the session ends. + func handle( + _ request: PluginHostRequest, + pluginID: String, + respond: @escaping @MainActor (Result) -> Void + ) { + let task = Task { @MainActor [weak self] in + guard let self else { return } + let outcome = await self.dispatch(request, pluginID: pluginID) + guard !Task.isCancelled, self.inFlight[pluginID]?.removeValue(forKey: request.id) != nil else { return } + respond(outcome) + } + inFlight[pluginID, default: [:]][request.id] = task + } + + /// The backend cancelled the request; Core already answered it. + func cancel(hostRequestID: String, pluginID: String) { + inFlight[pluginID]?.removeValue(forKey: hostRequestID)?.cancel() + } + + func dispatch(_ request: PluginHostRequest, pluginID: String) async -> Result { + guard let granted = grants[pluginID] else { + return .failure(PluginHostServiceError( + .backendExited, L10n.string("The plugin backend is no longer running.") + ).failure) + } + guard Self.implicitServices.contains(request.service) || granted.contains(request.service) else { + return .failure(PluginHostServiceError( + .permissionDenied, L10n.string("The plugin is not allowed to use the host service \(request.service).") + ).failure) + } + let params: [String: ToolingJSONValue] + switch request.params { + case .object(let object): params = object + case .null: params = [:] + default: + return .failure(PluginHostServiceError( + .invalidParams, L10n.string("The params of \(request.method) must be an object.") + ).failure) + } + if request.method == "lithe/host/subscriptions/dispose" { + guard case .string(let id)? = params["subscriptionID"] else { + return .failure(PluginHostServiceError( + .invalidParams, L10n.string("The params of \(request.method) must be an object.") + ).failure) + } + subscriptions.dispose(pluginID: pluginID, subscriptionID: id) + return .success(.null) + } + var context: PluginWorkspaceContext? + if let value = params["context"] { + guard let reference = Self.contextReference(value) else { + return .failure(PluginHostServiceError( + .invalidParams, L10n.string("The workspace context reference is malformed.") + ).failure) + } + switch contexts.resolve(reference) { + case .success(let current): context = current + case .failure(let error): return .failure(error.failure) + } + } + guard let handler = handlers[request.service] else { + return .failure(PluginHostServiceError( + .unavailable, L10n.string("The host does not serve \(request.method).") + ).failure) + } + let call = PluginHostCall( + pluginID: pluginID, request: request, context: context, params: params, subscriptions: subscriptions + ) + return await handler.answer(call).mapError(\.failure) + } + + static func contextReference(_ value: ToolingJSONValue) -> PluginWorkspaceContextReference? { + guard case .object(let object) = value, object.count == 2, + case .string(let id)? = object["contextID"], !id.isEmpty, + case .integer(let generation)? = object["generation"], generation >= 1 else { + return nil + } + return PluginWorkspaceContextReference(contextID: id, generation: generation) + } +} + +/// `credentials`: the secrets of function protocol 2, unchanged. Keychain +/// calls block, so they run off the main actor. +@MainActor +final class PluginCredentialHostService: PluginHostServiceHandling { + let service = "credentials" + private let credentials: any PluginCredentialStoring + + init(credentials: any PluginCredentialStoring) { + self.credentials = credentials + } + + func answer(_ call: PluginHostCall) async -> Result { + let credentials = credentials + let request = call.request + let pluginID = call.pluginID + let outcome = await Task.detached(priority: .userInitiated) { + PluginHostServices.answer(request, pluginID: pluginID, credentials: credentials) + }.value + return outcome.mapError { PluginHostServiceError(.failed, $0.message) } + } +} + +/// `workspace`: the contexts a backend works in (`workspace.metadata`). +/// Paths appear only for a local authority; a context's state directory is +/// private to the plugin and created on first use, outside the bundle. +@MainActor +final class PluginWorkspaceHostService: PluginHostServiceHandling { + let service = "workspace" + private let contexts: PluginWorkspaceContexts + private let backendStateDirectory: (String) -> URL + private let fileManager: FileManager + + /// `backendStateDirectory` gives a plugin's `${stateDir}`, which must be + /// a platform-writable directory; context state lives below it. + init( + contexts: PluginWorkspaceContexts, + backendStateDirectory: @escaping (String) -> URL, + fileManager: FileManager = .default + ) { + self.contexts = contexts + self.backendStateDirectory = backendStateDirectory + self.fileManager = fileManager + } + + func stateDirectory(of context: PluginWorkspaceContext, pluginID: String) -> URL { + backendStateDirectory(pluginID) + .appendingPathComponent("workspaces", isDirectory: true) + .appendingPathComponent(PluginWorkspaceContexts.stateDigest(context.stateKey), isDirectory: true) + } + + func answer(_ call: PluginHostCall) async -> Result { + switch call.request.method { + case "lithe/host/workspace/listContexts": + var described: [ToolingJSONValue] = [] + for context in contexts.contexts { + switch describe(context, pluginID: call.pluginID) { + case .success(let value): described.append(value) + case .failure(let error): return .failure(error) + } + } + return .success(.object(["revision": .integer(contexts.revision), "contexts": .array(described)])) + case "lithe/host/workspace/getContext": + guard let context = call.context else { return .failure(Self.missingContext) } + return describe(context, pluginID: call.pluginID) + case "lithe/host/workspace/getFolders": + guard let context = call.context else { return .failure(Self.missingContext) } + return .success(.object(["folders": .array(context.folders.map { Self.describe($0, authority: context.authority) })])) + default: + return .failure(PluginHostServiceError( + .unavailable, L10n.string("The host does not serve \(call.request.method).") + )) + } + } + + private static var missingContext: PluginHostServiceError { + PluginHostServiceError(.invalidParams, L10n.string("The workspace context reference is malformed.")) + } + + private func describe(_ context: PluginWorkspaceContext, pluginID: String) -> Result { + let directory = stateDirectory(of: context, pluginID: pluginID) + do { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return .failure(PluginHostServiceError( + .failed, L10n.string("The host could not create the plugin's workspace state directory.") + )) + } + let authority: [String: ToolingJSONValue] + switch context.authority { + case .local: authority = ["kind": .string("local")] + case .remote(let id): authority = ["kind": .string("remote"), "id": .string(id)] + } + return .success(.object([ + "contextID": .string(context.contextID), + "generation": .integer(context.generation), + "name": .string(context.name), + "authority": .object(authority), + "folders": .array(context.folders.map { Self.describe($0, authority: context.authority) }), + "stateDirectory": .string(directory.path), + ])) + } + + private static func describe( + _ folder: PluginWorkspaceContext.Folder, + authority: PluginWorkspaceContext.Authority + ) -> ToolingJSONValue { + var object: [String: ToolingJSONValue] = ["rootID": .string(folder.rootID), "name": .string(folder.name)] + // The local directory of a remote workspace is a placeholder. + if authority == .local { object["path"] = .string(folder.localURL.path) } + return .object(object) + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift b/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift index 597f013b2c..3d6e7ab332 100644 --- a/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift +++ b/macos/Sources/Lithe/Application/Features/PluginWebBridge.swift @@ -111,6 +111,24 @@ protocol PluginBackendRequesting: AnyObject { _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout: Duration, progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void ) async throws -> ToolingJSONValue + /// Like the call above, naming the workspace context of the page's + /// window. A backend of function protocol 3 receives it as + /// `params.context`; an older one receives the call unchanged. + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, + context: PluginWorkspaceContextReference?, timeout: Duration, + progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue +} + +extension PluginBackendRequesting { + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, + context _: PluginWorkspaceContextReference?, timeout: Duration, + progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + try await invokePluginFunction(functionID, params: params, ofPlugin: pluginID, timeout: timeout, progress: progress) + } } /// What a page may cause in its host window. @@ -127,10 +145,14 @@ protocol PluginWebBridgeHost: AnyObject { func pluginWebChooseFile(_ request: PluginWebFileRequest) async -> URL? /// The application's plugin backends; nil on hosts that run none. var pluginWebBackends: (any PluginBackendRequesting)? { get } + /// The workspace context of this window, named on every backend call; + /// nil when the window shows no workspace. + var pluginWebWorkspaceContext: PluginWorkspaceContextReference? { get } } extension PluginWebBridgeHost { var pluginWebBackends: (any PluginBackendRequesting)? { nil } + var pluginWebWorkspaceContext: PluginWorkspaceContextReference? { nil } } /// Page state kept by the host, per window and workspace. @@ -583,6 +605,7 @@ final class PluginWebBridge { } let timeout = requestTimeout let pluginID = window.pluginID + let workspaceContext = host?.pluginWebWorkspaceContext let instanceID: String? = if case .instance(_, let entry) = target { entry.id } else { nil } let progress: @MainActor @Sendable (PluginFunctionProgress) -> Void = { [weak self, weak reservation] progress in guard let self, let pageCall, let reservation, self.functionCalls[pageCall] === reservation else { return } @@ -600,7 +623,8 @@ final class PluginWebBridge { ) case .backend(let backends): try await backends.invokePluginFunction( - functionID, params: input, ofPlugin: pluginID, timeout: timeout, progress: progress + functionID, params: input, ofPlugin: pluginID, context: workspaceContext, + timeout: timeout, progress: progress ) } } diff --git a/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift b/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift new file mode 100644 index 0000000000..b899126734 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift @@ -0,0 +1,173 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 插件后端的工作区上下文、身份与存储范围的决策见 .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md + +/// One open workspace in one window, as plugin backends see it (function +/// protocol 3). `generation` grows whenever the window's folders or +/// authority change, so a backend holding an older one learns that its +/// view is stale (`plugin.host.contextExpired`) instead of acting on it. +struct PluginWorkspaceContext: Equatable, Sendable { + /// Where the workspace's files and processes are. Only `local` is + /// served by file and process services today; they refuse `remote` + /// with `plugin.host.authorityUnsupported`. + enum Authority: Equatable, Sendable { + case local + /// A workspace on an SSH host; `id` is the opaque connection ID. + case remote(id: String) + } + + struct Folder: Equatable, Sendable { + /// Stable for the same location across restarts, without the path. + let rootID: String + let name: String + /// The folder on this Mac. For a remote workspace it is the empty + /// placeholder, never the workspace: services must not read it. + let localURL: URL + /// The folder on the remote host, for a remote workspace. + let remotePath: String? + } + + let contextID: String + let generation: Int + let name: String + let authority: Authority + let folders: [Folder] + /// Keys the per-plugin state directory of this workspace; it names the + /// location, so reopening the same workspace finds the same state. + let stateKey: String + + var reference: PluginWorkspaceContextReference { + PluginWorkspaceContextReference(contextID: contextID, generation: generation) + } +} + +/// The workspace contexts of every window, shared by all windows because +/// plugin backends are. A window owns at most one context; it opens it when +/// it opens a workspace and closes it when the workspace closes. +@MainActor +final class PluginWorkspaceContexts { + static let shared = PluginWorkspaceContexts() + + /// Grows whenever a context opens, closes, or changes. + private(set) var revision = 1 + private var contextsByOwner: [ObjectIdentifier: PluginWorkspaceContext] = [:] + private var closeObservers: [UUID: @MainActor (String) -> Void] = [:] + private var openObservers: [UUID: @MainActor (PluginWorkspaceContext) -> Void] = [:] + private var nextContextNumber = 1 + + init() {} + + /// Every open context, ordered by context ID. + var contexts: [PluginWorkspaceContext] { + contextsByOwner.values.sorted { $0.contextID < $1.contextID } + } + + func context(ownedBy owner: AnyObject) -> PluginWorkspaceContext? { + contextsByOwner[ObjectIdentifier(owner)] + } + + /// The context a reference names, when it is open and of that + /// generation. Anything else is `plugin.host.contextExpired`. + func resolve(_ reference: PluginWorkspaceContextReference) -> Result { + guard let context = contextsByOwner.values.first(where: { $0.contextID == reference.contextID }), + context.generation == reference.generation else { + return .failure(PluginHostServiceError( + .contextExpired, + L10n.string("The workspace context is no longer current. Ask the host for it again.") + )) + } + return .success(context) + } + + /// Opens or replaces the window's context. The same workspace again + /// keeps the context unchanged; another one keeps the context ID with + /// the next generation, and closes what the old one owned. + @discardableResult + func open(owner: AnyObject, rootURL: URL, remote: (connectionID: String, remoteRoot: String)?) -> PluginWorkspaceContext { + let root = rootURL.standardizedFileURL + let authority: PluginWorkspaceContext.Authority = remote.map { .remote(id: $0.connectionID) } ?? .local + let stateKey: String + let name: String + if let remote { + stateKey = "remote:\(remote.connectionID):\(remote.remoteRoot)" + name = (remote.remoteRoot as NSString).lastPathComponent.isEmpty + ? remote.remoteRoot + : (remote.remoteRoot as NSString).lastPathComponent + } else { + stateKey = root.path + name = root.lastPathComponent + } + let folder = PluginWorkspaceContext.Folder( + rootID: Self.rootID(stateKey), name: name, localURL: root, remotePath: remote?.remoteRoot + ) + let key = ObjectIdentifier(owner) + let previous = contextsByOwner[key] + if let previous, previous.authority == authority, previous.folders == [folder] { + return previous + } + let contextID: String + if let previous { + contextID = previous.contextID + } else { + contextID = "w-\(nextContextNumber)" + nextContextNumber += 1 + } + let context = PluginWorkspaceContext( + contextID: contextID, + generation: (previous?.generation ?? 0) + 1, + name: name, + authority: authority, + folders: [folder], + stateKey: stateKey + ) + contextsByOwner[key] = context + revision += 1 + if previous != nil { + for observer in closeObservers.values { observer(contextID) } + } + for observer in openObservers.values { observer(context) } + return context + } + + /// Closes the window's context; nothing happens when it has none. + func close(owner: AnyObject) { + guard let context = contextsByOwner.removeValue(forKey: ObjectIdentifier(owner)) else { return } + revision += 1 + for observer in closeObservers.values { observer(context.contextID) } + } + + /// Called with the context ID whenever a context closes or moves to a + /// new generation, so what belonged to it is disposed. + @discardableResult + func observeClose(_ observer: @escaping @MainActor (String) -> Void) -> UUID { + let id = UUID() + closeObservers[id] = observer + return id + } + + /// Called whenever a context opens or moves to a new generation. + @discardableResult + func observeOpen(_ observer: @escaping @MainActor (PluginWorkspaceContext) -> Void) -> UUID { + let id = UUID() + openObservers[id] = observer + return id + } + + func removeObserver(_ id: UUID) { + closeObservers[id] = nil + openObservers[id] = nil + } + + /// `r` and 16 hex digits of the location's SHA-256. + static func rootID(_ location: String) -> String { + "r" + SHA256.hash(data: Data(location.utf8)).prefix(8).map { String(format: "%02x", $0) }.joined() + } + + /// Names the state directory of a context without the user's path. + static func stateDigest(_ stateKey: String) -> String { + SHA256.hash(data: Data(stateKey.utf8)).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index fa0a96b17b..30393ea837 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2060,15 +2060,24 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let functionID: String } + struct Context: Encodable { + let contextID: String + let generation: Int + } + let sessionId: String let function: Reference let params: ToolingJSONValue let timeoutMilliseconds: Int + /// Omitted when nil, so Core sees the version 2 request shape. + let context: Context? } private struct LspHostResponseRequest: Encodable { struct Failure: Encodable { let message: String + /// Omitted when nil. + let code: String? } let sessionId: String @@ -4016,6 +4025,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { pluginID: String, functionID: String, params: ToolingJSONValue, + context: PluginWorkspaceContextReference? = nil, timeout: TimeInterval ) -> Result { executeResult( @@ -4024,7 +4034,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { sessionId: sessionID, function: .init(pluginID: pluginID, functionID: functionID), params: params, - timeoutMilliseconds: max(1, Self.milliseconds(timeout)) + timeoutMilliseconds: max(1, Self.milliseconds(timeout)), + context: context.map { .init(contextID: $0.contextID, generation: $0.generation) } ) ) } @@ -4034,7 +4045,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { sessionID: String, hostRequestID: String, result: ToolingJSONValue?, - failureMessage: String? + failureMessage: String?, + failureCode: String? = nil ) -> Result { executeVoid( command: "lsp.hostResponse", @@ -4042,7 +4054,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { sessionId: sessionID, hostRequestID: hostRequestID, result: failureMessage == nil ? (result ?? .null) : nil, - error: failureMessage.map { .init(message: $0) } + error: failureMessage.map { .init(message: $0, code: failureCode) } ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 85a0e3153e..6a81e4d5f4 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -342,13 +342,14 @@ extension RustCoreBridge: LanguageServerRuntimeCore { outcome: Result ) -> Result { let result: ToolingJSONValue? - let failureMessage: String? + let failure: PluginHostRequestFailure? switch outcome { - case .success(let value): result = value; failureMessage = nil - case .failure(let failure): result = nil; failureMessage = failure.message + case .success(let value): result = value; failure = nil + case .failure(let reason): result = nil; failure = reason } return lspHostResponse( - sessionID: sessionID, hostRequestID: hostRequestID, result: result, failureMessage: failureMessage + sessionID: sessionID, hostRequestID: hostRequestID, result: result, + failureMessage: failure?.message, failureCode: failure?.code ).mapError(Self.runtimeFailure) } @@ -373,6 +374,21 @@ extension RustCoreBridge: LanguageServerRuntimeCore { .mapError(Self.runtimeFailure) } + func requestPluginFunction( + sessionID: String, + pluginID: String, + functionID: String, + params: ToolingJSONValue, + context: PluginWorkspaceContextReference?, + timeout: TimeInterval + ) -> Result { + lspFunctionRequest( + sessionID: sessionID, pluginID: pluginID, functionID: functionID, params: params, + context: context, timeout: timeout + ).map { LanguageServerRuntimeOperation(operationID: $0.operationId) } + .mapError(Self.runtimeFailure) + } + func cancelLanguageServerOperation(sessionID: String, operationID: String) { lspCancelOperation(sessionID: sessionID, operationID: operationID) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift index 3a89f1e7b7..d3e5414cf6 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginWeb.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import LitheExecutionModule // Note: 插件网页内容与宿主窗口的分工见 .agents/notes/implemented/architecture/2026-10-18-plugin-web-content-in-host-windows.md @@ -36,6 +37,37 @@ extension AppModel: PluginWebBridgeHost { var pluginWebBackends: (any PluginBackendRequesting)? { services.pluginWebPages.backends } + var pluginWebWorkspaceContext: PluginWorkspaceContextReference? { + services.pluginWorkspaceContexts?.context(ownedBy: self)?.reference + } + + // Note: 工作区上下文与 onWorkspaceOpen 激活见 .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md + + /// Gives the workspace this window opens a context for plugin backends, + /// with the SSH host as its authority when it is remote, then starts + /// the `onWorkspaceOpen` backends once the window shows it. Their start + /// never delays the workspace. + func openPluginWorkspaceContext(at rootURL: URL) { + guard let contexts = services.pluginWorkspaceContexts else { return } + let root = rootURL.standardizedFileURL + let binding = services.remoteConnections?.workspaces.binding(forRoot: root) + contexts.open( + owner: self, + rootURL: root, + remote: binding.map { (connectionID: $0.connectionID.uuidString, remoteRoot: $0.remoteRoot) } + ) + Task { @MainActor [weak self] in + guard let self, !self.isProjectSessionClosed, self.workspaceURL == root else { return } + self.services.pluginBackendActivation?.activate(event: "onWorkspaceOpen", owner: nil) + } + } + + /// The window no longer shows the workspace: backends see its context + /// close, and what they subscribed to in it is disposed. + func closePluginWorkspaceContext() { + services.pluginWorkspaceContexts?.close(owner: self) + } + /// True while the window's plugin has a new version, a rollback, or an /// uninstall waiting for restart: its old page is not shown again. func pluginWebPageAwaitsRestart(_ windowID: String) -> Bool { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift index 010f617ae6..ce3ebfd8e0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift @@ -19,6 +19,11 @@ extension AppModel { let target = contribution.kind == .toolWindow ? contribution.id : contribution.visibility["toolWindowID"] guard let target, workspaceURL != nil, services.moduleRuntime.availableContributions()[owner]?.contains(contribution) == true else { return } + // A plugin command also starts its `backend` 2 when the plugin + // declares `onCommand:`; the window opens without waiting. + if contribution.kind == .command, let commandID = contribution.actionID { + services.pluginBackendActivation?.activate(event: "onCommand:\(commandID)", owner: owner) + } let window = WorkbenchFeatureModel.ToolWindow(rawValue: target) if !openOnly && contribution.kind == .toolWindow && workbenchFeature.isVisible(window) { workbenchFeature.setVisibility(window, isVisible: false) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 49d064aca5..37e066cf59 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -711,6 +711,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { // Native window close can retain this model through Settings without // closing its workspace. Revoke page requests before teardown can yield. services.pluginWebPages.closeAll() + closePluginWorkspaceContext() workbenchFeature.endLayoutSession() // VCS providers too: their pending refreshes, or a scan still loading // the workspace, would otherwise start a process for a closed window. @@ -981,6 +982,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { selectedSidebar = .project projectItemEditRequest = nil pendingProjectItemDeletion = nil + openPluginWorkspaceContext(at: normalizedURL) } func resumeGitObservationAfterActivation() async { @@ -996,6 +998,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } private func finishWorkspaceClose(for workspaceURL: URL) { + closePluginWorkspaceContext() releaseRemoteWorkspace(at: workspaceURL) ideCapabilities.disable() documentLanguageCoordinator?.stop() @@ -1044,6 +1047,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } private func prepareForStandaloneOpen(at normalizedURL: URL) { + closePluginWorkspaceContext() documentLanguageCoordinator?.stop() featureGraph.editorSession.resetContent() isFindBarVisible = false diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 38e251e3c2..ec243530fe 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -413,7 +413,18 @@ final class MacServiceContainer { applicationLogging?.record(level: "warn", scope: "plugins.migration", message: message, fields: [:]) } ) - return MacPluginBackendSessions( + // Host services of backend requests: the credentials of + // protocol 2 and the workspace contexts of protocol 3. Context + // state lives below each plugin's `${stateDir}` in Application + // Support, never in the bundle or the package. + let hostDispatcher = PluginHostServiceDispatcher(contexts: .shared) + hostDispatcher.register(PluginCredentialHostService(credentials: pluginCredentials)) + let backendDirectories = MacPluginStateDirectories(fileStorage: fileStorage) + hostDispatcher.register(PluginWorkspaceHostService( + contexts: .shared, + backendStateDirectory: { backendDirectories.backendStateDirectory(pluginID: $0) } + )) + let sessions = MacPluginBackendSessions( launchResolver: pluginLaunchResolver, tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), versionChecker: declarativePluginOperations, @@ -426,8 +437,14 @@ final class MacServiceContainer { applicationLogging?.record(level: "info", scope: "plugins.migration", message: "database connections moved into the plugin", fields: ["profiles": String(profiles), "passwords": String(passwords)]) } - } + }, + hostDispatcher: hostDispatcher ) + sessions.onActivationFailure = { [applicationLogging] pluginID, error in + applicationLogging?.record(level: "warn", scope: "plugins.backend", message: "backend activation failed", + fields: ["plugin": pluginID, "error": error.localizedDescription]) + } + return sessions } pluginBackends.register(pluginStartup.registeredPlugins) let rustLanguageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) @@ -1122,6 +1139,8 @@ final class MacServiceContainer { } ) } + services.pluginWorkspaceContexts = PluginWorkspaceContexts.shared + services.pluginBackendActivation = pluginBackends services.processExitCoordinator = ProcessExitCoordinator( core: RustProcessLifecycleDecider(core: rustCore), presenter: MacProcessExitConfirmationPresenter.shared diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 8dfc441001..5ae65789b8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -4,6 +4,7 @@ import LitheLanguageIntelligenceModule import LitheModuleAPI // Note: 插件后端与宿主凭据服务的决策见 .agents/notes/implemented/architecture/2026-10-24-database-provider-plugin.md +// Note: backend 2 的激活事件、工作区上下文与宿主服务分发见 .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md /// The backends of plugins (`backend` 1): one process per plugin for the /// whole application, shared by every project window, because what a @@ -11,13 +12,14 @@ import LitheModuleAPI /// than to a project. /// /// A process starts on the first `functions.*` call from one of the -/// plugin's pages and runs until the plugin is disabled, its package is +/// plugin's pages, or, for `backend` 2, on its `onWorkspaceOpen` or +/// `onCommand:` activation event, and runs until the plugin is disabled, its package is /// replaced or removed, or the application quits. Its root and working /// directory is its `${stateDir}`, outside the app bundle and the package. /// It may request only the host services its permissions grant; Core checks -/// each request, and this type performs it for that plugin alone. +/// each request, and `hostDispatcher` performs it for that plugin alone. @MainActor -final class MacPluginBackendSessions: PluginBackendRequesting { +final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActivating { /// Shared by every project window's container; the first container /// creates it. private static var sharedInstance: MacPluginBackendSessions? @@ -40,6 +42,19 @@ final class MacPluginBackendSessions: PluginBackendRequesting { let packageURL: URL /// How the package was installed, for `prepareState`. let installation: PluginInstallationRecord + /// The manifest's activation events, as Core accepted them. Only + /// a `backend` 2 starts on them; a `backend` 1 waits for its page. + var activationEvents: [String] = [] + /// `requires.backend` of the manifest. + var backendVersion = 1 + } + + /// `requires.backend` of a normalized manifest; 1 when it cannot be read. + static func backendVersion(ofManifest manifest: Data) -> Int { + guard let object = try? JSONSerialization.jsonObject(with: manifest) as? [String: Any], + let requires = object["requires"] as? [String: Any], + let version = requires["backend"] as? Int else { return 1 } + return version } private let launchResolver: MacPluginLanguageServerLaunchResolver @@ -52,6 +67,13 @@ final class MacPluginBackendSessions: PluginBackendRequesting { private let prepareState: @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void private let endBackendProcesses: @MainActor (_ token: String, _ immediately: Bool) -> Void private let stuckSendGrace: Duration + /// Serves the host requests of every backend. + let hostDispatcher: PluginHostServiceDispatcher + /// Reports a backend that an activation event failed to start; the + /// workspace or command goes on without it. + var onActivationFailure: (@MainActor (_ pluginID: String, _ error: Error) -> Void)? + /// Starts activated backends one plugin at a time. + private var activationQueue: Task? private var packages: [String: Package] = [:] /// Disabled plugins start again once enabled. private var disabledPluginIDs: Set = [] @@ -81,6 +103,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting { credentials: any PluginCredentialStoring, prepareState: @escaping @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void = { _, _ in }, stuckSendGrace: Duration = .seconds(2), + hostDispatcher: PluginHostServiceDispatcher? = nil, endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in MacPluginBackendProcesses.end( token: token, @@ -98,6 +121,13 @@ final class MacPluginBackendSessions: PluginBackendRequesting { self.prepareState = prepareState self.stuckSendGrace = stuckSendGrace self.endBackendProcesses = endBackendProcesses + if let hostDispatcher { + self.hostDispatcher = hostDispatcher + } else { + let dispatcher = PluginHostServiceDispatcher(contexts: .shared) + dispatcher.register(PluginCredentialHostService(credentials: credentials)) + self.hostDispatcher = dispatcher + } } /// Records the backends of the plugins loaded at launch. Every project @@ -110,7 +140,9 @@ final class MacPluginBackendSessions: PluginBackendRequesting { packages[id] = Package( manifest: plugin.descriptor.normalizedManifest, packageURL: plugin.packageURL, - installation: plugin.installation + installation: plugin.installation, + activationEvents: plugin.descriptor.activationEvents, + backendVersion: Self.backendVersion(ofManifest: plugin.descriptor.normalizedManifest) ) } } @@ -129,11 +161,59 @@ final class MacPluginBackendSessions: PluginBackendRequesting { ofPlugin pluginID: String, timeout: Duration, progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + try await invokePluginFunction( + functionID, params: params, ofPlugin: pluginID, context: nil, timeout: timeout, progress: progress + ) + } + + func invokePluginFunction( + _ functionID: String, + params: ToolingJSONValue, + ofPlugin pluginID: String, + context: PluginWorkspaceContextReference?, + timeout: Duration, + progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void ) async throws -> ToolingJSONValue { let session = try await session(for: pluginID) let components = timeout.components let seconds = TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 - return try await session.invokeFunction(functionID, params: params, timeout: seconds, progress: progress) + return try await session.invokeFunction( + functionID, params: params, context: context, timeout: seconds, progress: progress + ) + } + + /// Plugins whose backend starts on `event`, such as `onWorkspaceOpen` + /// or `onCommand:`. `owner` limits a command to the plugin whose UI + /// module declares it. + func pluginIDs(activatedBy event: String, owner: ModuleID? = nil) -> [String] { + packages.filter { id, package in + package.backendVersion >= 2 + && package.activationEvents.contains(event) + && !disabledPluginIDs.contains(id) + && (owner == nil || owner == ModuleID("\(id).ui")) + }.keys.sorted() + } + + /// Starts the backends the event activates, after what is queued, one + /// plugin at a time and without the caller waiting: a slow or failing + /// backend never holds up the workspace or the command. A backend that + /// is already running stays as it is. + func activate(event: String, owner: ModuleID? = nil) { + let starting = pluginIDs(activatedBy: event, owner: owner).filter { running[$0] == nil } + guard !starting.isEmpty else { return } + let previous = activationQueue + activationQueue = Task { @MainActor [weak self] in + await previous?.value + for pluginID in starting { + guard let self, !Task.isCancelled else { return } + do { + _ = try await self.session(for: pluginID) + } catch { + if !(error is CancellationError) { self.onActivationFailure?(pluginID, error) } + } + } + } } /// Stops the backend until `enable` is called. @@ -155,6 +235,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting { } private func stop(_ pluginID: String) { + hostDispatcher.endSession(pluginID: pluginID) guard let entry = running.removeValue(forKey: pluginID) else { return } entry.task?.cancel() entry.session?.stop() @@ -245,17 +326,21 @@ final class MacPluginBackendSessions: PluginBackendRequesting { hostServices: backend.hostServices ) let endBackendProcesses = endBackendProcesses - let credentials = credentials + let hostDispatcher = hostDispatcher session.stuckSendGrace = stuckSendGrace session.terminateProcess = { endBackendProcesses(backendToken, true) } session.onReleased = { endBackendProcesses(backendToken, false) } + // A new process starts with no subscriptions and nothing in flight. + hostDispatcher.endSession(pluginID: pluginID) + hostDispatcher.beginSession(pluginID: pluginID, hostServices: backend.hostServices) session.onHostRequest = { [weak session] request in - // Keychain calls block, so they run off the main actor. - Task.detached(priority: .userInitiated) { - let outcome = PluginHostServices.answer(request, pluginID: pluginID, credentials: credentials) - await session?.respond(to: request.id, with: outcome) + hostDispatcher.handle(request, pluginID: pluginID) { outcome in + session?.respond(to: request.id, with: outcome) } } + session.onHostRequestCancelled = { hostRequestID in + hostDispatcher.cancel(hostRequestID: hostRequestID, pluginID: pluginID) + } entry.session = session try await waitUntilReady(session, rootURL: backend.stateDirectory) try Task.checkCancellation() diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index f9dd1c5820..e1cffe3e5e 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -554,6 +554,17 @@ package protocol LanguageServerRuntimeCore: Sendable { params: ToolingJSONValue, timeout: TimeInterval ) -> Result + /// Like `requestPluginFunction`, naming the workspace context of the + /// call. Core forwards it only to a backend that negotiated function + /// protocol 3; a runtime without contexts drops it. + func requestPluginFunction( + sessionID: String, + pluginID: String, + functionID: String, + params: ToolingJSONValue, + context: PluginWorkspaceContextReference?, + timeout: TimeInterval + ) -> Result func cancelLanguageServerOperation(sessionID: String, operationID: String) func pollLanguageServerEvents(sessionID: String) -> [LanguageServerRuntimeEvent] func destroyLanguageServer(sessionID: String) @@ -803,6 +814,19 @@ package extension LanguageServerRuntimeCore { )) } + func requestPluginFunction( + sessionID: String, + pluginID: String, + functionID: String, + params: ToolingJSONValue, + context _: PluginWorkspaceContextReference?, + timeout: TimeInterval + ) -> Result { + requestPluginFunction( + sessionID: sessionID, pluginID: pluginID, functionID: functionID, params: params, timeout: timeout + ) + } + func requestPluginFunction( sessionID _: String, pluginID _: String, @@ -836,12 +860,29 @@ package struct PluginHostRequest: Equatable, Sendable { } /// The host could not complete a host-service request. The message reaches -/// the backend; never put a secret in it. +/// the backend; never put a secret in it. `code` is a `plugin.host.*` code +/// Core passes to a function protocol 3 backend; nil, or a code Core does +/// not pass, reaches it as `plugin.host.failed`. package struct PluginHostRequestFailure: Error, Equatable, Sendable { package let message: String + package let code: String? - package init(message: String) { + package init(message: String, code: String? = nil) { self.message = message + self.code = code + } +} + +/// One generation of one workspace context, as a call or a host request +/// names it (function protocol 3). Only the host decides whether it is +/// still current. +package struct PluginWorkspaceContextReference: Hashable, Sendable { + package let contextID: String + package let generation: Int + + package init(contextID: String, generation: Int) { + self.contextID = contextID + self.generation = generation } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index cc68ae2d59..f308656578 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -121,6 +121,10 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// it once through `respond(to:with:)`; without an owner the request is /// refused so the backend never waits for an answer that cannot come. package var onHostRequest: ((PluginHostRequest) -> Void)? + /// Receives the ID of a host request the backend cancelled with + /// `$/cancelRequest` (function protocol 3). Core has already answered + /// the backend; the owner stops the work and sends no answer. + package var onHostRequestCancelled: ((String) -> Void)? /// The VCS provider the process negotiated in `initialize`, as Core's /// `vcsProvider` event reported it; nil for every other session. package private(set) var vcsNegotiation: VcsProviderNegotiation? @@ -862,6 +866,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { package func invokeFunction( _ functionID: String, params: ToolingJSONValue, + context: PluginWorkspaceContextReference? = nil, timeout: TimeInterval, progress: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void ) async throws -> ToolingJSONValue { @@ -876,7 +881,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { let operation: LanguageServerRuntimeOperation switch await send(timeout: timeout, { core in core.requestPluginFunction( - sessionID: sessionID, pluginID: pluginID, functionID: functionID, params: params, timeout: timeout + sessionID: sessionID, pluginID: pluginID, functionID: functionID, params: params, + context: context, timeout: timeout ) }) { case .success(let started): operation = started @@ -1334,6 +1340,11 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { respond(to: id, with: .failure(PluginHostRequestFailure(message: "The host serves no requests of this process."))) } return false + case "hostRequestCancelled": + if case .object(let object)? = event.result, case .string(let id)? = object["hostRequestID"] { + onHostRequestCancelled?(id) + } + return false case "vcsProvider": switch Self.decodeEventResult(event, as: VcsProviderNegotiation.self) { case .success(let negotiation): vcsNegotiation = negotiation diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index bea2595b29..81dd81db9b 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -509,7 +509,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ "packageTrust": [1], - "backend": [1], + "backend": [1, 2], "buildProviders": [1, 2, 3, 4, 5, 6], "buildModelInputs": [1], "buildSystems": [1, 2], "debugAdapters": [1, 2], "executableResolution": [1], "executionProviders": [1], diff --git a/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift new file mode 100644 index 0000000000..b057e36b0a --- /dev/null +++ b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift @@ -0,0 +1,279 @@ +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheModuleAPI +import Testing + +/// The host side of function protocol 3: workspace contexts and their +/// generations, the dispatcher's own grant and context checks, the +/// `workspace` service, and when subscriptions are disposed. Every call is +/// awaited directly; nothing waits on a clock. +@MainActor +struct PluginHostServiceDispatchTests { + private static let pluginID = "dev.lithe.fixture.workspacebackend" + + /// A window keeps its context ID while its workspace changes; the + /// generation grows, so an older reference expires. + @Test + func reopeningAnotherWorkspaceKeepsTheIDWithTheNextGeneration() { + let contexts = PluginWorkspaceContexts() + let window = NSObject() + let first = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) + let same = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) + let second = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/billing"), remote: nil) + + #expect(first == same) + #expect(first.generation == 1) + #expect(second.contextID == first.contextID) + #expect(second.generation == 2) + #expect(throws: PluginHostServiceError.self) { try contexts.resolve(first.reference).get() } + #expect(try contexts.resolve(second.reference).get() == second) + + contexts.close(owner: window) + #expect(throws: PluginHostServiceError.self) { try contexts.resolve(second.reference).get() } + #expect(contexts.contexts.isEmpty) + } + + /// A root ID is stable for a location and reveals no path. + @Test + func rootIDsAreStableAndOpaque() { + let id = PluginWorkspaceContexts.rootID("/Users/me/orders") + #expect(id == PluginWorkspaceContexts.rootID("/Users/me/orders")) + #expect(id != PluginWorkspaceContexts.rootID("/Users/me/billing")) + #expect(id.count == 17) + #expect(id.hasPrefix("r")) + #expect(!id.contains("orders")) + } + + /// A session serves only its granted services, plus the implicit ones. + @Test + func aServiceTheSessionWasNotGrantedIsRefused() async { + let fixture = Fixture() + defer { fixture.remove() } + fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: ["credentials"]) + + let outcome = await fixture.dispatcher.dispatch( + PluginHostRequest(id: "h-1", method: "lithe/host/workspace/listContexts", service: "workspace", params: .null), + pluginID: Self.pluginID + ) + #expect(outcome.failureCode == PluginHostErrorCode.permissionDenied.rawValue) + + let notRunning = await fixture.dispatcher.dispatch( + PluginHostRequest(id: "h-2", method: "lithe/host/workspace/listContexts", service: "workspace", params: .null), + pluginID: "dev.example.other" + ) + #expect(notRunning.failureCode == PluginHostErrorCode.backendExited.rawValue) + } + + /// A granted service with no handler on this host is unavailable. + @Test + func aGrantedServiceWithoutAHandlerIsUnavailable() async { + let fixture = Fixture() + defer { fixture.remove() } + fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: ["files"]) + + let outcome = await fixture.dispatcher.dispatch( + PluginHostRequest(id: "h-1", method: "lithe/host/files/read", service: "files", params: .object([:])), + pluginID: Self.pluginID + ) + #expect(outcome.failureCode == PluginHostErrorCode.unavailable.rawValue) + } + + /// `listContexts` and `getContext` describe a local workspace with its + /// path and a state directory below the plugin's `${stateDir}`. + @Test + func aLocalContextIsDescribedWithItsPathAndStateDirectory() async throws { + let fixture = Fixture() + defer { fixture.remove() } + fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: ["workspace"]) + let window = NSObject() + let context = fixture.contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) + + let listed = try await fixture.dispatcher.dispatch( + PluginHostRequest(id: "h-1", method: "lithe/host/workspace/listContexts", service: "workspace", params: .null), + pluginID: Self.pluginID + ).get() + guard case .object(let list) = listed, case .array(let entries)? = list["contexts"] else { + Issue.record("listContexts returned \(listed)") + return + } + #expect(list["revision"] == .integer(fixture.contexts.revision)) + #expect(entries.count == 1) + + let described = try await fixture.dispatcher.dispatch( + PluginHostRequest( + id: "h-2", method: "lithe/host/workspace/getContext", service: "workspace", + params: .object(["context": Self.reference(context.reference)]) + ), + pluginID: Self.pluginID + ).get() + guard case .object(let object) = described, + case .string(let stateDirectory)? = object["stateDirectory"], + case .array(let folders)? = object["folders"], + case .object(let folder)? = folders.first else { + Issue.record("getContext returned \(described)") + return + } + #expect(entries.first == described) + #expect(object["authority"] == .object(["kind": .string("local")])) + #expect(folder["path"] == .string("/tmp/orders")) + #expect(folder["rootID"] == .string(PluginWorkspaceContexts.rootID("/tmp/orders"))) + let expectedRoot = fixture.stateRoot + .appendingPathComponent(Self.pluginID, isDirectory: true) + .appendingPathComponent("backend/workspaces", isDirectory: true) + #expect(stateDirectory.hasPrefix(expectedRoot.path)) + #expect(!stateDirectory.contains("orders")) + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: stateDirectory, isDirectory: &isDirectory) && isDirectory.boolValue) + } + + /// A remote workspace has a remote authority and no path: the local + /// directory is a placeholder that services must never read. + @Test + func aRemoteContextCarriesItsAuthorityAndNoPath() async throws { + let fixture = Fixture() + defer { fixture.remove() } + fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: ["workspace"]) + let window = NSObject() + let context = fixture.contexts.open( + owner: window, + rootURL: URL(fileURLWithPath: "/tmp/placeholder"), + remote: (connectionID: "conn-1", remoteRoot: "/srv/orders") + ) + + let folders = try await fixture.dispatcher.dispatch( + PluginHostRequest( + id: "h-1", method: "lithe/host/workspace/getFolders", service: "workspace", + params: .object(["context": Self.reference(context.reference)]) + ), + pluginID: Self.pluginID + ).get() + #expect(context.authority == .remote(id: "conn-1")) + #expect(context.name == "orders") + #expect(folders == .object(["folders": .array([.object([ + "rootID": .string(PluginWorkspaceContexts.rootID("remote:conn-1:/srv/orders")), + "name": .string("orders"), + ])])])) + } + + /// A request naming an older generation is told to ask again, and a + /// malformed reference is invalid params. + @Test + func aStaleOrMalformedContextIsRefused() async { + let fixture = Fixture() + defer { fixture.remove() } + fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: ["workspace"]) + let window = NSObject() + let old = fixture.contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) + fixture.contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/billing"), remote: nil) + + let stale = await fixture.dispatcher.dispatch( + PluginHostRequest( + id: "h-1", method: "lithe/host/workspace/getFolders", service: "workspace", + params: .object(["context": Self.reference(old.reference)]) + ), + pluginID: Self.pluginID + ) + #expect(stale.failureCode == PluginHostErrorCode.contextExpired.rawValue) + + let malformed = await fixture.dispatcher.dispatch( + PluginHostRequest( + id: "h-2", method: "lithe/host/workspace/getFolders", service: "workspace", + params: .object(["context": .object(["contextID": .string(old.contextID)])]) + ), + pluginID: Self.pluginID + ) + #expect(malformed.failureCode == PluginHostErrorCode.invalidParams.rawValue) + } + + /// Subscriptions go with `dispose`, with their context, and with the + /// session; an unknown ID disposes nothing and succeeds. + @Test + func subscriptionsAreDisposedByIDContextAndSession() async throws { + let fixture = Fixture() + defer { fixture.remove() } + fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: []) + let window = NSObject() + let context = fixture.contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) + let log = DisposalLog() + let subscriptions = fixture.dispatcher.subscriptions + let byID = subscriptions.add(pluginID: Self.pluginID, contextID: nil) { log.items.append("byID") } + _ = subscriptions.add(pluginID: Self.pluginID, contextID: context.contextID) { log.items.append("context") } + _ = subscriptions.add(pluginID: Self.pluginID, contextID: nil) { log.items.append("session") } + + for id in [byID, "s-unknown"] { + let outcome = try await fixture.dispatcher.dispatch( + PluginHostRequest( + id: "h-\(id)", method: "lithe/host/subscriptions/dispose", service: "subscriptions", + params: .object(["subscriptionID": .string(id)]) + ), + pluginID: Self.pluginID + ).get() + #expect(outcome == .null) + } + #expect(log.items == ["byID"]) + + fixture.contexts.close(owner: window) + #expect(log.items == ["byID", "context"]) + + fixture.dispatcher.endSession(pluginID: Self.pluginID) + #expect(log.items == ["byID", "context", "session"]) + #expect(subscriptions.count(pluginID: Self.pluginID) == 0) + } + + /// Typed failures keep their code on the way to Core. + @Test + func aServiceErrorBecomesAFailureWithItsCode() { + let failure = PluginHostServiceError(.versionConflict, "changed").failure + #expect(failure == PluginHostRequestFailure(message: "changed", code: "plugin.host.versionConflict")) + } + + /// Only a `backend` 2 starts on activation events; a manifest whose + /// version cannot be read counts as `backend` 1. + @Test + func theBackendVersionComesFromRequires() { + let v2 = Data(#"{"requires":{"backend":2,"serverProcess":1}}"#.utf8) + let v1 = Data(#"{"requires":{"backend":1}}"#.utf8) + #expect(MacPluginBackendSessions.backendVersion(ofManifest: v2) == 2) + #expect(MacPluginBackendSessions.backendVersion(ofManifest: v1) == 1) + #expect(MacPluginBackendSessions.backendVersion(ofManifest: Data("{}".utf8)) == 1) + } + + private static func reference(_ reference: PluginWorkspaceContextReference) -> ToolingJSONValue { + .object(["contextID": .string(reference.contextID), "generation": .integer(reference.generation)]) + } + + @MainActor + private final class DisposalLog { + var items: [String] = [] + } + + @MainActor + private struct Fixture { + let contexts = PluginWorkspaceContexts() + let dispatcher: PluginHostServiceDispatcher + let stateRoot: URL + + init() { + stateRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-host-dispatch-\(UUID().uuidString)", isDirectory: true) + dispatcher = PluginHostServiceDispatcher(contexts: contexts) + let directories = MacPluginStateDirectories(rootURL: stateRoot) + dispatcher.register(PluginWorkspaceHostService( + contexts: contexts, + backendStateDirectory: { directories.backendStateDirectory(pluginID: $0) } + )) + } + + func remove() { + try? FileManager.default.removeItem(at: stateRoot) + } + } +} + +private extension Result where Success == ToolingJSONValue, Failure == PluginHostRequestFailure { + var failureCode: String? { + if case .failure(let failure) = self { return failure.code } + return nil + } +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 4acee3de83..bcf4e95aa6 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9515,7 +9515,13 @@ "document.fileAccess.theSharedFileCouldNotBeFullySavedAllHardLinksMayContainPartialChangesYourEditorContentsAreRetainedCheckTheFileBeforeRetrying": "The shared file could not be fully saved. All hard links may contain partial changes. Your editor contents are retained; check the file before retrying.", "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "This file type cannot be opened in this editor", "document.fileAccess.updateAllHardLinks": "Update All Hard Links", - "document.fileAccess.savePermissionDenied": "Could not save {arg1}: permission denied. Check the file and parent folder permissions. Your edits are still unsaved." + "document.fileAccess.savePermissionDenied": "Could not save {arg1}: permission denied. Check the file and parent folder permissions. Your edits are still unsaved.", + "native.theWorkspaceContextIsNoLongerCurrent": "The workspace context is no longer current. Ask the host for it again.", + "native.thePluginBackendIsNoLongerRunning": "The plugin backend is no longer running.", + "native.thePluginIsNotAllowedToUseTheHostService": "The plugin is not allowed to use the host service {arg1}.", + "native.theParamsOfMustBeAnObject": "The params of {arg1} must be an object.", + "native.theWorkspaceContextReferenceIsMalformed": "The workspace context reference is malformed.", + "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "The host could not create the plugin's workspace state directory." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index d3d242a32e..2b016e06e1 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9515,7 +9515,13 @@ "document.fileAccess.theSharedFileCouldNotBeFullySavedAllHardLinksMayContainPartialChangesYourEditorContentsAreRetainedCheckTheFileBeforeRetrying": "未能完整保存共享文件。所有硬链接可能已包含部分修改。编辑器中的内容已保留,请检查文件后再重试。", "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "此编辑器不支持打开这种文件类型", "document.fileAccess.updateAllHardLinks": "更新所有硬链接", - "document.fileAccess.savePermissionDenied": "无法保存 {arg1}:权限不足。请检查文件及其父文件夹的权限,编辑内容尚未保存。" + "document.fileAccess.savePermissionDenied": "无法保存 {arg1}:权限不足。请检查文件及其父文件夹的权限,编辑内容尚未保存。", + "native.theWorkspaceContextIsNoLongerCurrent": "工作区上下文已失效,请重新向宿主获取。", + "native.thePluginBackendIsNoLongerRunning": "插件后端已不在运行。", + "native.thePluginIsNotAllowedToUseTheHostService": "插件无权使用宿主服务 {arg1}。", + "native.theParamsOfMustBeAnObject": "{arg1} 的参数必须是对象。", + "native.theWorkspaceContextReferenceIsMalformed": "工作区上下文引用格式不正确。", + "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "宿主无法创建插件的工作区状态目录。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index c391ee48c3..4de54c6eb8 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -90792,6 +90792,82 @@ "arg1" ] } + }, + { + "id": "native.theWorkspaceContextIsNoLongerCurrent", + "description": "Native UI: The workspace context is no longer current. Ask the host for it again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The workspace context is no longer current. Ask the host for it again.", + "arguments": [] + } + }, + { + "id": "native.thePluginBackendIsNoLongerRunning", + "description": "Native UI: The plugin backend is no longer running.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin backend is no longer running.", + "arguments": [] + } + }, + { + "id": "native.thePluginIsNotAllowedToUseTheHostService", + "description": "Native UI: The plugin is not allowed to use the host service %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin is not allowed to use the host service %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theParamsOfMustBeAnObject", + "description": "Native UI: The params of %@ must be an object.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The params of %@ must be an object.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "native.theWorkspaceContextReferenceIsMalformed", + "description": "Native UI: The workspace context reference is malformed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The workspace context reference is malformed.", + "arguments": [] + } + }, + { + "id": "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory", + "description": "Native UI: The host could not create the plugin's workspace state directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The host could not create the plugin's workspace state directory.", + "arguments": [] + } } ] } From 0d31138270fd0df9e22a53c941d4ca1fa53f02a0 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 10:26:14 +0800 Subject: [PATCH 04/16] =?UTF-8?q?test(macos):=20=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B5=8B=E8=AF=95=E7=BC=BA=E5=B0=91?= =?UTF-8?q?=E7=9A=84=20throws=20=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift index b057e36b0a..4a07bfcae5 100644 --- a/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift +++ b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift @@ -15,7 +15,7 @@ struct PluginHostServiceDispatchTests { /// A window keeps its context ID while its workspace changes; the /// generation grows, so an older reference expires. @Test - func reopeningAnotherWorkspaceKeepsTheIDWithTheNextGeneration() { + func reopeningAnotherWorkspaceKeepsTheIDWithTheNextGeneration() throws { let contexts = PluginWorkspaceContexts() let window = NSObject() let first = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) From 8ec0b10ddf04a7ec697bf7068678117de6db9510 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 00:28:19 +0800 Subject: [PATCH 05/16] =?UTF-8?q?feat(macos):=20=E4=B8=BA=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=20API-09/10=20=E6=8F=90=E4=BE=9B=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E5=86=85=E9=83=A8=E6=96=87=E6=A1=A3=E4=B8=8E=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 macos-plugin-api-plan 中 API-09(documents.list/getSnapshot、 editor.getActive/getSelections)和 API-10(editor.applyEdits/ replaceSelections/insertText)的 Mac 宿主内部服务,暂不对插件公开, 等 M0 分发层接入。 - EditorDocumentService:调用方必须显式传入窗口与工作区范围和文档 ID, 不回退到当前活动窗口;读取的是含未保存修改的内存缓冲区。 - 新增 EditorDocument.contentVersion 作为版本来源:每次文本变化单调 递增,不受保存/冲突决策改写,没有 WebView 时也存在,WebView 重载 后不归零。 - 坐标:行、列都从 1 开始,列按 UTF-16 码元计数;CR/LF/CRLF 各算 一次换行。只在 LSP 适配边界换算成从 0 开始。 - EditorTextEditPlanner 先校验整批编辑(越界、反向、拆开代理对、 重叠),全部通过才下发;插入的换行沿用文档首个换行符。 - Monaco 适配:同步比对桥接 revision;JS 端再核对 revision 和未确认 输入,有 IME 组字、只读、冻结或关闭中时拒绝;用前后两个 undo stop 加 pushEditOperations 作为一个撤销步骤应用;编辑消息带 origin, 用来回报这一批产生的精确版本。 - SourceText.apply 支持按批次指定 EOL。 - 补充 Swift 单测(emoji、组合字符、CRLF、中文、多选区、版本冲突、 只读、越界、重叠)和 bun 单测。 --- frontend/editor/src/plugin-edits.test.ts | 120 +++++++++++ frontend/editor/src/plugin-edits.ts | 103 +++++++++ frontend/editor/src/source-text.ts | 5 +- frontend/editor/src/workbench.ts | 47 +++- .../Features/EditorDocumentService.swift | 191 ++++++++++++++++ .../Features/EditorDocumentServiceTypes.swift | 140 ++++++++++++ .../Features/EditorTextEditPlanner.swift | 144 +++++++++++++ .../AppModel+EditorDocumentService.swift | 17 ++ .../Lithe/Models/Editor/EditorDocument.swift | 12 ++ .../MacOS/MonacoWorkbenchEditor.swift | 122 +++++++++++ .../EditorDocumentServiceTests.swift | 203 ++++++++++++++++++ .../EditorTextEditPlannerTests.swift | 99 +++++++++ 12 files changed, 1199 insertions(+), 4 deletions(-) create mode 100644 frontend/editor/src/plugin-edits.test.ts create mode 100644 frontend/editor/src/plugin-edits.ts create mode 100644 macos/Sources/Lithe/Application/Features/EditorDocumentService.swift create mode 100644 macos/Sources/Lithe/Application/Features/EditorDocumentServiceTypes.swift create mode 100644 macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift create mode 100644 macos/Tests/LitheTests/EditorDocumentServiceTests.swift create mode 100644 macos/Tests/LitheTests/EditorTextEditPlannerTests.swift diff --git a/frontend/editor/src/plugin-edits.test.ts b/frontend/editor/src/plugin-edits.test.ts new file mode 100644 index 0000000000..4477a6cf45 --- /dev/null +++ b/frontend/editor/src/plugin-edits.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test"; +import { CompositionTracker, applyPluginEdits, checkPluginEdits, savedPluginSelections, selectionView, toPluginSelections, + type PluginEdit, type PluginEditModel, type PluginEditView, type PluginRange } from "./plugin-edits"; + +/** Line model double with Monaco's validateRange clamping and an undo stack of + * stack elements: every pushStackElement closes the open element. */ +class FakeModel implements PluginEditModel { + lines: string[]; + undo: string[][] = []; + private open: string[] | undefined; + calls: string[] = []; + constructor(text: string) { this.lines = text.split("\n"); } + get value() { return this.lines.join("\n"); } + validateRange(range: PluginRange): PluginRange { + const clamp = (line: number, column: number) => { + const l = Math.min(Math.max(line, 1), this.lines.length); + return [l, Math.min(Math.max(column, 1), this.lines[l - 1].length + 1)]; + }; + const [startLineNumber, startColumn] = clamp(range.startLineNumber, range.startColumn); + const [endLineNumber, endColumn] = clamp(range.endLineNumber, range.endColumn); + return { startLineNumber, startColumn, endLineNumber, endColumn }; + } + pushStackElement() { this.calls.push("stop"); this.open = undefined; } + pushEditOperations(_before: unknown, operations: { range: PluginRange; text: string }[]) { + this.calls.push("edit"); + if (!this.open) { this.open = [...this.lines]; this.undo.push(this.open); } + const offsetOf = (line: number, column: number) => + this.lines.slice(0, line - 1).reduce((sum, text) => sum + text.length + 1, 0) + column - 1; + let text = this.value; + for (const { range, text: insert } of [...operations].sort((a, b) => + offsetOf(b.range.startLineNumber, b.range.startColumn) - offsetOf(a.range.startLineNumber, a.range.startColumn))) { + text = text.slice(0, offsetOf(range.startLineNumber, range.startColumn)) + insert + + text.slice(offsetOf(range.endLineNumber, range.endColumn)); + } + this.lines = text.split("\n"); + return null; + } + type(text: string) { this.pushEditOperations(null, [{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, text }]); } + undoOnce() { const previous = this.undo.pop(); if (previous) this.lines = previous; this.open = undefined; } +} + +class FakeView implements PluginEditView { + start?: () => void; end?: () => void; + constructor(public model: unknown, public focused = false, + public selections = [{ selectionStartLineNumber: 1, selectionStartColumn: 1, positionLineNumber: 1, positionColumn: 1 }]) {} + getModel() { return this.model; } + getSelections() { return this.selections; } + hasTextFocus() { return this.focused; } + onDidCompositionStart(listener: () => void) { this.start = listener; return { dispose() {} }; } + onDidCompositionEnd(listener: () => void) { this.end = listener; return { dispose() {} }; } +} + +const edit = (sl: number, sc: number, el: number, ec: number, text: string): PluginEdit => + ({ startLineNumber: sl, startColumn: sc, endLineNumber: el, endColumn: ec, text }); +const gate = (model: FakeModel, edits: PluginEdit[], overrides: Partial[0]> = {}) => + checkPluginEdits({ model, edits, readonly: false, busy: false, revision: 4, expectedRevision: 4, + pendingEdits: 0, composing: false, ...overrides }); + +test("multi-selection batch with emoji and Chinese is one undo element", () => { + const model = new FakeModel("a😀b\n中文"); + model.type(""); + const edits = [edit(1, 2, 1, 4, "X"), edit(2, 1, 2, 2, "英\n")]; + expect(gate(model, edits)).toBeUndefined(); + applyPluginEdits(model, edits, null); + expect(model.value).toBe("aXb\n英\n文"); + expect(model.calls.slice(-3)).toEqual(["stop", "edit", "stop"]); + // Typing right after the batch must not join its undo element. + model.type("z"); + model.undoOnce(); + model.undoOnce(); + expect(model.value).toBe("a😀b\n中文"); +}); + +test("rejects unacknowledged input, readonly, busy, composition and clamped ranges", () => { + const model = new FakeModel("ab"); + const ok = [edit(1, 1, 1, 3, "x")]; + expect(gate(model, ok, { revision: 5 })).toBe("versionConflict"); + expect(gate(model, ok, { pendingEdits: 1 })).toBe("versionConflict"); + expect(gate(model, ok, { readonly: true })).toBe("readOnly"); + expect(gate(model, ok, { busy: true })).toBe("busy"); + expect(gate(model, ok, { composing: true })).toBe("composition"); + expect(gate(model, [edit(1, 1, 1, 4, "x")])).toBe("outOfRange"); + expect(gate(model, [edit(2, 1, 2, 1, "x")])).toBe("outOfRange"); + expect(model.calls).toEqual([]); +}); + +test("composition is tracked per view showing the model", () => { + const model = {}, other = {}; + const tracker = new CompositionTracker(); + const showing = new FakeView(model), elsewhere = new FakeView(other); + tracker.track(showing); tracker.track(elsewhere); + elsewhere.start!(); + expect(tracker.isComposing(model, [showing, elsewhere])).toBe(false); + showing.start!(); + expect(tracker.isComposing(model, [showing, elsewhere])).toBe(true); + showing.end!(); + expect(tracker.isComposing(model, [showing, elsewhere])).toBe(false); + showing.start!(); tracker.forget(showing); + expect(tracker.isComposing(model, [showing])).toBe(false); +}); + +test("selections come from the focused view, keep direction and primary order", () => { + const model = {}; + const background = new FakeView(model); + const focused = new FakeView(model, true, [ + { selectionStartLineNumber: 2, selectionStartColumn: 5, positionLineNumber: 2, positionColumn: 1 }, + { selectionStartLineNumber: 1, selectionStartColumn: 1, positionLineNumber: 1, positionColumn: 1 }, + ]); + const view = selectionView(model, [new FakeView({}), background, focused], background); + expect(view).toBe(focused); + expect(toPluginSelections(view!.getSelections())).toEqual([ + { anchorLine: 2, anchorColumn: 5, activeLine: 2, activeColumn: 1 }, + { anchorLine: 1, anchorColumn: 1, activeLine: 1, activeColumn: 1 }, + ]); + expect(selectionView(model, [background], undefined)).toBe(background); + expect(selectionView(model, [new FakeView({})], undefined)).toBeUndefined(); + expect(savedPluginSelections({ cursorState: [{ selectionStart: { lineNumber: 3, column: 2 }, position: { lineNumber: 1, column: 1 } }] })) + .toEqual([{ anchorLine: 3, anchorColumn: 2, activeLine: 1, activeColumn: 1 }]); + expect(savedPluginSelections(null)).toEqual([]); +}); diff --git a/frontend/editor/src/plugin-edits.ts b/frontend/editor/src/plugin-edits.ts new file mode 100644 index 0000000000..233662c71d --- /dev/null +++ b/frontend/editor/src/plugin-edits.ts @@ -0,0 +1,103 @@ +// Host-internal plugin document edits (plugin API plan API-09/API-10). +// +// Positions are Monaco's one-based line/column in UTF-16 code units, the same +// numbers the native service uses, so no coordinate conversion happens here. +// The helpers take structural model/view types so they stay testable without +// the browser-only Monaco bundle. + +export type PluginRange = { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; +export type PluginEdit = PluginRange & { text: string }; +export type PluginSelection = { anchorLine: number; anchorColumn: number; activeLine: number; activeColumn: number }; +type MonacoSelection = { selectionStartLineNumber: number; selectionStartColumn: number; positionLineNumber: number; positionColumn: number }; + +export type PluginEditModel = { + validateRange(range: PluginRange): PluginRange; + pushStackElement(): void; + pushEditOperations(before: MonacoSelection[] | null, operations: { range: PluginRange; text: string; forceMoveMarkers?: boolean }[], + cursor: () => null): unknown; +}; +type Disposable = { dispose(): void }; +export type PluginEditView = { + getModel(): unknown; + getSelections(): MonacoSelection[] | null; + hasTextFocus(): boolean; + onDidCompositionStart(listener: () => void): Disposable; + onDidCompositionEnd(listener: () => void): Disposable; +}; + +export type PluginEditStatus = "applied" | "versionConflict" | "composition" | "readOnly" | "outOfRange" | "busy"; + +/** Tracks IME composition per view; a composing view owns uncommitted user input. */ +export class CompositionTracker { + private readonly composing = new Set(); + + track(view: PluginEditView): Disposable[] { + return [ + view.onDidCompositionStart(() => { this.composing.add(view); }), + view.onDidCompositionEnd(() => { this.composing.delete(view); }), + ]; + } + + forget(view: PluginEditView) { this.composing.delete(view); } + + /** True when any view showing `model` has an active composition. */ + isComposing(model: unknown, views: readonly PluginEditView[]): boolean { + return views.some(view => this.composing.has(view) && view.getModel() === model); + } +} + +/** Gate checked before anything is applied. Order: busy, readonly, version, composition, range. */ +export function checkPluginEdits(input: { + model: PluginEditModel; edits: readonly PluginEdit[]; readonly: boolean; busy: boolean; + revision: number; expectedRevision: number; pendingEdits: number; composing: boolean; +}): Exclude | undefined { + if (input.busy) return "busy"; + if (input.readonly) return "readOnly"; + // Local input increments the revision before it is acknowledged, so typing + // that Swift has not seen yet rejects the batch instead of being overwritten. + if (input.revision !== input.expectedRevision || input.pendingEdits > 0) return "versionConflict"; + if (input.composing) return "composition"; + for (const edit of input.edits) { + const valid = input.model.validateRange(edit); + if (valid.startLineNumber !== edit.startLineNumber || valid.startColumn !== edit.startColumn || + valid.endLineNumber !== edit.endLineNumber || valid.endColumn !== edit.endColumn) return "outOfRange"; + } + return undefined; +} + +/** Applies a checked batch as exactly one undo element. `text` must use LF. */ +export function applyPluginEdits(model: PluginEditModel, edits: readonly PluginEdit[], before: MonacoSelection[] | null) { + // Closing stack elements on both sides keeps neighbouring typing out of the + // batch's undo element, so one Undo restores the text from before the batch. + model.pushStackElement(); + model.pushEditOperations(before, edits.map(edit => ({ + range: { startLineNumber: edit.startLineNumber, startColumn: edit.startColumn, + endLineNumber: edit.endLineNumber, endColumn: edit.endColumn }, + text: edit.text, + })), () => null); + model.pushStackElement(); +} + +/** The view whose selections represent the document: focused, last focused, then any. */ +export function selectionView(model: unknown, views: readonly View[], lastFocused?: View): View | undefined { + const showing = views.filter(view => view.getModel() === model); + return showing.find(view => view.hasTextFocus()) ?? showing.find(view => view === lastFocused) ?? showing[0]; +} + +/** Monaco selections as anchor/active pairs, primary first as Monaco reports them. */ +export function toPluginSelections(selections: readonly MonacoSelection[] | null | undefined): PluginSelection[] { + return (selections ?? []).map(selection => ({ + anchorLine: selection.selectionStartLineNumber, anchorColumn: selection.selectionStartColumn, + activeLine: selection.positionLineNumber, activeColumn: selection.positionColumn, + })); +} + +type SavedCursor = { selectionStart: { lineNumber: number; column: number }; position: { lineNumber: number; column: number } }; + +/** Selections from a saved view state, used when no view currently shows the document. */ +export function savedPluginSelections(state: { cursorState: readonly SavedCursor[] } | null | undefined): PluginSelection[] { + return (state?.cursorState ?? []).map(cursor => ({ + anchorLine: cursor.selectionStart.lineNumber, anchorColumn: cursor.selectionStart.column, + activeLine: cursor.position.lineNumber, activeColumn: cursor.position.column, + })); +} diff --git a/frontend/editor/src/source-text.ts b/frontend/editor/src/source-text.ts index a7c7bf4a06..8fd6a030be 100644 --- a/frontend/editor/src/source-text.ts +++ b/frontend/editor/src/source-text.ts @@ -147,7 +147,8 @@ export class SourceText { } /** Translate a simultaneous Monaco batch to original-source UTF-16 edits. */ - apply(changes: readonly NormalizedTextChange[], version: number): SourceTextChange[] { + /** `eol` overrides the separator for inserted line breaks of this batch only. */ + apply(changes: readonly NormalizedTextChange[], version: number, eol: string = this.defaultEOL): SourceTextChange[] { const previous = this.current; const restoring = this.versions.has(version); const target = this.versions.get(version); @@ -155,7 +156,7 @@ export class SourceText { const translated = [...changes].sort((a, b) => a.rangeOffset - b.rangeOffset).map(change => { const normalized = change.text.replace(/\r\n|\r/g, "\n"); const text = restoring ? slice(target, change.rangeOffset + delta, normalized.length) - : normalized.replace(/\n/g, this.defaultEOL); + : normalized.replace(/\n/g, eol); delta += normalized.length - change.rangeLength; const start = sourceOffset(previous, change.rangeOffset); return { change, text, offset: start, diff --git a/frontend/editor/src/workbench.ts b/frontend/editor/src/workbench.ts index 1ed8a86462..5d10f1f9f9 100644 --- a/frontend/editor/src/workbench.ts +++ b/frontend/editor/src/workbench.ts @@ -4,6 +4,7 @@ import { IBulkEditService } from "monaco-editor/esm/vs/editor/browser/services/b import { StandaloneServices } from "monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js"; import { mountNativeFind, type NativeFindInput } from "./native-find"; import { mapCompletionKind } from "./completion-kind"; +import { CompositionTracker, applyPluginEdits, checkPluginEdits, savedPluginSelections, selectionView, toPluginSelections, type PluginEdit } from "./plugin-edits"; import { mountDiffReview, type DiffReviewInput, type ReviewSelection } from "./diff-review"; import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js"; import "monaco-editor/esm/vs/editor/editor.all.js"; @@ -137,6 +138,10 @@ export function mountWorkbench(host: WorkbenchHost) { } let updating = false; let failed = false; + // Set only while a plugin batch is applied, so its edit message names the + // batch (exact post-edit version) and the source mirror keeps the host's EOL. + let pluginEdit: { token: string; eol: string } | undefined; + const compositions = new CompositionTracker(); let activation: Promise = Promise.resolve(); let textmate: Awaited>; const semanticChanges = new Emitter(); @@ -323,10 +328,11 @@ export function mountWorkbench(host: WorkbenchHost) { scheduleNavigation(payload.id, owned); if (updating) return; const baseRevision = owned.revision++; - const changes = owned.source.apply(event.changes, model.getAlternativeVersionId()); + const origin = pluginEdit; + const changes = owned.source.apply(event.changes, model.getAlternativeVersionId(), origin?.eol); owned.pendingEdits++; owned.chain = owned.chain.then(async () => { - const reply = await send({ type: "edit", id: payload.id, baseRevision, changes }); + const reply = await send({ type: "edit", id: payload.id, baseRevision, changes, ...(origin ? { origin: origin.token } : {}) }); if (reply.revision !== owned.revision && reply.revision !== baseRevision + 1) throw new Error("Edit revision mismatch"); }).then(() => { owned.pendingEdits--; }, error => { owned.pendingEdits--; @@ -804,6 +810,40 @@ export function mountWorkbench(host: WorkbenchHost) { } const api = { + /** Selections of a document, primary first, with the revision they belong to. */ + async pluginSelections(payload: { id: string }) { + await activation; + const entry = entries.get(payload.id); + if (!entry) return { status: "documentNotFound" }; + await entry.chain.catch(() => undefined); + const view = selectionView(entry.model, allEditors(), lastFocusedView); + const selections = view ? toPluginSelections(view.getSelections()) : savedPluginSelections(entry.state); + return { status: "ok", revision: entry.revision, selections }; + }, + /** Applies a host-validated batch as one undo step, or rejects it untouched. */ + async applyPluginEdits(payload: { id: string; revision: number; token: string; eol: string; edits: PluginEdit[] }) { + // No await before the check: queued activation must not hide newer input, + // and the revision comparison below already rejects any unseen edit. + const entry = entries.get(payload.id); + if (!entry) return { status: "documentNotFound" }; + const views = allEditors(); + const status = checkPluginEdits({ + model: entry.model, edits: payload.edits, readonly: entry.readonly, + busy: failed || updating || entry.frozen || (entry.closingHolds ?? 0) > 0, + revision: entry.revision, expectedRevision: payload.revision, pendingEdits: entry.pendingEdits, + composing: compositions.isComposing(entry.model, views), + }); + if (status) return { status }; + const view = selectionView(entry.model, views, lastFocusedView); + pluginEdit = { token: payload.token, eol: payload.eol }; + try { + applyPluginEdits(entry.model, payload.edits, view?.getSelections() ?? null); + } finally { pluginEdit = undefined; } + // Resolve after Swift acknowledged the tagged edit so it can report the + // version produced by exactly this batch. + await entry.chain; + return { status: "applied", revision: entry.revision }; + }, async markdownScroll(payload: { id: string; ratio?: number } | null) { await activation; clearTimeout(markdownScrollTimer); markdownScrollTimer = undefined; @@ -949,6 +989,7 @@ export function mountWorkbench(host: WorkbenchHost) { attachDebugInteractions(view); surface = { editor: view, id: payload.id, states: new Map() }; surfaces.set(surfaceID, surface); + compositions.track(view); view.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { void send({ type: "save", id: surface!.id }).catch(fail); }); @@ -980,6 +1021,7 @@ export function mountWorkbench(host: WorkbenchHost) { surfaceOperations.set(surfaceID, (surfaceOperations.get(surfaceID) ?? 0) + 1); const surface = surfaces.get(surfaceID); if (!surface) return; + compositions.forget(surface.editor); surface.editor.dispose(); surfaces.delete(surfaceID); }, @@ -1364,6 +1406,7 @@ export function mountWorkbench(host: WorkbenchHost) { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { if (active) void send({ type: "save", id: active }).catch(fail); }); + compositions.track(editor); editor.onDidFocusEditorText(() => { lastFocusedView = editor; const position = editor.getPosition(); diff --git a/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift b/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift new file mode 100644 index 0000000000..b4bd97a564 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift @@ -0,0 +1,191 @@ +import Foundation +import LitheCoreContracts + +/// Host-internal document and editor access for plugin API-09 and API-10. +/// +/// Every call names its window/workspace scope and document. The service never +/// substitutes the frontmost window, and it reads the live buffer (including +/// unsaved edits), never the disk. Authorization, public error codes and +/// capability negotiation belong to the plugin dispatch layer in front of it. +@MainActor +protocol EditorDocumentServicing: AnyObject { + /// Open documents in tab order, then transient previews. + func listDocuments(scope: EditorDocumentScope) throws -> [EditorDocumentSummary] + func snapshot(scope: EditorDocumentScope, documentID: UUID) throws -> EditorDocumentSnapshot + /// The window's active text document, or `nil` when none is selected. + func activeDocument(scope: EditorDocumentScope) throws -> EditorDocumentSummary? + func selections(scope: EditorDocumentScope, documentID: UUID) async throws -> EditorSelectionsSnapshot + /// Validates the whole batch against `expectedVersion`, then applies it as one undo step. + func applyEdits(scope: EditorDocumentScope, documentID: UUID, expectedVersion: UInt64, + edits: [EditorTextEdit]) async throws -> EditorDocumentEditResult + /// Replaces previously read selections; `replacements` has one entry per + /// selection, or a single entry used for every selection. + func replaceSelections(scope: EditorDocumentScope, selections: EditorSelectionsSnapshot, + replacements: [String]) async throws -> EditorDocumentEditResult + func insertText(scope: EditorDocumentScope, documentID: UUID, expectedVersion: UInt64, + at position: EditorTextPosition, text: String) async throws -> EditorDocumentEditResult +} + +/// Selections reported by an editor surface, in one-based Monaco coordinates. +struct EditorSurfaceSelections: Sendable { + /// Unsorted, as the editor reports them; the first is the primary caret. + let selections: [EditorSelection] + /// Content version these selections belong to. + let version: UInt64 +} + +/// Edit batch handed to the surface after full validation. +struct EditorSurfaceEditRequest: Sendable { + let expectedVersion: UInt64 + /// Sorted, non-overlapping; ranges are one-based and `normalizedText` uses LF. + let edits: [PlannedEditorTextEdit] + /// Separator the document mirror must use for inserted line breaks. + let lineSeparator: String +} + +/// Port implemented by the editor adapter that owns a document's live buffer +/// (the Monaco session that drains its edits before save/close). +@MainActor +protocol EditorDocumentSurface: AnyObject { + func readSelections(of document: EditorDocument) async throws -> EditorSurfaceSelections + /// Applies the batch as one undo step only if the editor has no unacknowledged + /// input since `expectedVersion` and no active composition. Returns the + /// content version produced by exactly this batch. + func apply(_ request: EditorSurfaceEditRequest, to document: EditorDocument) async throws -> UInt64 +} + +@MainActor +final class EditorDocumentService: EditorDocumentServicing { + private let windowID: UUID + private let workspaceURL: @MainActor () -> URL? + private let documents: @MainActor () -> [EditorDocument] + private let activeDocumentID: @MainActor () -> UUID? + private let languageIdentifier: @MainActor (URL) -> String? + + init( + windowID: UUID, + workspaceURL: @escaping @MainActor () -> URL?, + documents: @escaping @MainActor () -> [EditorDocument], + activeDocumentID: @escaping @MainActor () -> UUID?, + languageIdentifier: @escaping @MainActor (URL) -> String? + ) { + self.windowID = windowID + self.workspaceURL = workspaceURL + self.documents = documents + self.activeDocumentID = activeDocumentID + self.languageIdentifier = languageIdentifier + } + + func listDocuments(scope: EditorDocumentScope) throws -> [EditorDocumentSummary] { + try validate(scope) + return documents().map { summary(of: $0, index: EditorTextLineIndex($0.text)) } + } + + func snapshot(scope: EditorDocumentScope, documentID: UUID) throws -> EditorDocumentSnapshot { + let document = try resolve(scope, documentID) + // Text and version are read together on the main actor, so they always + // describe the same buffer state. Keystrokes still crossing the editor + // bridge appear in a later version and make edits based on this one fail. + return EditorDocumentSnapshot(summary: summary(of: document, index: EditorTextLineIndex(document.text)), + text: document.text) + } + + func activeDocument(scope: EditorDocumentScope) throws -> EditorDocumentSummary? { + try validate(scope) + guard let id = activeDocumentID(), let document = documents().first(where: { $0.id == id }) else { return nil } + return summary(of: document, index: EditorTextLineIndex(document.text)) + } + + func selections(scope: EditorDocumentScope, documentID: UUID) async throws -> EditorSelectionsSnapshot { + let document = try resolve(scope, documentID) + guard let surface = document.editorSurface else { throw EditorDocumentServiceError.editorUnavailable } + let reported = try await surface.readSelections(of: document) + let current = try resolve(scope, documentID) + guard current === document else { throw EditorDocumentServiceError.documentNotFound } + guard reported.version == document.contentVersion else { + throw EditorDocumentServiceError.versionConflict(expected: reported.version, actual: document.contentVersion) + } + let index = EditorTextLineIndex(document.text) + guard reported.selections.allSatisfy({ index.offset(of: $0.anchor) != nil && index.offset(of: $0.active) != nil }) else { + throw EditorDocumentServiceError.editorUnavailable + } + let primary = reported.selections.first + let sorted = reported.selections.sorted { + ($0.range.start, $0.range.end) < ($1.range.start, $1.range.end) + } + return EditorSelectionsSnapshot(documentID: documentID, version: reported.version, selections: sorted, + primaryIndex: primary.flatMap { sorted.firstIndex(of: $0) }) + } + + func applyEdits(scope: EditorDocumentScope, documentID: UUID, expectedVersion: UInt64, + edits: [EditorTextEdit]) async throws -> EditorDocumentEditResult { + let document = try resolve(scope, documentID) + guard !document.isReadOnly else { throw EditorDocumentServiceError.readOnly } + guard document.contentVersion == expectedVersion else { + throw EditorDocumentServiceError.versionConflict(expected: expectedVersion, actual: document.contentVersion) + } + let index = EditorTextLineIndex(document.text) + let plan = try EditorTextEditPlanner.plan(edits, in: index) + guard !plan.isEmpty else { + return EditorDocumentEditResult(documentID: documentID, version: expectedVersion, applied: false) + } + guard let surface = document.editorSurface else { throw EditorDocumentServiceError.editorUnavailable } + // No suspension between the checks above and this call: the surface + // compares the editor's own revision against the same instant. + let version = try await surface.apply( + EditorSurfaceEditRequest(expectedVersion: expectedVersion, edits: plan, + lineSeparator: index.insertionSeparator), + to: document) + return EditorDocumentEditResult(documentID: documentID, version: version, applied: true) + } + + func replaceSelections(scope: EditorDocumentScope, selections: EditorSelectionsSnapshot, + replacements: [String]) async throws -> EditorDocumentEditResult { + let count = selections.selections.count + guard replacements.count == count || (replacements.count == 1 && count > 0) else { + throw EditorDocumentServiceError.replacementCountMismatch(selections: count, replacements: replacements.count) + } + let edits = selections.selections.enumerated().map { offset, selection in + EditorTextEdit(range: selection.range, text: replacements.count == 1 ? replacements[0] : replacements[offset]) + } + return try await applyEdits(scope: scope, documentID: selections.documentID, + expectedVersion: selections.version, edits: edits) + } + + func insertText(scope: EditorDocumentScope, documentID: UUID, expectedVersion: UInt64, + at position: EditorTextPosition, text: String) async throws -> EditorDocumentEditResult { + try await applyEdits(scope: scope, documentID: documentID, expectedVersion: expectedVersion, + edits: [EditorTextEdit(range: EditorTextRange(start: position, end: position), text: text)]) + } + + private func validate(_ scope: EditorDocumentScope) throws { + guard scope.windowID == windowID, + scope.workspaceURL?.standardizedFileURL == workspaceURL()?.standardizedFileURL else { + throw EditorDocumentServiceError.contextMismatch + } + } + + private func resolve(_ scope: EditorDocumentScope, _ documentID: UUID) throws -> EditorDocument { + try validate(scope) + guard let document = documents().first(where: { $0.id == documentID }) else { + throw EditorDocumentServiceError.documentNotFound + } + return document + } + + private func summary(of document: EditorDocument, index: EditorTextLineIndex) -> EditorDocumentSummary { + EditorDocumentSummary( + documentID: document.id, version: document.contentVersion, + workspaceRelativePath: relativePath(of: document.url), languageID: languageIdentifier(document.url), + isReadOnly: document.isReadOnly, isDirty: document.isDirty, encoding: document.readEncoding, + lineEnding: index.lineEnding) + } + + private func relativePath(of url: URL) -> String? { + guard let root = workspaceURL()?.standardizedFileURL.path else { return nil } + let path = url.standardizedFileURL.path + let prefix = root.hasSuffix("/") ? root : root + "/" + guard path.hasPrefix(prefix) else { return nil } + return String(path.dropFirst(prefix.count)) + } +} diff --git a/macos/Sources/Lithe/Application/Features/EditorDocumentServiceTypes.swift b/macos/Sources/Lithe/Application/Features/EditorDocumentServiceTypes.swift new file mode 100644 index 0000000000..907df4bfd8 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/EditorDocumentServiceTypes.swift @@ -0,0 +1,140 @@ +import Foundation +import LitheCoreContracts + +// Value types for the host-internal document/editor service (plugin API plan +// API-09/API-10). They are deliberately free of WebKit, Monaco and SwiftUI so the +// future plugin dispatch layer can map them onto its public contract. +// +// Coordinates: lines and columns are both one-based. A column counts UTF-16 code +// units (the LSP unit), excluding the line terminator, so the valid columns of a +// line are `1...lineLength + 1`. CR, LF and CRLF each end one line. Ranges are +// half-open `[start, end)`. Monaco uses the same one-based numbers; LSP adapters +// subtract one from both line and column at their own boundary. + +/// Explicit caller context. The service never falls back to the frontmost window. +struct EditorDocumentScope: Hashable, Sendable { + /// The `AppModel.id` of the window that owns the documents. + let windowID: UUID + /// The workspace root the caller was authorised for; `nil` means no workspace. + let workspaceURL: URL? +} + +struct EditorTextPosition: Hashable, Comparable, Sendable { + /// One-based line. + let line: Int + /// One-based UTF-16 column. + let column: Int + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.line == rhs.line ? lhs.column < rhs.column : lhs.line < rhs.line + } +} + +/// Half-open range. `start` must not be after `end`. +struct EditorTextRange: Hashable, Sendable { + let start: EditorTextPosition + let end: EditorTextPosition + + var isEmpty: Bool { start == end } +} + +struct EditorSelection: Hashable, Sendable { + /// Where the selection was started. + let anchor: EditorTextPosition + /// Where the caret is; may precede `anchor` for a backwards selection. + let active: EditorTextPosition + + var range: EditorTextRange { EditorTextRange(start: min(anchor, active), end: max(anchor, active)) } + var isEmpty: Bool { anchor == active } + var isReversed: Bool { active < anchor } +} + +/// Selections captured at one document version. +struct EditorSelectionsSnapshot: Hashable, Sendable { + let documentID: UUID + let version: UInt64 + /// Sorted by range start, then range end. Empty when no editor view has ever + /// shown the document. + let selections: [EditorSelection] + /// Index into `selections` of the editor's primary caret. + let primaryIndex: Int? +} + +enum EditorLineEnding: String, Hashable, Sendable { + case lf, crlf, cr + /// More than one terminator style. Inserted line breaks use the first one. + case mixed + + var separator: String? { + switch self { + case .lf: "\n" + case .crlf: "\r\n" + case .cr: "\r" + case .mixed: nil + } + } +} + +/// Metadata of an open document. `version` is the document owner's content +/// version (see `EditorDocument.contentVersion`). +struct EditorDocumentSummary: Hashable, Sendable { + let documentID: UUID + let version: UInt64 + /// `/`-separated path relative to the scope's workspace, or `nil` outside it. + let workspaceRelativePath: String? + let languageID: String? + let isReadOnly: Bool + let isDirty: Bool + /// Codec the in-memory text was decoded with. + let encoding: DocumentEncoding + let lineEnding: EditorLineEnding +} + +/// Full live buffer, including unsaved edits; never re-read from disk. +struct EditorDocumentSnapshot: Hashable, Sendable { + let summary: EditorDocumentSummary + let text: String +} + +struct EditorTextEdit: Hashable, Sendable { + let range: EditorTextRange + /// Line breaks of any style are written with the document's line ending. + let text: String +} + +struct EditorDocumentEditResult: Hashable, Sendable { + let documentID: UUID + /// Version immediately after this batch, before any later user input. + let version: UInt64 + /// `false` when the batch was empty and nothing changed. + let applied: Bool +} + +enum EditorTextInvalidRangeReason: String, Hashable, Sendable { + /// `start` is after `end`. + case reversed + /// An endpoint falls between the two code units of a surrogate pair. + case splitsSurrogatePair +} + +/// Stable failure classes; the dispatch layer maps them to public error codes. +enum EditorDocumentServiceError: Error, Hashable, Sendable { + /// The scope does not name this window or its current workspace. + case contextMismatch + /// The document is unknown to the scope or has been closed. + case documentNotFound + case readOnly + /// The caller's version is not the current one. `actual` lets callers re-read. + case versionConflict(expected: UInt64, actual: UInt64) + /// A position names a line or column that does not exist. + case rangeOutOfBounds(editIndex: Int) + case invalidRange(editIndex: Int, reason: EditorTextInvalidRangeReason) + /// Two edits overlap. Indices refer to the caller's original order. + case overlappingRanges(editIndex: Int, otherEditIndex: Int) + /// An input-method composition is active; retry after it commits. + case compositionInProgress + /// Selection replacement needs one text, or one per selection. + case replacementCountMismatch(selections: Int, replacements: Int) + /// No live editor owns the buffer, or it is synchronizing/failed. Retryable. + case editorUnavailable +} diff --git a/macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift b/macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift new file mode 100644 index 0000000000..88cffe8734 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/EditorTextEditPlanner.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Line table of a text in UTF-16 code units. CR, LF and CRLF each terminate one +/// line, matching Monaco's normalized model and LSP. +struct EditorTextLineIndex: Sendable { + let units: [UInt16] + /// UTF-16 offset of the first unit of each line. + private(set) var lineStarts: [Int] = [0] + /// UTF-16 offset just before each line's terminator (or the text end). + private(set) var contentEnds: [Int] = [] + let lineEnding: EditorLineEnding + /// Separator written for inserted line breaks: the first one in the text, LF when none. + let insertionSeparator: String + + init(_ text: String) { + units = Array(text.utf16) + var seen: Set = [] + var first: String? + var index = 0 + while index < units.count { + let unit = units[index] + guard unit == 13 || unit == 10 else { index += 1; continue } + contentEnds.append(index) + let separator: String + if unit == 13, index + 1 < units.count, units[index + 1] == 10 { + separator = "\r\n"; index += 2 + } else { + separator = unit == 13 ? "\r" : "\n"; index += 1 + } + if first == nil { first = separator } + seen.insert(separator) + lineStarts.append(index) + } + contentEnds.append(units.count) + switch seen.count { + case 0, 1: + lineEnding = first == "\r\n" ? .crlf : first == "\r" ? .cr : .lf + default: + lineEnding = .mixed + } + insertionSeparator = first ?? "\n" + } + + var lineCount: Int { lineStarts.count } + + /// UTF-16 offset of a one-based position, or `nil` when it does not exist. + func offset(of position: EditorTextPosition) -> Int? { + guard position.line >= 1, position.line <= lineCount, position.column >= 1 else { return nil } + let start = lineStarts[position.line - 1] + let offset = start + position.column - 1 + return offset <= contentEnds[position.line - 1] ? offset : nil + } + + /// One-based position of a UTF-16 offset that is not inside a CRLF pair. + func position(at offset: Int) -> EditorTextPosition { + let clamped = min(max(offset, 0), units.count) + // Last line start not after the offset. + var low = 0, high = lineStarts.count - 1 + while low < high { + let mid = (low + high + 1) / 2 + if lineStarts[mid] <= clamped { low = mid } else { high = mid - 1 } + } + let column = min(clamped, contentEnds[low]) - lineStarts[low] + 1 + return EditorTextPosition(line: low + 1, column: column) + } + + /// True when `offset` lies between a high and a low surrogate. + func splitsSurrogatePair(at offset: Int) -> Bool { + guard offset > 0, offset < units.count else { return false } + return UTF16.isLeadSurrogate(units[offset - 1]) && UTF16.isTrailSurrogate(units[offset]) + } +} + +/// One validated edit in UTF-16 offsets of the source text. +struct PlannedEditorTextEdit: Hashable, Sendable { + /// Position of the edit in the caller's batch. + let index: Int + let range: EditorTextRange + let utf16Offset: Int + let utf16Length: Int + /// Replacement with line breaks written in the document's insertion separator. + let text: String + /// Replacement with LF line breaks, as Monaco's normalized model expects. + let normalizedText: String +} + +/// Validates a whole batch before anything is applied: every range must exist, +/// keep surrogate pairs intact and not overlap another edit. Zero-length +/// insertions at the same position are allowed and keep the caller's order. +enum EditorTextEditPlanner { + static func plan(_ edits: [EditorTextEdit], in index: EditorTextLineIndex) throws -> [PlannedEditorTextEdit] { + var planned: [PlannedEditorTextEdit] = [] + planned.reserveCapacity(edits.count) + for (position, edit) in edits.enumerated() { + guard let start = index.offset(of: edit.range.start), let end = index.offset(of: edit.range.end) else { + throw EditorDocumentServiceError.rangeOutOfBounds(editIndex: position) + } + guard start <= end else { + throw EditorDocumentServiceError.invalidRange(editIndex: position, reason: .reversed) + } + guard !index.splitsSurrogatePair(at: start), !index.splitsSurrogatePair(at: end) else { + throw EditorDocumentServiceError.invalidRange(editIndex: position, reason: .splitsSurrogatePair) + } + let normalized = normalizeLineBreaks(edit.text, to: "\n") + planned.append(PlannedEditorTextEdit( + index: position, range: edit.range, utf16Offset: start, utf16Length: end - start, + text: index.insertionSeparator == "\n" ? normalized + : normalized.replacingOccurrences(of: "\n", with: index.insertionSeparator), + normalizedText: normalized)) + } + // Stable order: by start, then end, then the caller's order. + planned.sort { + ($0.utf16Offset, $0.utf16Offset + $0.utf16Length, $0.index) + < ($1.utf16Offset, $1.utf16Offset + $1.utf16Length, $1.index) + } + for (previous, next) in zip(planned, planned.dropFirst()) + where next.utf16Offset < previous.utf16Offset + previous.utf16Length { + throw EditorDocumentServiceError.overlappingRanges( + editIndex: min(previous.index, next.index), otherEditIndex: max(previous.index, next.index)) + } + return planned + } + + /// Applies a validated plan to the text it was planned against. + static func apply(_ plan: [PlannedEditorTextEdit], to text: String) -> String { + let result = NSMutableString(string: text) + for edit in plan.reversed() { + result.replaceCharacters(in: NSRange(location: edit.utf16Offset, length: edit.utf16Length), with: edit.text) + } + return result as String + } + + static func normalizeLineBreaks(_ text: String, to separator: String) -> String { + guard text.contains(where: { $0 == "\r" || $0 == "\n" || $0 == "\r\n" }) else { return text } + var result = "" + result.reserveCapacity(text.utf16.count) + // Swift treats CRLF as one Character, so each Character is one line break. + for character in text { + if character == "\r\n" || character == "\r" || character == "\n" { result += separator } + else { result.append(character) } + } + return result + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift new file mode 100644 index 0000000000..5de22e5afb --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift @@ -0,0 +1,17 @@ +import Foundation +import LitheCoreContracts + +extension AppModel { + /// Document/editor service bound to this window. The plugin dispatch layer + /// creates one per window and passes the caller's scope on every call. + func makeEditorDocumentService() -> EditorDocumentService { + EditorDocumentService( + windowID: id, + workspaceURL: { [weak self] in self?.workspaceURL }, + documents: { [weak self] in self?.documentFeature.editorDocuments ?? [] }, + activeDocumentID: { [weak self] in self?.documentFeature.activeDocumentID }, + languageIdentifier: { [weak self] url in + self?.languageProviderCatalog.provider(for: url)?.languageIdentifier(for: url) + }) + } +} diff --git a/macos/Sources/Lithe/Models/Editor/EditorDocument.swift b/macos/Sources/Lithe/Models/Editor/EditorDocument.swift index c6ccb01e37..cffe75ee9e 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorDocument.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorDocument.swift @@ -68,6 +68,16 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable actions.forEach { $0(result) } } } + /// Live editor surface for plugin reads/edits; installed and cleared by the + /// same adapter session that owns ``synchronizeEditor``. + var editorSurface: (any EditorDocumentSurface)? + /// Monotonic version of ``text``. It increases on every buffer change, + /// including undo, external replacement and reload, and never decreases, so + /// equal versions always mean identical text. Unlike `lifecycleState.revision` + /// it is not rewritten by save/conflict decisions, and unlike a Monaco + /// version it exists for buffers without a web view and survives web view + /// reloads. Plugin edits name this version to detect concurrent input. + private(set) var contentVersion: UInt64 = 1 private var storedText: String var text: String { get { storedText } @@ -269,6 +279,7 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable objectWillChange.send() } storedText = newText + contentVersion &+= 1 lifecycleState = nextLifecycle textDidChange.send() } @@ -310,6 +321,7 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable } func replaceWithDiskContent(_ contents: String, encoding: DocumentEncoding? = nil, identity: String? = nil) { + if storedText != contents { contentVersion &+= 1 } storedText = contents textDidChange.send() savedText = contents diff --git a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift index 5db8f8f038..7723566c4d 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift @@ -327,6 +327,9 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private var lastGitStates: [String: Data] = [:] private var lastMarkers: [String: [EditorDiagnostic]] = [:] private var synchronizingIDs: Set = [] + /// Content version recorded when an origin-tagged plugin edit crossed the + /// bridge, keyed by the edit token until the applying call collects it. + private var pluginEditAcknowledgements: [String: UInt64] = [:] private var markdownScrollBinding: Binding? private var markdownScrollID: String? private var markdownScrollRevision: UInt64? @@ -539,6 +542,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele document.synchronizeEditor = { [self] completion in self.synchronize(id: id, completion: completion) } + document.editorSurface = MonacoDocumentSurface(session: self) document.holdEditorForClose = { [self, weak document] completion in guard let document else { completion(.failure(EditorDocument.DocumentError.editorNotSynchronized)); return } let token = UUID().uuidString @@ -818,6 +822,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele documentReadOnlyStates[oldID] = nil if let old = documents[oldID], old.editorSynchronizationOwnerID == synchronizationOwnerID { old.synchronizeEditor = nil; old.holdEditorForClose = nil; old.editorSynchronizationOwnerID = nil + old.editorSurface = nil } completionLists[oldID] = nil codeActionLists[oldID] = nil @@ -1330,6 +1335,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele } applyingEdit = false revisions[id, default: 0] += 1 + if let origin = body["origin"] as? String { pluginEditAcknowledgements[origin] = document.contentVersion } model?.documentFeature.promotePreviewDocument(document) model?.documentDidChange(document) if let model { refreshDebugState(model: model) } @@ -1631,3 +1637,119 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele pending.values.forEach { $0(.failure(EditorDocument.DocumentError.editorNotSynchronized)) } } } + +// MARK: - Plugin document surface + +/// Adapter from the host-internal ``EditorDocumentSurface`` port to the Monaco +/// bridge. Version checks compare the bridge revision captured synchronously with +/// the document's ``EditorDocument/contentVersion``; Monaco repeats the check +/// against its own revision, so input not yet acknowledged by Swift rejects the edit. +@MainActor +private final class MonacoDocumentSurface: EditorDocumentSurface { + private weak var session: MonacoWorkbenchSession? + + init(session: MonacoWorkbenchSession) { self.session = session } + + func readSelections(of document: EditorDocument) async throws -> EditorSurfaceSelections { + guard let session else { throw EditorDocumentServiceError.editorUnavailable } + return try await session.readPluginSelections(of: document) + } + + func apply(_ request: EditorSurfaceEditRequest, to document: EditorDocument) async throws -> UInt64 { + guard let session else { throw EditorDocumentServiceError.editorUnavailable } + return try await session.applyPluginEdits(request, to: document) + } +} + +/// Sendable decoding of the editor's reply so no `Any` crosses the continuation. +private struct MonacoPluginReply: Sendable { + let status: String + let revision: Int? + let selections: [EditorSelection] + + init(_ value: Any?) { + let body = value as? [String: Any] ?? [:] + status = body["status"] as? String ?? "invalid" + revision = body["revision"] as? Int + selections = (body["selections"] as? [[String: Any]] ?? []).compactMap { item in + guard let anchorLine = item["anchorLine"] as? Int, let anchorColumn = item["anchorColumn"] as? Int, + let activeLine = item["activeLine"] as? Int, let activeColumn = item["activeColumn"] as? Int else { return nil } + return EditorSelection(anchor: EditorTextPosition(line: anchorLine, column: anchorColumn), + active: EditorTextPosition(line: activeLine, column: activeColumn)) + } + } +} + +extension MonacoWorkbenchSession { + private func pluginCall(_ script: String, payload: [String: Any]) async throws -> MonacoPluginReply { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + call(script, arguments: ["payload": payload]) { result in + switch result { + case .success(let value): continuation.resume(returning: MonacoPluginReply(value)) + case .failure: continuation.resume(throwing: EditorDocumentServiceError.editorUnavailable) + } + } + } + } + + /// The bridge revision of a document this session owns and can currently serve. + private func pluginRevision(of document: EditorDocument) throws -> (id: String, revision: Int) { + let id = document.id.uuidString + guard currentDocument(id) === document else { throw EditorDocumentServiceError.documentNotFound } + guard ready, !failed, webView != nil, !synchronizingIDs.contains(id), !unconfirmedModels.contains(id), + let revision = revisions[id] else { throw EditorDocumentServiceError.editorUnavailable } + return (id, revision) + } + + fileprivate func readPluginSelections(of document: EditorDocument) async throws -> EditorSurfaceSelections { + let (id, _) = try pluginRevision(of: document) + let reply = try await pluginCall("window.lithe.pluginSelections(payload)", payload: ["id": id]) + guard currentDocument(id) === document else { throw EditorDocumentServiceError.documentNotFound } + guard reply.status == "ok", let revision = reply.revision else { + throw reply.status == "documentNotFound" ? EditorDocumentServiceError.documentNotFound + : EditorDocumentServiceError.editorUnavailable + } + // A differing revision means keystrokes are still crossing the bridge; + // the selections then describe text this side has not seen yet. + guard revision == revisions[id] else { + throw EditorDocumentServiceError.versionConflict(expected: document.contentVersion, actual: document.contentVersion &+ 1) + } + return EditorSurfaceSelections(selections: reply.selections, version: document.contentVersion) + } + + fileprivate func applyPluginEdits(_ request: EditorSurfaceEditRequest, to document: EditorDocument) async throws -> UInt64 { + let (id, revision) = try pluginRevision(of: document) + guard !isReadOnly(document) else { throw EditorDocumentServiceError.readOnly } + guard document.contentVersion == request.expectedVersion else { + throw EditorDocumentServiceError.versionConflict(expected: request.expectedVersion, actual: document.contentVersion) + } + let token = UUID().uuidString + defer { pluginEditAcknowledgements[token] = nil } + let edits: [[String: Any]] = request.edits.map { edit in + ["startLineNumber": edit.range.start.line, "startColumn": edit.range.start.column, + "endLineNumber": edit.range.end.line, "endColumn": edit.range.end.column, "text": edit.normalizedText] + } + let reply = try await pluginCall("window.lithe.applyPluginEdits(payload)", payload: [ + "id": id, "revision": revision, "token": token, "eol": request.lineSeparator, "edits": edits, + ]) + switch reply.status { + case "applied": + // The tagged edit was acknowledged before the editor replied, so its + // version is exact even if newer keystrokes have arrived since. + if let version = pluginEditAcknowledgements[token] { return version } + // Monaco drops edits that rewrite identical text; nothing crossed + // the bridge, so the buffer is still at the expected version. + if reply.revision == revision, document.contentVersion == request.expectedVersion { return request.expectedVersion } + throw EditorDocumentServiceError.editorUnavailable + case "versionConflict": + throw EditorDocumentServiceError.versionConflict( + expected: request.expectedVersion, actual: max(document.contentVersion, request.expectedVersion &+ 1)) + case "composition": throw EditorDocumentServiceError.compositionInProgress + case "readOnly": throw EditorDocumentServiceError.readOnly + case "documentNotFound": throw EditorDocumentServiceError.documentNotFound + case "outOfRange": + throw EditorDocumentServiceError.rangeOutOfBounds(editIndex: request.edits.first?.index ?? 0) + default: throw EditorDocumentServiceError.editorUnavailable + } + } +} diff --git a/macos/Tests/LitheTests/EditorDocumentServiceTests.swift b/macos/Tests/LitheTests/EditorDocumentServiceTests.swift new file mode 100644 index 0000000000..7a60a8d7cd --- /dev/null +++ b/macos/Tests/LitheTests/EditorDocumentServiceTests.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing +@testable import Lithe + +private func at(_ line: Int, _ column: Int) -> EditorTextPosition { EditorTextPosition(line: line, column: column) } + +/// In-memory editor surface: applies validated batches to the document the way +/// the Monaco adapter does, as one step, and can simulate editor refusals. +@MainActor +private final class FakeEditorSurface: EditorDocumentSurface { + var selections: [EditorSelection] = [] + var failure: EditorDocumentServiceError? + private(set) var requests: [EditorSurfaceEditRequest] = [] + private(set) var undoStack: [String] = [] + + func readSelections(of document: EditorDocument) async throws -> EditorSurfaceSelections { + if let failure { throw failure } + return EditorSurfaceSelections(selections: selections, version: document.contentVersion) + } + + func apply(_ request: EditorSurfaceEditRequest, to document: EditorDocument) async throws -> UInt64 { + requests.append(request) + if let failure { throw failure } + guard document.contentVersion == request.expectedVersion else { + throw EditorDocumentServiceError.versionConflict(expected: request.expectedVersion, actual: document.contentVersion) + } + undoStack.append(document.text) + document.text = EditorTextEditPlanner.apply(request.edits, to: document.text) + return document.contentVersion + } + + func undo(_ document: EditorDocument) { if let previous = undoStack.popLast() { document.text = previous } } +} + +@MainActor +@Suite("Plugin document and editor service") +struct EditorDocumentServiceTests { + private let windowID = UUID() + private let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) + + private struct Fixture { + let service: EditorDocumentService + let document: EditorDocument + let surface: FakeEditorSurface + let scope: EditorDocumentScope + } + + private func fixture(_ text: String, readOnly: Bool = false, attachSurface: Bool = true) -> Fixture { + let document = EditorDocument(url: workspace.appendingPathComponent("src/Main.swift"), text: text, + modificationDate: nil, isReadOnly: readOnly) + let surface = FakeEditorSurface() + if attachSurface { document.editorSurface = surface } + let workspace = self.workspace + let service = EditorDocumentService( + windowID: windowID, workspaceURL: { workspace }, documents: { [document] }, + activeDocumentID: { document.id }, languageIdentifier: { _ in "swift" }) + return Fixture(service: service, document: document, surface: surface, + scope: EditorDocumentScope(windowID: windowID, workspaceURL: workspace)) + } + + @Test func snapshotReturnsUnsavedTextAndMetadata() throws { + let f = fixture("let a = 1\r\n") + f.document.text = "let a = 2\r\n" + let snapshot = try f.service.snapshot(scope: f.scope, documentID: f.document.id) + #expect(snapshot.text == "let a = 2\r\n") + #expect(snapshot.summary.isDirty) + #expect(snapshot.summary.version == f.document.contentVersion) + #expect(snapshot.summary.lineEnding == .crlf) + #expect(snapshot.summary.workspaceRelativePath == "src/Main.swift") + #expect(snapshot.summary.languageID == "swift") + #expect(try f.service.activeDocument(scope: f.scope)?.documentID == f.document.id) + #expect(try f.service.listDocuments(scope: f.scope).map(\.documentID) == [f.document.id]) + } + + @Test func contentVersionChangesOnlyWithText() { + let f = fixture("a") + let initial = f.document.contentVersion + f.document.text = "a" + #expect(f.document.contentVersion == initial) + f.document.text = "b" + f.document.text = "a" + #expect(f.document.contentVersion == initial + 2) + } + + @Test func rejectsOtherWindowsWorkspacesAndUnknownDocuments() { + let f = fixture("a") + #expect(throws: EditorDocumentServiceError.contextMismatch) { + try f.service.snapshot(scope: EditorDocumentScope(windowID: UUID(), workspaceURL: workspace), documentID: f.document.id) + } + #expect(throws: EditorDocumentServiceError.contextMismatch) { + try f.service.listDocuments(scope: EditorDocumentScope(windowID: windowID, workspaceURL: nil)) + } + #expect(throws: EditorDocumentServiceError.documentNotFound) { + try f.service.snapshot(scope: f.scope, documentID: UUID()) + } + } + + @Test func selectionsAreSortedAndKeepThePrimaryCaret() async throws { + let f = fixture("one\ntwo\nthree") + let primary = EditorSelection(anchor: at(3, 6), active: at(3, 1)) + f.surface.selections = [primary, EditorSelection(anchor: at(1, 1), active: at(1, 4)), + EditorSelection(anchor: at(2, 2), active: at(2, 2))] + let snapshot = try await f.service.selections(scope: f.scope, documentID: f.document.id) + #expect(snapshot.selections.map(\.range.start) == [at(1, 1), at(2, 2), at(3, 1)]) + #expect(snapshot.primaryIndex == 2) + #expect(snapshot.selections[2].isReversed) + #expect(snapshot.selections[1].isEmpty) + #expect(snapshot.version == f.document.contentVersion) + } + + /// Plan §7 selection transformer: read dirty selections, compute, then + /// replace them in one undoable step guarded by the version. + @Test func selectionTransformerRoundTripUndoesInOneStep() async throws { + let f = fixture("foo 😀 bar\r\n中文 baz") + f.document.text = "foo 😀 bar\r\n中文 qux" + f.surface.selections = [EditorSelection(anchor: at(2, 1), active: at(2, 3)), + EditorSelection(anchor: at(1, 1), active: at(1, 4))] + let selections = try await f.service.selections(scope: f.scope, documentID: f.document.id) + let snapshot = try f.service.snapshot(scope: f.scope, documentID: f.document.id) + let index = EditorTextLineIndex(snapshot.text) + let replacements = selections.selections.map { selection -> String in + let start = index.offset(of: selection.range.start)!, end = index.offset(of: selection.range.end)! + return String(utf16CodeUnits: Array(index.units[start.. EditorTextPosition { EditorTextPosition(line: line, column: column) } +private func edit(_ start: EditorTextPosition, _ end: EditorTextPosition, _ text: String) -> EditorTextEdit { + EditorTextEdit(range: EditorTextRange(start: start, end: end), text: text) +} +private func applying(_ edits: [EditorTextEdit], to text: String) throws -> String { + EditorTextEditPlanner.apply(try EditorTextEditPlanner.plan(edits, in: EditorTextLineIndex(text)), to: text) +} + +@Suite("Plugin text edit planning") +struct EditorTextEditPlannerTests { + @Test func emojiColumnsCountUTF16CodeUnits() throws { + // "😀" occupies columns 2 and 3; "b" is column 4. + #expect(try applying([edit(at(1, 2), at(1, 4), "X")], to: "a😀b") == "aXb") + #expect(EditorTextLineIndex("a😀b").offset(of: at(1, 5)) == 4) + #expect(throws: EditorDocumentServiceError.invalidRange(editIndex: 0, reason: .splitsSurrogatePair)) { + try EditorTextEditPlanner.plan([edit(at(1, 3), at(1, 4), "")], in: EditorTextLineIndex("a😀b")) + } + } + + @Test func combiningMarksAreSeparateCodeUnits() throws { + // "é" written as e + U+0301 spans columns 1...2; the mark alone is addressable. + #expect(try applying([edit(at(1, 2), at(1, 3), "")], to: "e\u{301}x") == "ex") + #expect(try applying([edit(at(1, 1), at(1, 3), "E")], to: "e\u{301}x") == "Ex") + } + + @Test func chineseCharactersAreOneColumnEach() throws { + #expect(try applying([edit(at(1, 1), at(1, 2), "英")], to: "中文") == "英文") + #expect(EditorTextLineIndex("中文").offset(of: at(1, 3)) == 2) + #expect(EditorTextLineIndex("中文").offset(of: at(1, 4)) == nil) + } + + @Test func crlfIsOneLineBreakAndInsertedBreaksFollowTheDocument() throws { + let index = EditorTextLineIndex("a\r\nb") + #expect(index.lineEnding == .crlf) + #expect(index.lineCount == 2) + #expect(index.offset(of: at(2, 1)) == 3) + // Column 3 of line 1 would sit between CR and LF. + #expect(index.offset(of: at(1, 3)) == nil) + let plan = try EditorTextEditPlanner.plan([edit(at(2, 1), at(2, 1), "x\ny\r\n")], in: index) + #expect(plan.first?.normalizedText == "x\ny\n") + #expect(EditorTextEditPlanner.apply(plan, to: "a\r\nb") == "a\r\nx\r\ny\r\nb") + #expect(index.position(at: 3) == at(2, 1)) + } + + @Test func lineEndingDetection() { + #expect(EditorTextLineIndex("plain").lineEnding == .lf) + #expect(EditorTextLineIndex("a\rb").lineEnding == .cr) + let mixed = EditorTextLineIndex("a\nb\r\nc") + #expect(mixed.lineEnding == .mixed) + #expect(mixed.insertionSeparator == "\n") + #expect(mixed.lineCount == 3) + } + + @Test func endOfLineAndEndOfDocumentAreValid() throws { + #expect(try applying([edit(at(1, 3), at(1, 3), "!"), edit(at(2, 1), at(2, 1), "?")], to: "ab\n") == "ab!\n?") + } + + @Test func rejectsPositionsOutsideTheDocument() { + let index = EditorTextLineIndex("ab\ncd") + for position in [at(0, 1), at(3, 1), at(1, 0), at(1, 4)] { + #expect(throws: EditorDocumentServiceError.rangeOutOfBounds(editIndex: 1)) { + try EditorTextEditPlanner.plan([edit(at(1, 1), at(1, 1), ""), edit(position, position, "x")], in: index) + } + } + } + + @Test func rejectsReversedRanges() { + #expect(throws: EditorDocumentServiceError.invalidRange(editIndex: 0, reason: .reversed)) { + try EditorTextEditPlanner.plan([edit(at(1, 3), at(1, 1), "")], in: EditorTextLineIndex("abc")) + } + } + + @Test func rejectsOverlapAndReportsCallerIndices() { + #expect(throws: EditorDocumentServiceError.overlappingRanges(editIndex: 0, otherEditIndex: 1)) { + try EditorTextEditPlanner.plan([edit(at(1, 3), at(1, 5), ""), edit(at(1, 1), at(1, 4), "")], + in: EditorTextLineIndex("abcdef")) + } + // An insertion strictly inside a replaced range also overlaps. + #expect(throws: EditorDocumentServiceError.overlappingRanges(editIndex: 0, otherEditIndex: 1)) { + try EditorTextEditPlanner.plan([edit(at(1, 1), at(1, 4), ""), edit(at(1, 2), at(1, 2), "x")], + in: EditorTextLineIndex("abcdef")) + } + } + + @Test func adjacentRangesAndSamePositionInsertsAreAllowed() throws { + #expect(try applying([edit(at(1, 2), at(1, 3), "B"), edit(at(1, 1), at(1, 2), "A")], to: "ab") == "AB") + #expect(try applying([edit(at(1, 1), at(1, 1), "x"), edit(at(1, 1), at(1, 1), "y")], to: "z") == "xyz") + } + + @Test func multiLineEditsInMultiSelectionBatch() throws { + let text = "one\ntwo\nthree" + let edits = [edit(at(3, 1), at(3, 6), "3"), edit(at(1, 1), at(1, 4), "1"), edit(at(1, 4), at(2, 4), "")] + #expect(try applying(edits, to: text) == "1\n3") + } +} From 1038049553b147c4e1516ce5e679d17a1e8afeed Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 00:37:16 +0800 Subject: [PATCH 06/16] =?UTF-8?q?feat(macos):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E7=9A=84=E5=AE=BF=E4=B8=BB=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=EF=BC=88API-06/07/08=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 尚未暴露为插件公共 API,也未改动 shared/contracts、capabilities.rs 和 hostCapabilities; M0 分发层负责授权并把插件请求映射为显式根和调用方身份。 - Core/Ports/PluginWorkspaceFileAccess.swift:服务协议与值类型。每次调用显式携带 工作区根(root ID、目录、authority、generation、可见性规则)和调用方身份,不回退到活动窗口; 资源 ID 为 root ID + 以 / 分隔的 NFC 相对路径,结果中不出现系统绝对路径; 错误分类、集中的预算常量及每个取值的依据。 - MacWorkspaceSecurePath:路径词法校验(拒绝绝对路径、空段、.、..、NUL、超长段和超长路径), 逐段 NFC 规范化;从根描述符逐段 openat(O_NOFOLLOW) 解析,末段用 fstatat(AT_SYMLINK_NOFOLLOW) 或 O_NOFOLLOW|O_NONBLOCK 打开,防止 .. 越界、任意段的符号链接以及检查后换成链接的 TOCTOU; 注释写明能防和不能防的范围(硬链接、持有描述符期间被移出的目录、挂载点)。 - MacPluginWorkspaceFileService: - readDirectory 用不透明游标分页(游标绑定根、generation 和目录,记录最后一个排序键), 单页上限和单目录扫描上限;排序与项目树一致(目录优先、自然文件名序), 隐藏规则、符号链接和嵌套 linked worktree 的省略也与项目树一致。 - stat 报告链接本身。readFile 有字节上限、编辑器同款编码探测、二进制结果、截断标记, 未截断时附带 SHA-256 身份。 - findFiles/findText 复用现有搜索 owner(Search Everywhere 文件提供者和项目文本搜索), 支持取消、偏移游标分页和截断标记,结果标明只来自磁盘、不含未保存缓冲区。 - 拒绝非本地 authority,以及被标为本地的远程占位根(unsupportedAuthority); 旧的或已关闭的 generation 返回 staleWorkspace。 - 阻塞工作在宿主队列上执行;按调用方限制并发;任务取消时立即返回 cancelled。 - MacPluginWorkspaceWatchHub:复用 DirectoryWatcherFactory/DirectoryChangeSource, 同一根 generation 的订阅共享一个变更源并做引用计数;订阅归属调用方和工作区, 在 dispose、releaseResources、closeWorkspace 或新 generation 取代时释放; 高频事件按订阅合并,超出待处理上限或源报告丢失时折叠为“需要重新扫描”; 旧 generation 的迟到事件按键丢弃,不会到达新订阅;dispose 返回后不再投递。 - MacWorkspaceSnapshotBuilder.isNestedLinkedWorktree 改为 internal 以复用。 - PluginWorkspaceFileServiceTests 覆盖:越界与路径校验、根外符号链接、 链接互换竞态(含中途换链接)、分页游标与篡改、超大目录、隐藏规则、二进制文件、 超限截断、NFC 名称、watcher 释放/合并/溢出/旧 generation、远程 authority 拒绝、 搜索分页与不可用、取消与并发预算。 --- .../Ports/PluginWorkspaceFileAccess.swift | 345 ++++++++ .../MacPluginWorkspaceFileService.swift | 690 +++++++++++++++ .../MacPluginWorkspaceWatchHub.swift | 356 ++++++++ .../FileSystem/MacWorkspaceSecurePath.swift | 167 ++++ .../MacWorkspaceSnapshotBuilder.swift | 4 +- .../PluginWorkspaceFileServiceTests.swift | 798 ++++++++++++++++++ 6 files changed, 2359 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Lithe/Core/Ports/PluginWorkspaceFileAccess.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceWatchHub.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSecurePath.swift create mode 100644 macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift diff --git a/macos/Sources/Lithe/Core/Ports/PluginWorkspaceFileAccess.swift b/macos/Sources/Lithe/Core/Ports/PluginWorkspaceFileAccess.swift new file mode 100644 index 0000000000..5784f715ce --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/PluginWorkspaceFileAccess.swift @@ -0,0 +1,345 @@ +import Foundation + +// Note: 插件公共 API 计划见 docs/development/macos-plugin-api-plan.md 第 3.2 节(API-06/07/08) +// 与第 4.2、4.3 节。这里只是宿主内部服务,尚未暴露为插件公共 API;M0 的宿主服务分发层负责 +// 授权、能力协商和把插件请求映射成下面的显式根与调用方身份。 + +/// Where the files of a workspace root live. +/// +/// Only `.local` is served. A remote root is backed on this Mac by an empty +/// placeholder directory (`MacRemoteWorkspaceRoots`), so reading it locally +/// would silently return an empty tree; every operation rejects it with +/// ``PluginWorkspaceFileError/unsupportedAuthority`` instead. +enum PluginWorkspaceAuthority: Hashable, Sendable { + case local + /// Host-issued authority of a non-local root, for example a remote connection. + case remote(String) +} + +/// A workspace root as issued by the host to one caller. +/// +/// Every request carries it explicitly; the service never falls back to the +/// active window. `url` is platform-owned and never appears in results. +struct PluginWorkspaceRoot: Hashable, Sendable { + /// Host-issued root ID, echoed in every ``PluginWorkspaceResource``. + let id: String + let url: URL + let authority: PluginWorkspaceAuthority + /// Host-issued generation of this root. Reopening a root with the same ID + /// uses a larger value; older generations are rejected as stale and their + /// watchers are released. + let generation: UInt64 + /// The workspace's own Project-view rules. They are host state, never a + /// caller argument, so listings, search and watch hide what the tree hides. + let visibilityRules: FileVisibilityRules + + init( + id: String, + url: URL, + authority: PluginWorkspaceAuthority, + generation: UInt64, + visibilityRules: FileVisibilityRules = .default + ) { + self.id = id + self.url = url + self.authority = authority + self.generation = generation + self.visibilityRules = visibilityRules + } +} + +/// Identity of the requesting plugin session, verified by the dispatcher. +/// Watch subscriptions and concurrency budgets are owned per caller. +struct PluginWorkspaceCaller: Hashable, Sendable { + let pluginID: String + let sessionID: String +} + +/// Stable resource identifier: a root ID plus a `/`-separated, NFC-normalized +/// path relative to that root. `""` is the root itself. It never contains an +/// OS absolute path, `.` or `..` components, or empty components. +struct PluginWorkspaceResource: Hashable, Sendable, Codable { + let rootID: String + let path: String +} + +enum PluginWorkspaceEntryKind: String, Hashable, Sendable, Codable { + case file + case directory + /// Reported by `stat`, omitted from listings and never followed (see the + /// symbolic-link policy on ``PluginWorkspaceFileAccess``). + case symbolicLink + /// FIFOs, sockets and device nodes. Listed, never read. + case other +} + +struct PluginWorkspaceDirectoryEntry: Hashable, Sendable { + let name: String + let resource: PluginWorkspaceResource + let kind: PluginWorkspaceEntryKind +} + +/// One page of a directory, ordered like Project-view siblings (directories +/// first, then natural file-name order). `nextCursor` is opaque, bound to the +/// directory, root and generation, and `nil` on the last page. Entries added +/// or removed between pages do not shift unrelated entries, because the +/// cursor is the last returned sort key, not an offset. +struct PluginWorkspaceDirectoryPage: Sendable { + let directory: PluginWorkspaceResource + let entries: [PluginWorkspaceDirectoryEntry] + let nextCursor: String? +} + +struct PluginWorkspaceFileStat: Hashable, Sendable { + let resource: PluginWorkspaceResource + let kind: PluginWorkspaceEntryKind + /// Size in bytes as reported by the file system (the link itself for + /// `.symbolicLink`). + let size: UInt64 + let modificationDate: Date +} + +enum PluginWorkspaceFileContent: Sendable, Equatable { + /// Decoded text. `encoding` is a ``DocumentEncoding`` stable ID such as + /// `utf-8` or `gb18030`, detected the way the editor detects it. + case text(String, encoding: String) + /// Bytes that are not plain text in any detected encoding. + case binary(Data) +} + +/// Disk contents of one regular file. Unsaved editor buffers are never +/// consulted; `source` states that explicitly. +struct PluginWorkspaceFileContents: Sendable { + let resource: PluginWorkspaceResource + let content: PluginWorkspaceFileContent + /// Bytes read from disk (at most the requested maximum). + let byteCount: Int + /// The file has more bytes than were read. Text ends at the last complete + /// character inside the limit. + let isTruncated: Bool + /// SHA-256 of the complete file, matching the editor's document identity. + /// `nil` when truncated, because a prefix hash identifies no file version. + let identity: String? + let source: PluginWorkspaceDataSource +} + +/// Which data a read or search used. +enum PluginWorkspaceDataSource: String, Hashable, Sendable, Codable { + /// Bytes on disk. Unsaved editor changes are not included. + case disk +} + +/// A coalesced change notice. It is not a log of every file-system event: +/// `changedPaths` may name paths that changed, were created or were deleted, +/// and a caller must `stat` or list them again. When `requiresRescan` is set, +/// changes were lost (native overflow, root replaced, or the pending set +/// exceeded its budget) and `changedPaths` is empty; the caller must reload the +/// subscribed directory. +struct PluginWorkspaceWatchEvent: Sendable, Equatable { + let subscriptionID: UUID + let rootID: String + let generation: UInt64 + /// Strictly increasing per subscription, starting at 1. + let sequence: UInt64 + let changedPaths: [String] + let requiresRescan: Bool +} + +/// Owned by the caller that created it. Disposing is idempotent; after +/// `dispose()` returns no further event is delivered. +protocol PluginWorkspaceWatchSubscription: AnyObject, Sendable { + var id: UUID { get } + func dispose() +} + +struct PluginWorkspaceFindFilesQuery: Hashable, Sendable { + /// File-name query, matched like Search Everywhere's Files provider. + var query: String + /// Comma-separated globs such as `*.swift, *.md`; empty means all files. + var fileMask: String = "" +} + +struct PluginWorkspaceFindTextQuery: Hashable, Sendable { + var pattern: String + var caseSensitive = false + var wholeWords = false + var regularExpression = false + var fileMask = "" +} + +struct PluginWorkspaceFileMatch: Hashable, Sendable { + let resource: PluginWorkspaceResource +} + +struct PluginWorkspaceTextMatch: Hashable, Sendable { + let resource: PluginWorkspaceResource + /// One-based line number. + let line: Int + let preview: String +} + +/// A page of search results. The underlying project search returns at most +/// ``PluginWorkspaceFileBudgets/searchResultCap`` matches; `isTruncated` means +/// that cap was reached and more matches may exist. Pages are cut from a fresh +/// search each time, so files changed between pages can shift results. +struct PluginWorkspaceSearchPage: Sendable { + let matches: [Match] + let nextCursor: String? + let isTruncated: Bool + let source: PluginWorkspaceDataSource +} + +enum PluginWorkspacePathRejection: String, Hashable, Sendable { + case absolute + case emptyComponent + case currentDirectoryComponent + case parentTraversal + case nulCharacter + case componentTooLong + case pathTooLong +} + +enum PluginWorkspaceLimit: String, Hashable, Sendable { + case concurrentOperations + case directoryEntries + case watchSubscriptions +} + +/// Stable failure categories. The contract PR maps them to wire error codes; +/// platform detail stays in `io(errno:)` and must not reach users verbatim. +enum PluginWorkspaceFileError: Error, Hashable, Sendable { + case unsupportedAuthority + case invalidRoot + /// The root generation was closed or superseded. + case staleWorkspace + case invalidPath(PluginWorkspacePathRejection) + case notFound + case notDirectory + case notRegularFile + /// A path component is a symbolic link; links are never followed. + case symbolicLinkNotFollowed + case permissionDenied + case invalidCursor + case limitExceeded(PluginWorkspaceLimit) + case cancelled + /// The project search owner could not answer (index unavailable). + case searchUnavailable + case io(errno: Int32) +} + +/// Resource budgets of the plugin workspace file service. All values are +/// enforced by the service; requested sizes above a maximum are clamped. +struct PluginWorkspaceFileBudgets: Hashable, Sendable { + /// Matches Search Everywhere's provider limit, the largest list the + /// existing workbench renders per request. + var defaultDirectoryPageSize = 200 + /// One tree page stays well below a single function result (1 MiB, + /// `plugin_function.rs` `MAX_RESULT_BYTES`) even with 255-byte names. + var maximumDirectoryPageSize = 1_000 + /// Each page re-reads the directory names to resume from the cursor, so a + /// single directory is capped. 100k names is about 10 MB transient memory + /// per page and is far above source directories (`node_modules` and other + /// generated folders are hidden by the default rules anyway). + var maximumDirectoryScanEntries = 100_000 + /// Same as one plugin function result (`MAX_RESULT_BYTES` = 1 MiB). + var defaultReadBytes = 1 << 20 + /// Same as the editor's standalone text file limit + /// (`WorkspaceTextFilePolicy.standaloneFileByteLimit`); base64 of it still + /// fits the 64 MiB LSP/JSON-RPC frame limit of `transport.rs`. + var maximumReadBytes = 32 << 20 + /// Blocking file-system work runs on a host queue, never on Swift's + /// cooperative pool or the main thread. A small per-caller fan-out keeps + /// one plugin from occupying that queue; revise with measurements. + var maximumConcurrentOperationsPerCaller = 4 + /// Subscriptions of one root generation share one native stream, so each + /// is cheap; the caps catch leaked subscriptions, not normal use. + var maximumWatchSubscriptionsPerCaller = 32 + var maximumWatchSubscriptions = 256 + /// Pending paths per subscription while its consumer is busy; beyond this + /// the event collapses to `requiresRescan`, which costs no more than one + /// directory page to recover. + var maximumPendingWatchPaths = 1_000 + var defaultSearchPageSize = 100 + /// Same as Search Everywhere's provider limit. + var maximumSearchPageSize = 200 + /// The project search owner's own result cap (`RustCoreBridge.search` + /// default `maxResults` and `SearchEverywhereResults.providerLimit`). + var searchResultCap = 200 + + static let `default` = PluginWorkspaceFileBudgets() +} + +/// Host-internal workspace file service for plugins (API-06/07/08). +/// +/// - Every call names its root and caller; nothing defaults to the active +/// window. Non-local authorities are rejected. +/// - Paths are validated and normalized (see `MacWorkspaceSecurePath`) and +/// resolved with descriptor-relative opens that refuse symbolic links in +/// every component. +/// - Symbolic links are never followed, whether they point inside or outside +/// the root. Listings omit them, as the Project view and the search index +/// do; `stat` reports them as `.symbolicLink`; reading through one fails +/// with `.symbolicLinkNotFollowed`. Following in-root links can be added +/// later as an explicit option. +/// - Visibility rules are the Project view's: hidden entries are omitted from +/// listings, search and watch events. They are presentation rules, not +/// access control, so `stat` and `readFile` on an explicit hidden path are +/// allowed; authorization belongs to the dispatcher. +/// - Reads and searches use disk contents only. +protocol PluginWorkspaceFileAccess: Sendable { + func readDirectory( + _ path: String, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + cursor: String?, + limit: Int? + ) async throws -> PluginWorkspaceDirectoryPage + + func stat( + _ path: String, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller + ) async throws -> PluginWorkspaceFileStat + + func readFile( + _ path: String, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + maximumBytes: Int? + ) async throws -> PluginWorkspaceFileContents + + /// Watches `path` (a directory or file). `recursive == false` reports the + /// path itself and its direct children only. `onEvent` is called serially + /// per subscription on a host queue and must return quickly. + func watch( + _ path: String, + recursive: Bool, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + onEvent: @escaping @Sendable (PluginWorkspaceWatchEvent) -> Void + ) throws -> any PluginWorkspaceWatchSubscription + + func findFiles( + _ query: PluginWorkspaceFindFilesQuery, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + cursor: String?, + limit: Int? + ) async throws -> PluginWorkspaceSearchPage + + func findText( + _ query: PluginWorkspaceFindTextQuery, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + cursor: String?, + limit: Int? + ) async throws -> PluginWorkspaceSearchPage + + /// Releases every subscription of `caller` (plugin disabled, updated, + /// uninstalled, crashed or its grant revoked). + func releaseResources(of caller: PluginWorkspaceCaller) + + /// Marks a root generation closed: its watchers are released and later + /// requests with that generation fail with `.staleWorkspace`. + func closeWorkspace(rootID: String, generation: UInt64) +} diff --git a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift new file mode 100644 index 0000000000..50053d544c --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift @@ -0,0 +1,690 @@ +import CryptoKit +import Darwin +import Foundation +import LitheCoreContracts +import LitheSearchModule + +/// macOS implementation of ``PluginWorkspaceFileAccess``. +/// +/// - Roots: only `.local` authorities whose URL is not a remote placeholder +/// are served. Each root ID tracks its newest generation; requests with an +/// older or closed generation fail with `.staleWorkspace`, and a newer +/// generation releases the watchers of the older one. +/// - Blocking work (descriptor walks, directory reads, file reads and the +/// synchronous Rust search) runs on `workQueue`, never on the caller's +/// executor. A task cancellation returns `.cancelled` at once; the worker +/// stops at its next check (Rust search cannot be interrupted and its late +/// result is dropped). A caller's concurrency slot is returned only when +/// the worker actually ends, so cancelled work still counts against the +/// budget. +/// - Results carry resource IDs (`rootID` + relative path) only. +final class MacPluginWorkspaceFileService: PluginWorkspaceFileAccess, @unchecked Sendable { + private let budgets: PluginWorkspaceFileBudgets + private let searchOperations: any SearchOperations + private let isRemoteWorkspaceRoot: @Sendable (URL) -> Bool + private let watchHub: MacPluginWorkspaceWatchHub + private let workQueue = DispatchQueue( + label: "app.lithe.plugin-workspace-files", + qos: .userInitiated, + attributes: .concurrent + ) + private let lock = NSLock() + private var activeOperations: [PluginWorkspaceCaller: Int] = [:] + private var newestGenerations: [String: UInt64] = [:] + private var closedGenerations: [String: UInt64] = [:] + + /// - Parameter isRemoteWorkspaceRoot: whether a URL is a local placeholder + /// of a remote root (production: `MacRemoteWorkspaceRoots` registry). + /// Such a root is rejected even if the caller labelled it `.local`. + init( + budgets: PluginWorkspaceFileBudgets = .default, + searchOperations: any SearchOperations, + watcherFactory: any DirectoryWatcherFactory, + isRemoteWorkspaceRoot: @escaping @Sendable (URL) -> Bool, + makeWatchDelivery: @escaping @Sendable () -> any PluginWorkspaceWatchDelivery = { + MacPluginWorkspaceWatchQueueDelivery() + } + ) { + self.budgets = budgets + self.searchOperations = searchOperations + self.isRemoteWorkspaceRoot = isRemoteWorkspaceRoot + watchHub = MacPluginWorkspaceWatchHub( + budgets: budgets, + watcherFactory: watcherFactory, + makeDelivery: makeWatchDelivery + ) + } + + var activeWatchSubscriptionCount: Int { watchHub.activeSubscriptionCount } + var activeWatchStreamCount: Int { watchHub.activeStreamCount } + + // MARK: - API-06 + + func readDirectory( + _ path: String, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + cursor: String?, + limit: Int? + ) async throws -> PluginWorkspaceDirectoryPage { + try admit(root) + let components = try MacWorkspaceSecurePath.components(of: path) + let directoryPath = components.joined(separator: "/") + let pageSize = Self.clamp(limit, default: budgets.defaultDirectoryPageSize, maximum: budgets.maximumDirectoryPageSize) + let after = try cursor.map { try DirectoryCursor.decode($0, root: root, directory: directoryPath) } + let budgets = budgets + return try await perform(for: caller) { cancellation in + try Self.listDirectory( + components: components, + root: root, + after: after, + pageSize: pageSize, + budgets: budgets, + cancellation: cancellation + ) + } + } + + func stat( + _ path: String, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller + ) async throws -> PluginWorkspaceFileStat { + try admit(root) + let components = try MacWorkspaceSecurePath.components(of: path) + return try await perform(for: caller) { _ in + let rootDescriptor = try MacWorkspaceSecurePath.openRoot(root.url) + defer { Darwin.close(rootDescriptor) } + let status: Darwin.stat + if let name = components.last { + let parent = try MacWorkspaceSecurePath.openDirectory(components.dropLast(), below: rootDescriptor) + defer { Darwin.close(parent) } + status = try MacWorkspaceSecurePath.status(of: name, in: parent) + } else { + var rootStatus = Darwin.stat() + guard fstat(rootDescriptor, &rootStatus) == 0 else { throw MacWorkspaceSecurePath.map(errno) } + status = rootStatus + } + let modified = status.st_mtimespec + return PluginWorkspaceFileStat( + resource: PluginWorkspaceResource(rootID: root.id, path: components.joined(separator: "/")), + kind: MacWorkspaceSecurePath.kind(of: status), + size: UInt64(max(0, status.st_size)), + modificationDate: Date(timeIntervalSince1970: TimeInterval(modified.tv_sec) + TimeInterval(modified.tv_nsec) / 1_000_000_000) + ) + } + } + + func readFile( + _ path: String, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + maximumBytes: Int? + ) async throws -> PluginWorkspaceFileContents { + try admit(root) + let components = try MacWorkspaceSecurePath.components(of: path) + guard let name = components.last else { throw PluginWorkspaceFileError.notRegularFile } + let limit = Self.clamp(maximumBytes, default: budgets.defaultReadBytes, maximum: budgets.maximumReadBytes) + return try await perform(for: caller) { cancellation in + let rootDescriptor = try MacWorkspaceSecurePath.openRoot(root.url) + defer { Darwin.close(rootDescriptor) } + let parent = try MacWorkspaceSecurePath.openDirectory(components.dropLast(), below: rootDescriptor) + defer { Darwin.close(parent) } + let file = try MacWorkspaceSecurePath.openRegularFile(named: name, in: parent) + defer { Darwin.close(file.descriptor) } + let bytes = try Self.readBytes(from: file.descriptor, limit: limit + 1, cancellation: cancellation) + let isTruncated = bytes.count > limit + let kept = isTruncated ? Data(bytes.prefix(limit)) : bytes + return PluginWorkspaceFileContents( + resource: PluginWorkspaceResource(rootID: root.id, path: components.joined(separator: "/")), + content: Self.classify(kept, isTruncated: isTruncated), + byteCount: kept.count, + isTruncated: isTruncated, + identity: isTruncated ? nil : MacDocumentEncoding.identity(kept), + source: .disk + ) + } + } + + // MARK: - API-07 + + func watch( + _ path: String, + recursive: Bool, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + onEvent: @escaping @Sendable (PluginWorkspaceWatchEvent) -> Void + ) throws -> any PluginWorkspaceWatchSubscription { + try admit(root) + // The path need not exist yet; FSEvents never reports through links, + // so a link component simply produces no events. + let basePath = try MacWorkspaceSecurePath.components(of: path).joined(separator: "/") + return try watchHub.subscribe( + basePath: basePath, + recursive: recursive, + root: root, + caller: caller, + onEvent: onEvent + ) + } + + // MARK: - API-08 + + func findFiles( + _ query: PluginWorkspaceFindFilesQuery, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + cursor: String?, + limit: Int? + ) async throws -> PluginWorkspaceSearchPage { + try admit(root) + let fingerprint = Self.fingerprint(["files", query.query, query.fileMask]) + let offset = try SearchCursor.decode(cursor, root: root, fingerprint: fingerprint) + let pageSize = Self.clamp(limit, default: budgets.defaultSearchPageSize, maximum: budgets.maximumSearchPageSize) + guard !query.query.isEmpty else { return Self.emptyPage() } + let searchOperations = searchOperations + let cap = budgets.searchResultCap + return try await perform(for: caller) { cancellation in + guard let results = searchOperations.searchEverywhere( + at: root.url, + query: query.query, + options: ProjectSearchOptions(fileMask: query.fileMask), + visibilityRules: root.visibilityRules.searchRules, + includeGeneratedFileNames: false, + kinds: [.file] + ) else { throw PluginWorkspaceFileError.searchUnavailable } + try cancellation.check() + let prefixes = MacPluginWorkspacePaths.rootPrefixes(of: root.url) + let matches = results.fileMatches.compactMap { result -> PluginWorkspaceFileMatch? in + MacPluginWorkspacePaths.relativePath(of: result.url.path, rootPrefixes: prefixes) + .flatMap { $0.isEmpty ? nil : PluginWorkspaceFileMatch(resource: PluginWorkspaceResource(rootID: root.id, path: $0)) } + } + return Self.page( + matches, + offset: offset, + pageSize: pageSize, + isTruncated: results.fileMatches.count >= cap, + root: root, + fingerprint: fingerprint + ) + } + } + + func findText( + _ query: PluginWorkspaceFindTextQuery, + in root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + cursor: String?, + limit: Int? + ) async throws -> PluginWorkspaceSearchPage { + try admit(root) + let fingerprint = Self.fingerprint([ + "text", query.pattern, query.fileMask, + "\(query.caseSensitive)", "\(query.wholeWords)", "\(query.regularExpression)", + ]) + let offset = try SearchCursor.decode(cursor, root: root, fingerprint: fingerprint) + let pageSize = Self.clamp(limit, default: budgets.defaultSearchPageSize, maximum: budgets.maximumSearchPageSize) + guard !query.pattern.isEmpty else { return Self.emptyPage() } + let searchOperations = searchOperations + let cap = budgets.searchResultCap + return try await perform(for: caller) { cancellation in + guard let results = searchOperations.search( + at: root.url, + query: query.pattern, + options: ProjectSearchOptions( + caseSensitive: query.caseSensitive, + wholeWords: query.wholeWords, + regularExpression: query.regularExpression, + fileMask: query.fileMask + ), + visibilityRules: root.visibilityRules.searchRules + ) else { throw PluginWorkspaceFileError.searchUnavailable } + try cancellation.check() + let prefixes = MacPluginWorkspacePaths.rootPrefixes(of: root.url) + let matches = results.filter { $0.kind == .content }.compactMap { result -> PluginWorkspaceTextMatch? in + guard let path = MacPluginWorkspacePaths.relativePath(of: result.url.path, rootPrefixes: prefixes), + !path.isEmpty else { return nil } + return PluginWorkspaceTextMatch( + resource: PluginWorkspaceResource(rootID: root.id, path: path), + line: result.line ?? 1, + preview: result.preview + ) + } + return Self.page( + matches, + offset: offset, + pageSize: pageSize, + isTruncated: results.count >= cap, + root: root, + fingerprint: fingerprint + ) + } + } + + // MARK: - Ownership + + func releaseResources(of caller: PluginWorkspaceCaller) { + watchHub.releaseResources(of: caller) + } + + func closeWorkspace(rootID: String, generation: UInt64) { + lock.withLock { + closedGenerations[rootID] = max(closedGenerations[rootID] ?? 0, generation) + } + watchHub.closeGenerations(rootID: rootID, through: generation) + } + + // MARK: - Roots and admission + + private func admit(_ root: PluginWorkspaceRoot) throws { + guard case .local = root.authority else { throw PluginWorkspaceFileError.unsupportedAuthority } + guard !root.id.isEmpty, root.url.isFileURL else { throw PluginWorkspaceFileError.invalidRoot } + guard !isRemoteWorkspaceRoot(root.url) else { throw PluginWorkspaceFileError.unsupportedAuthority } + let supersededGeneration: UInt64? = try lock.withLock { + if let closed = closedGenerations[root.id], root.generation <= closed { + throw PluginWorkspaceFileError.staleWorkspace + } + guard let newest = newestGenerations[root.id] else { + newestGenerations[root.id] = root.generation + return nil + } + if root.generation < newest { throw PluginWorkspaceFileError.staleWorkspace } + guard root.generation > newest else { return nil } + newestGenerations[root.id] = root.generation + return root.generation - 1 + } + if let supersededGeneration { + watchHub.closeGenerations(rootID: root.id, through: supersededGeneration) + } + } + + private func perform( + for caller: PluginWorkspaceCaller, + _ work: @escaping @Sendable (OperationCancellation) throws -> Value + ) async throws -> Value { + let maximum = budgets.maximumConcurrentOperationsPerCaller + try lock.withLock { + let active = activeOperations[caller, default: 0] + guard active < maximum else { throw PluginWorkspaceFileError.limitExceeded(.concurrentOperations) } + activeOperations[caller] = active + 1 + } + let cancellation = OperationCancellation() + let gate = OneShotContinuation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + gate.install(continuation) + workQueue.async { [self] in + let result = Result { try work(cancellation) } + finishOperation(for: caller) + gate.resume(with: result) + } + } + } onCancel: { + cancellation.cancel() + gate.resume(with: .failure(PluginWorkspaceFileError.cancelled)) + } + } + + private func finishOperation(for caller: PluginWorkspaceCaller) { + lock.withLock { + let remaining = activeOperations[caller, default: 1] - 1 + activeOperations[caller] = remaining > 0 ? remaining : nil + } + } + + private static func clamp(_ requested: Int?, default defaultValue: Int, maximum: Int) -> Int { + guard let requested else { return defaultValue } + return min(max(requested, 1), maximum) + } + + // MARK: - Directory listing + + private struct SortKey { + let isDirectory: Bool + let name: String + let utf16: [UInt16] + + init(isDirectory: Bool, name: String) { + self.isDirectory = isDirectory + self.name = name + utf16 = Array(name.utf16) + } + + /// Project-view sibling order made total by a byte tie-break, so a + /// cursor names exactly one position. + func precedes(_ other: SortKey) -> Bool { + if isDirectory != other.isDirectory { return isDirectory } + let order = NaturalNameOrder.compare(utf16, other.utf16, ignoreCase: true, likeFileNames: true) + if order != 0 { return order < 0 } + return name.utf8.lexicographicallyPrecedes(other.name.utf8) + } + } + + private struct ListedEntry { + let key: SortKey + let kind: PluginWorkspaceEntryKind + } + + private static func listDirectory( + components: [String], + root: PluginWorkspaceRoot, + after: DirectoryCursor?, + pageSize: Int, + budgets: PluginWorkspaceFileBudgets, + cancellation: OperationCancellation + ) throws -> PluginWorkspaceDirectoryPage { + let rootDescriptor = try MacWorkspaceSecurePath.openRoot(root.url) + defer { Darwin.close(rootDescriptor) } + let directory = try MacWorkspaceSecurePath.openDirectory(components[...], below: rootDescriptor) + defer { Darwin.close(directory) } + + let rootURL = root.url.standardizedFileURL + let directoryPath = components.joined(separator: "/") + func relativePath(_ name: String) -> String { + directoryPath.isEmpty ? name : directoryPath + "/" + name + } + + var seen: Set = [] + var entries: [ListedEntry] = [] + try readNames(of: directory, maximum: budgets.maximumDirectoryScanEntries, cancellation: cancellation) { rawName, type in + let name = rawName.precomposedStringWithCanonicalMapping + guard seen.insert(name).inserted else { return } + let kind: PluginWorkspaceEntryKind + switch Int32(type) { + case DT_DIR: kind = .directory + case DT_REG: kind = .file + case DT_LNK: kind = .symbolicLink + case DT_UNKNOWN: + guard let status = try? MacWorkspaceSecurePath.status(of: rawName, in: directory) else { return } + kind = MacWorkspaceSecurePath.kind(of: status) + default: kind = .other + } + // Same omissions as the Project view: links, then hidden rules. + guard kind != .symbolicLink else { return } + let isDirectory = kind == .directory + guard !root.visibilityRules.isHidden( + rootURL.appendingPathComponent(relativePath(name)), + relativeTo: rootURL, + isDirectory: isDirectory + ) else { return } + entries.append(ListedEntry(key: SortKey(isDirectory: isDirectory, name: name), kind: kind)) + } + try cancellation.check() + entries.sort { $0.key.precedes($1.key) } + + let startKey = after.map { SortKey(isDirectory: $0.isDirectory, name: $0.name) } + var index = startKey.map { key in entries.firstIndex { key.precedes($0.key) } ?? entries.endIndex } ?? 0 + let snapshotBuilder = FileSystemWorkspaceSnapshotBuilder() + var page: [PluginWorkspaceDirectoryEntry] = [] + while index < entries.endIndex, page.count < pageSize { + try cancellation.check() + let entry = entries[index] + index += 1 + let path = relativePath(entry.key.name) + if entry.kind == .directory, + snapshotBuilder.isNestedLinkedWorktree(rootURL.appendingPathComponent(path, isDirectory: true)) { + continue + } + page.append(PluginWorkspaceDirectoryEntry( + name: entry.key.name, + resource: PluginWorkspaceResource(rootID: root.id, path: path), + kind: entry.kind + )) + } + // The cursor is the last examined key (a returned entry or a skipped + // nested worktree after it), so the next page starts right after it. + let nextCursor = index < entries.endIndex + ? DirectoryCursor( + root: root.id, + generation: root.generation, + directory: directoryPath, + isDirectory: entries[index - 1].key.isDirectory, + name: entries[index - 1].key.name + ).encoded() + : nil + return PluginWorkspaceDirectoryPage( + directory: PluginWorkspaceResource(rootID: root.id, path: directoryPath), + entries: page, + nextCursor: nextCursor + ) + } + + /// Reads raw entry names with `readdir` from a duplicate of `directory`. + /// Names that are not valid UTF-8 cannot form a resource ID and are + /// skipped (APFS stores UTF-8 only). + private static func readNames( + of directory: Int32, + maximum: Int, + cancellation: OperationCancellation, + _ body: (String, UInt8) throws -> Void + ) throws { + let duplicate = dup(directory) + guard duplicate >= 0 else { throw MacWorkspaceSecurePath.map(errno) } + guard let stream = fdopendir(duplicate) else { + let error = errno + Darwin.close(duplicate) + throw MacWorkspaceSecurePath.map(error) + } + defer { closedir(stream) } + var count = 0 + while true { + errno = 0 + guard let entry = readdir(stream) else { + if errno != 0 { throw MacWorkspaceSecurePath.map(errno) } + return + } + let nameCapacity = Int(entry.pointee.d_namlen) + 1 + let name = withUnsafePointer(to: &entry.pointee.d_name) { + $0.withMemoryRebound(to: CChar.self, capacity: nameCapacity) { String(validatingCString: $0) } + } + guard let name, name != ".", name != ".." else { continue } + count += 1 + if count > maximum { throw PluginWorkspaceFileError.limitExceeded(.directoryEntries) } + if count % 1_024 == 0 { try cancellation.check() } + try body(name, entry.pointee.d_type) + } + } + + // MARK: - File reading + + private static let readChunkSize = 64 * 1024 + + private static func readBytes(from descriptor: Int32, limit: Int, cancellation: OperationCancellation) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: readChunkSize) + while data.count < limit { + try cancellation.check() + let wanted = min(buffer.count, limit - data.count) + let count = buffer.withUnsafeMutableBytes { Darwin.read(descriptor, $0.baseAddress, wanted) } + if count < 0 { + if errno == EINTR { continue } + throw MacWorkspaceSecurePath.map(errno) + } + if count == 0 { break } + data.append(contentsOf: buffer[0.. PluginWorkspaceFileContent { + guard !bytes.isEmpty else { return .text("", encoding: DocumentEncoding.utf8.rawValue) } + let maximumTrim = isTruncated ? min(3, bytes.count - 1) : 0 + // A UTF-8 prefix cut inside a character is not valid UTF-8 and would + // otherwise fall through to GB18030 detection, so the shortest trim + // that yields UTF-8 is preferred. + let utf8Trim = (0...maximumTrim).first { String(data: Data(bytes.dropLast($0)), encoding: .utf8) != nil } + let trims = utf8Trim.map { [$0] } ?? Array(0...maximumTrim) + for trim in trims { + guard let details = try? MacDocumentEncoding.decode(Data(bytes.dropLast(trim)), encoding: nil) else { continue } + guard WorkspaceTextFilePolicy.isPlainText(details.text) else { return .binary(bytes) } + return .text(details.text, encoding: details.encoding.rawValue) + } + return .binary(bytes) + } + + // MARK: - Search pages + + private static func emptyPage() -> PluginWorkspaceSearchPage { + PluginWorkspaceSearchPage(matches: [], nextCursor: nil, isTruncated: false, source: .disk) + } + + private static func page( + _ matches: [Match], + offset: Int, + pageSize: Int, + isTruncated: Bool, + root: PluginWorkspaceRoot, + fingerprint: String + ) -> PluginWorkspaceSearchPage { + let start = min(offset, matches.count) + let end = min(start + pageSize, matches.count) + let nextCursor = end < matches.count + ? SearchCursor(root: root.id, generation: root.generation, fingerprint: fingerprint, offset: end).encoded() + : nil + return PluginWorkspaceSearchPage( + matches: Array(matches[start.. String { + let joined = parts.joined(separator: "\u{0}") + return SHA256.hash(data: Data(joined.utf8)).prefix(12).map { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - Cursors + +/// Opaque cursors are base64 JSON. They are not secrets: a forged cursor can +/// only name a position inside the same directory or result list, and every +/// field is checked against the request. +private enum CursorCoding { + static let maximumLength = 4_096 + + static func encode(_ value: Value) -> String { + let data = (try? JSONEncoder().encode(value)) ?? Data() + return data.base64EncodedString() + } + + static func decode(_ type: Value.Type, from cursor: String) throws -> Value { + guard cursor.utf8.count <= maximumLength, + let data = Data(base64Encoded: cursor), + let value = try? JSONDecoder().decode(type, from: data) + else { throw PluginWorkspaceFileError.invalidCursor } + return value + } +} + +private struct DirectoryCursor: Codable { + static let version = 1 + + var v = DirectoryCursor.version + let root: String + let generation: UInt64 + let directory: String + let isDirectory: Bool + let name: String + + init(root: String, generation: UInt64, directory: String, isDirectory: Bool, name: String) { + self.root = root + self.generation = generation + self.directory = directory + self.isDirectory = isDirectory + self.name = name + } + + func encoded() -> String { CursorCoding.encode(self) } + + static func decode(_ cursor: String, root: PluginWorkspaceRoot, directory: String) throws -> DirectoryCursor { + let value = try CursorCoding.decode(DirectoryCursor.self, from: cursor) + guard value.v == version, value.root == root.id, value.generation == root.generation, + value.directory == directory, !value.name.isEmpty + else { throw PluginWorkspaceFileError.invalidCursor } + return value + } +} + +private struct SearchCursor: Codable { + static let version = 1 + + var v = SearchCursor.version + let root: String + let generation: UInt64 + let fingerprint: String + let offset: Int + + init(root: String, generation: UInt64, fingerprint: String, offset: Int) { + self.root = root + self.generation = generation + self.fingerprint = fingerprint + self.offset = offset + } + + func encoded() -> String { CursorCoding.encode(self) } + + /// The offset a cursor resumes at; `nil` starts at 0. + static func decode(_ cursor: String?, root: PluginWorkspaceRoot, fingerprint: String) throws -> Int { + guard let cursor else { return 0 } + let value = try CursorCoding.decode(SearchCursor.self, from: cursor) + guard value.v == version, value.root == root.id, value.generation == root.generation, + value.fingerprint == fingerprint, value.offset > 0 + else { throw PluginWorkspaceFileError.invalidCursor } + return value.offset + } +} + +// MARK: - Cancellation + +/// Set from the task's cancellation handler, polled by the worker. +private final class OperationCancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + + func cancel() { lock.withLock { cancelled = true } } + + func check() throws { + if lock.withLock({ cancelled }) { throw PluginWorkspaceFileError.cancelled } + } +} + +/// Resumes a continuation exactly once, whichever of the worker result and the +/// cancellation arrives first, including a cancellation that arrives before +/// the continuation exists. +private final class OneShotContinuation: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var early: Result? + private var finished = false + + func install(_ continuation: CheckedContinuation) { + let early: Result? = lock.withLock { + if let early = self.early { + finished = true + return early + } + self.continuation = continuation + return nil + } + if let early { continuation.resume(with: early) } + } + + func resume(with result: Result) { + let continuation: CheckedContinuation? = lock.withLock { + guard !finished else { return nil } + guard let continuation = self.continuation else { + if early == nil { early = result } + return nil + } + self.continuation = nil + finished = true + return continuation + } + continuation?.resume(with: result) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceWatchHub.swift b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceWatchHub.swift new file mode 100644 index 0000000000..ea74329412 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceWatchHub.swift @@ -0,0 +1,356 @@ +import Foundation +import LitheCoreContracts + +/// Runs coalesced watch deliveries for one subscription, serially. +protocol PluginWorkspaceWatchDelivery: Sendable { + func schedule(_ work: @escaping @Sendable () -> Void) +} + +/// Production delivery: one serial queue per subscription, so a slow plugin +/// callback delays only its own events. +struct MacPluginWorkspaceWatchQueueDelivery: PluginWorkspaceWatchDelivery { + private let queue = DispatchQueue(label: "app.lithe.plugin-workspace-watch.delivery", qos: .utility) + + func schedule(_ work: @escaping @Sendable () -> Void) { + queue.async(execute: work) + } +} + +/// Plugin watch subscriptions on top of the existing directory change source. +/// +/// Ownership: every subscription belongs to one caller and one root +/// generation. All subscriptions of a generation share one +/// `DirectoryChangeSource` (FSEvents in production), started with the first +/// subscription and stopped with the last. A subscription ends when the caller +/// disposes it, when `releaseResources(of:)` runs for its caller, or when its +/// root generation is closed or superseded. +/// +/// Generations: a stream is registered under `(rootID, generation)`. Closing +/// or superseding a generation removes its stream before stopping it, and +/// incoming batches are looked up by that key, so a late callback from an old +/// generation's source finds nothing and can never reach a subscription of a +/// newer generation. +/// +/// Coalescing: paths accumulate per subscription while a delivery is queued; +/// one event carries all of them. Above `maximumPendingWatchPaths`, or when +/// the source reports lost events or a replaced root, the pending paths +/// collapse to one `requiresRescan` event. +final class MacPluginWorkspaceWatchHub: @unchecked Sendable { + private struct StreamKey: Hashable { + let rootID: String + let generation: UInt64 + } + + private final class Stream { + let source: any DirectoryChangeSource + let rootPrefixes: [String] + let rootURL: URL + let visibilityRules: FileVisibilityRules + var subscriptions: [UUID: Subscription] = [:] + + init(source: any DirectoryChangeSource, rootURL: URL, visibilityRules: FileVisibilityRules) { + self.source = source + self.rootURL = rootURL.standardizedFileURL + self.rootPrefixes = MacPluginWorkspacePaths.rootPrefixes(of: rootURL) + self.visibilityRules = visibilityRules + } + } + + private let budgets: PluginWorkspaceFileBudgets + private let watcherFactory: any DirectoryWatcherFactory + private let makeDelivery: @Sendable () -> any PluginWorkspaceWatchDelivery + private let lock = NSLock() + private var streams: [StreamKey: Stream] = [:] + + init( + budgets: PluginWorkspaceFileBudgets, + watcherFactory: any DirectoryWatcherFactory, + makeDelivery: @escaping @Sendable () -> any PluginWorkspaceWatchDelivery = { MacPluginWorkspaceWatchQueueDelivery() } + ) { + self.budgets = budgets + self.watcherFactory = watcherFactory + self.makeDelivery = makeDelivery + } + + var activeSubscriptionCount: Int { + lock.withLock { streams.values.reduce(0) { $0 + $1.subscriptions.count } } + } + + var activeStreamCount: Int { + lock.withLock { streams.count } + } + + /// `basePath` must already be validated and NFC-normalized. + func subscribe( + basePath: String, + recursive: Bool, + root: PluginWorkspaceRoot, + caller: PluginWorkspaceCaller, + onEvent: @escaping @Sendable (PluginWorkspaceWatchEvent) -> Void + ) throws -> any PluginWorkspaceWatchSubscription { + let key = StreamKey(rootID: root.id, generation: root.generation) + let subscription = Subscription( + hub: self, + caller: caller, + rootID: root.id, + generation: root.generation, + basePath: basePath, + recursive: recursive, + maximumPendingPaths: budgets.maximumPendingWatchPaths, + delivery: makeDelivery(), + onEvent: onEvent + ) + var newSource: (any DirectoryChangeSource)? + try lock.withLock { + let all = streams.values.flatMap { $0.subscriptions.values } + guard all.count < budgets.maximumWatchSubscriptions, + all.filter({ $0.caller == caller }).count < budgets.maximumWatchSubscriptionsPerCaller + else { throw PluginWorkspaceFileError.limitExceeded(.watchSubscriptions) } + let stream: Stream + if let existing = streams[key] { + stream = existing + } else { + // `gitContext: nil`: plugin watches cover the workspace tree + // only; Git metadata stays with the VCS watchers. + let source = watcherFactory.make( + configuration: DirectoryWatchConfiguration(workspaceRoot: root.url, gitContext: nil), + visibilityRules: root.visibilityRules, + onChange: { [weak self] batch in self?.receive(batch, for: key) } + ) + stream = Stream(source: source, rootURL: root.url, visibilityRules: root.visibilityRules) + streams[key] = stream + newSource = source + } + stream.subscriptions[subscription.id] = subscription + } + if let newSource { + // Started outside the lock so a source that reports synchronously + // cannot deadlock. If every subscription went away meanwhile the + // stream is no longer registered and is stopped again here. + newSource.start() + let stillRegistered = lock.withLock { streams[key]?.source === newSource } + if !stillRegistered { newSource.stop() } + } + return subscription + } + + func releaseResources(of caller: PluginWorkspaceCaller) { + removeSubscriptions { $0.caller == caller } + } + + /// Ends every subscription of `rootID` whose generation is at most + /// `generation`. + func closeGenerations(rootID: String, through generation: UInt64) { + removeSubscriptions { $0.rootID == rootID && $0.generation <= generation } + } + + fileprivate func remove(_ subscription: Subscription) { + removeSubscriptions { $0 === subscription } + } + + private func removeSubscriptions(where shouldRemove: (Subscription) -> Bool) { + var removed: [Subscription] = [] + var stoppedSources: [any DirectoryChangeSource] = [] + lock.withLock { + for (key, stream) in streams { + for subscription in stream.subscriptions.values where shouldRemove(subscription) { + stream.subscriptions[subscription.id] = nil + removed.append(subscription) + } + if stream.subscriptions.isEmpty { + streams[key] = nil + stoppedSources.append(stream.source) + } + } + } + // Marked outside the hub lock: marking waits for an in-flight callback + // of that subscription, which may itself dispose or subscribe. + removed.forEach { $0.markDisposed() } + stoppedSources.forEach { $0.stop() } + } + + private func receive(_ batch: DirectoryChangeBatch, for key: StreamKey) { + let target: (stream: Stream, subscriptions: [Subscription])? = lock.withLock { + guard let stream = streams[key] else { return nil } + return (stream, Array(stream.subscriptions.values)) + } + guard let target, !target.subscriptions.isEmpty else { return } + if batch.requiresFullRescan || batch.watchRootsChanged { + target.subscriptions.forEach { $0.enqueue(paths: [], requiresRescan: true) } + return + } + let stream = target.stream + let paths = batch.workspacePaths.compactMap { absolutePath -> String? in + guard let relative = MacPluginWorkspacePaths.relativePath(of: absolutePath, rootPrefixes: stream.rootPrefixes) + else { return nil } + guard relative.isEmpty || !stream.visibilityRules.isHiddenPath( + stream.rootURL.appendingPathComponent(relative), + relativeTo: stream.rootURL + ) else { return nil } + return relative + } + guard !paths.isEmpty else { return } + for subscription in target.subscriptions { + let matching = paths.filter(subscription.covers) + if !matching.isEmpty { subscription.enqueue(paths: matching, requiresRescan: false) } + } + } + + fileprivate final class Subscription: PluginWorkspaceWatchSubscription, @unchecked Sendable { + let id = UUID() + let caller: PluginWorkspaceCaller + let rootID: String + let generation: UInt64 + private let basePath: String + private let recursive: Bool + private let maximumPendingPaths: Int + private let delivery: any PluginWorkspaceWatchDelivery + private let onEvent: @Sendable (PluginWorkspaceWatchEvent) -> Void + private weak var hub: MacPluginWorkspaceWatchHub? + + private let stateLock = NSLock() + /// Held while `onEvent` runs; `markDisposed` takes it so that no + /// callback is running or can start once dispose returns. Recursive + /// so a callback may dispose its own subscription. + private let deliveryLock = NSRecursiveLock() + private var pending: [String] = [] + private var pendingSet: Set = [] + private var rescanPending = false + private var deliveryScheduled = false + private var disposed = false + private var sequence: UInt64 = 0 + + init( + hub: MacPluginWorkspaceWatchHub, + caller: PluginWorkspaceCaller, + rootID: String, + generation: UInt64, + basePath: String, + recursive: Bool, + maximumPendingPaths: Int, + delivery: any PluginWorkspaceWatchDelivery, + onEvent: @escaping @Sendable (PluginWorkspaceWatchEvent) -> Void + ) { + self.hub = hub + self.caller = caller + self.rootID = rootID + self.generation = generation + self.basePath = basePath + self.recursive = recursive + self.maximumPendingPaths = maximumPendingPaths + self.delivery = delivery + self.onEvent = onEvent + } + + func dispose() { + if let hub { hub.remove(self) } else { markDisposed() } + } + + /// Whether a changed relative path belongs to this subscription. + func covers(_ path: String) -> Bool { + if path == basePath { return true } + if recursive { + return basePath.isEmpty || path.hasPrefix(basePath + "/") + } + return MacPluginWorkspacePaths.parent(of: path) == basePath + } + + func enqueue(paths: [String], requiresRescan: Bool) { + let shouldSchedule: Bool = stateLock.withLock { + guard !disposed else { return false } + if requiresRescan { + rescanPending = true + } else if !rescanPending { + for path in paths where pendingSet.insert(path).inserted { + pending.append(path) + } + if pending.count > maximumPendingPaths { rescanPending = true } + } + if rescanPending { + pending.removeAll() + pendingSet.removeAll() + } + guard !deliveryScheduled, rescanPending || !pending.isEmpty else { return false } + deliveryScheduled = true + return true + } + if shouldSchedule { + delivery.schedule { [self] in flush() } + } + } + + func markDisposed() { + deliveryLock.lock() + defer { deliveryLock.unlock() } + stateLock.withLock { + disposed = true + pending.removeAll() + pendingSet.removeAll() + rescanPending = false + } + } + + private func flush() { + deliveryLock.lock() + defer { deliveryLock.unlock() } + let event: PluginWorkspaceWatchEvent? = stateLock.withLock { + deliveryScheduled = false + guard !disposed, rescanPending || !pending.isEmpty else { return nil } + sequence += 1 + let event = PluginWorkspaceWatchEvent( + subscriptionID: id, + rootID: rootID, + generation: generation, + sequence: sequence, + changedPaths: rescanPending ? [] : pending, + requiresRescan: rescanPending + ) + pending.removeAll() + pendingSet.removeAll() + rescanPending = false + return event + } + if let event { onEvent(event) } + } + } +} + +/// Converts host absolute paths into resource paths, for watch and search. +enum MacPluginWorkspacePaths { + /// The root as given and with links resolved: FSEvents reports real paths + /// (`/private/var/...`), the search index reports paths under the given + /// root. + static func rootPrefixes(of rootURL: URL) -> [String] { + var prefixes: [String] = [] + for path in [rootURL.path, rootURL.standardizedFileURL.path, rootURL.resolvingSymlinksInPath().standardizedFileURL.path] { + let trimmed = path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path + if trimmed != "/", !prefixes.contains(trimmed) { prefixes.append(trimmed) } + } + return prefixes + } + + /// The validated, NFC-normalized resource path of `absolutePath`, or nil + /// when it is outside every root prefix or not a valid resource path. + static func relativePath(of absolutePath: String, rootPrefixes: [String]) -> String? { + let path = absolutePath.count > 1 && absolutePath.hasSuffix("/") ? String(absolutePath.dropLast()) : absolutePath + for prefix in rootPrefixes { + let relative: String + if path == prefix { + relative = "" + } else if path.hasPrefix(prefix + "/") { + relative = String(path.dropFirst(prefix.count + 1)) + } else { + continue + } + guard let components = try? MacWorkspaceSecurePath.components(of: relative) else { return nil } + return components.joined(separator: "/") + } + return nil + } + + static func parent(of path: String) -> String? { + guard !path.isEmpty else { return nil } + guard let slash = path.lastIndex(of: "/") else { return "" } + return String(path[.. [String] { + guard !path.isEmpty else { return [] } + guard !path.utf8.contains(0) else { throw PluginWorkspaceFileError.invalidPath(.nulCharacter) } + guard !path.hasPrefix("/") else { throw PluginWorkspaceFileError.invalidPath(.absolute) } + var components: [String] = [] + for raw in path.split(separator: "/", omittingEmptySubsequences: false) { + let component = String(raw).precomposedStringWithCanonicalMapping + switch component { + case "": throw PluginWorkspaceFileError.invalidPath(.emptyComponent) + case ".": throw PluginWorkspaceFileError.invalidPath(.currentDirectoryComponent) + case "..": throw PluginWorkspaceFileError.invalidPath(.parentTraversal) + default: break + } + guard component.utf8.count <= maximumComponentBytes else { + throw PluginWorkspaceFileError.invalidPath(.componentTooLong) + } + components.append(component) + } + guard components.joined(separator: "/").utf8.count <= maximumPathBytes else { + throw PluginWorkspaceFileError.invalidPath(.pathTooLong) + } + return components + } + + /// Opens the root directory. The caller owns and closes the descriptor. + static func openRoot(_ url: URL) throws -> Int32 { + let descriptor = open(url.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard descriptor >= 0 else { + let error = errno + throw error == ENOENT || error == ENOTDIR ? PluginWorkspaceFileError.invalidRoot : map(error) + } + return descriptor + } + + /// Opens `components` as directories below `rootDescriptor`, one + /// `openat(O_NOFOLLOW)` per component. Returns a new descriptor the caller + /// closes; `rootDescriptor` stays open. + /// + /// `afterOpeningComponent` runs after each component is open, with its + /// index. Production passes nil; tests use it to replace path components + /// between two steps and prove the held descriptor is not re-resolved. + static func openDirectory( + _ components: ArraySlice, + below rootDescriptor: Int32, + afterOpeningComponent: ((Int) -> Void)? = nil + ) throws -> Int32 { + var current = dup(rootDescriptor) + guard current >= 0 else { throw map(errno) } + for (offset, component) in components.enumerated() { + let next = openat(current, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + if next < 0 { + let error = classifyOpenFailure(errno, name: component, in: current) + Darwin.close(current) + throw error + } + Darwin.close(current) + current = next + afterOpeningComponent?(offset) + } + return current + } + + /// Opens a regular file for reading without following a final link and + /// without blocking on FIFOs. The caller closes the descriptor. + static func openRegularFile(named name: String, in directory: Int32) throws -> (descriptor: Int32, status: Darwin.stat) { + let descriptor = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + guard descriptor >= 0 else { + throw classifyOpenFailure(errno, name: name, in: directory) + } + var status = Darwin.stat() + guard fstat(descriptor, &status) == 0 else { + let error = errno + Darwin.close(descriptor) + throw map(error) + } + guard status.st_mode & S_IFMT == S_IFREG else { + Darwin.close(descriptor) + throw PluginWorkspaceFileError.notRegularFile + } + return (descriptor, status) + } + + /// `lstat` of `name` relative to `directory`. + static func status(of name: String, in directory: Int32) throws -> Darwin.stat { + var status = Darwin.stat() + guard fstatat(directory, name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { throw map(errno) } + return status + } + + static func kind(of status: Darwin.stat) -> PluginWorkspaceEntryKind { + switch status.st_mode & S_IFMT { + case S_IFREG: .file + case S_IFDIR: .directory + case S_IFLNK: .symbolicLink + default: .other + } + } + + /// `O_NOFOLLOW` reports a link as `ELOOP`, and `O_DIRECTORY` reports a + /// link or file as `ENOTDIR`; `lstat` tells the two cases apart. + private static func classifyOpenFailure(_ error: Int32, name: String, in directory: Int32) -> PluginWorkspaceFileError { + guard error == ELOOP || error == ENOTDIR else { return map(error) } + var status = Darwin.stat() + if fstatat(directory, name, &status, AT_SYMLINK_NOFOLLOW) == 0, status.st_mode & S_IFMT == S_IFLNK { + return .symbolicLinkNotFollowed + } + return error == ELOOP ? .symbolicLinkNotFollowed : .notDirectory + } + + static func map(_ error: Int32) -> PluginWorkspaceFileError { + switch error { + case ENOENT: .notFound + case ENOTDIR: .notDirectory + case EACCES, EPERM: .permissionDenied + case ELOOP: .symbolicLinkNotFollowed + case ENAMETOOLONG: .invalidPath(.pathTooLong) + default: .io(errno: error) + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift index ec6a7fe73f..b9018c7996 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift @@ -95,7 +95,9 @@ struct FileSystemWorkspaceSnapshotBuilder { /// Mirrors Rust Core's `is_nested_linked_worktree`: a `.git` pointer file /// into `
/.git/worktrees/` where `
` contains the directory. - private func isNestedLinkedWorktree(_ directory: URL) -> Bool { + /// `MacPluginWorkspaceFileService` reuses it so plugin listings omit the + /// same nested worktrees as the Project view. + func isNestedLinkedWorktree(_ directory: URL) -> Bool { let marker = directory.appendingPathComponent(".git") guard let attributes = try? fileManager.attributesOfItem(atPath: marker.path), attributes[.type] as? FileAttributeType == .typeRegular, diff --git a/macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift b/macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift new file mode 100644 index 0000000000..2153f1da54 --- /dev/null +++ b/macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift @@ -0,0 +1,798 @@ +import Darwin +import Foundation +import LitheCoreContracts +import LitheSearchModule +import Testing +@testable import Lithe + +/// Host-internal plugin workspace file service (API-06/07/08): path safety, +/// paging, reads, watch ownership and search adaptation. Every test owns a +/// temporary directory removed in `defer`; watch tests use a fake change +/// source and a manually drained delivery, so nothing waits on FSEvents. +@Suite("Plugin workspace file service") +struct PluginWorkspaceFileServiceTests { + private static let caller = PluginWorkspaceCaller(pluginID: "sample.files", sessionID: "session-1") + private static let otherCaller = PluginWorkspaceCaller(pluginID: "sample.other", sessionID: "session-2") + + // MARK: Path validation + + @Test func pathValidationRejectsEscapesAndNormalizesToNFC() throws { + #expect(try MacWorkspaceSecurePath.components(of: "") == []) + #expect(try MacWorkspaceSecurePath.components(of: "src/main.swift") == ["src", "main.swift"]) + #expect(try MacWorkspaceSecurePath.components(of: "e\u{301}.txt") == ["\u{e9}.txt"]) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: "../etc") } == .invalidPath(.parentTraversal)) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: "a/../../b") } == .invalidPath(.parentTraversal)) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: "/etc/passwd") } == .invalidPath(.absolute)) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: "a//b") } == .invalidPath(.emptyComponent)) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: "a/") } == .invalidPath(.emptyComponent)) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: "./a") } == .invalidPath(.currentDirectoryComponent)) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: "a\u{0}b") } == .invalidPath(.nulCharacter)) + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: String(repeating: "x", count: 256)) } == .invalidPath(.componentTooLong)) + let longPath = Array(repeating: String(repeating: "x", count: 200), count: 6).joined(separator: "/") + #expect(Self.rejection { try MacWorkspaceSecurePath.components(of: longPath) } == .invalidPath(.pathTooLong)) + } + + @Test func serviceRejectsTraversalBeforeTouchingDisk() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + try workspace.write("inside.txt", "inside") + let service = Self.makeService() + let root = workspace.root(id: "w1") + + #expect(await Self.failure { try await service.readFile("../inside.txt", in: root, caller: Self.caller, maximumBytes: nil) } + == .invalidPath(.parentTraversal)) + #expect(await Self.failure { try await service.readDirectory("/", in: root, caller: Self.caller, cursor: nil, limit: nil) } + == .invalidPath(.absolute)) + #expect(await Self.failure { try await service.stat("a/./b", in: root, caller: Self.caller) } + == .invalidPath(.currentDirectoryComponent)) + } + + @Test func listingAndReadingUseNFCResourcePaths() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let decomposed = "cafe\u{301}.txt" + try workspace.write(decomposed, "bonjour") + let service = Self.makeService() + let root = workspace.root(id: "w1") + + let page = try await service.readDirectory("", in: root, caller: Self.caller, cursor: nil, limit: nil) + #expect(page.entries.map(\.resource.path) == ["caf\u{e9}.txt"]) + #expect(page.entries.first?.resource.path.utf8.count == "caf\u{e9}.txt".utf8.count) + + let contents = try await service.readFile(decomposed, in: root, caller: Self.caller, maximumBytes: nil) + #expect(contents.resource == PluginWorkspaceResource(rootID: "w1", path: "caf\u{e9}.txt")) + #expect(contents.content == .text("bonjour", encoding: "utf-8")) + } + + // MARK: Symbolic links + + @Test func symbolicLinksOutOfRootAreNeverFollowed() async throws { + let workspace = try TemporaryWorkspace() + let outside = try TemporaryWorkspace() + defer { + workspace.remove() + outside.remove() + } + try outside.write("secret.txt", "secret") + try workspace.write("visible.txt", "visible") + try FileManager.default.createSymbolicLink(at: workspace.url("linked-dir"), withDestinationURL: outside.base) + try FileManager.default.createSymbolicLink(at: workspace.url("linked-file"), withDestinationURL: outside.url("secret.txt")) + let service = Self.makeService() + let root = workspace.root(id: "w1") + + #expect(await Self.failure { try await service.readDirectory("linked-dir", in: root, caller: Self.caller, cursor: nil, limit: nil) } + == .symbolicLinkNotFollowed) + #expect(await Self.failure { try await service.readFile("linked-dir/secret.txt", in: root, caller: Self.caller, maximumBytes: nil) } + == .symbolicLinkNotFollowed) + #expect(await Self.failure { try await service.readFile("linked-file", in: root, caller: Self.caller, maximumBytes: nil) } + == .symbolicLinkNotFollowed) + #expect(await Self.failure { try await service.stat("linked-dir/secret.txt", in: root, caller: Self.caller) } + == .symbolicLinkNotFollowed) + + let link = try await service.stat("linked-file", in: root, caller: Self.caller) + #expect(link.kind == .symbolicLink) + let listing = try await service.readDirectory("", in: root, caller: Self.caller, cursor: nil, limit: nil) + #expect(listing.entries.map(\.name) == ["visible.txt"]) + } + + @Test func directorySwappedForLinkBeforeOpenIsRejected() throws { + let workspace = try TemporaryWorkspace() + let outside = try TemporaryWorkspace() + defer { + workspace.remove() + outside.remove() + } + try workspace.write("a/b/marker.txt", "inside") + try outside.write("b/marker.txt", "outside") + let rootDescriptor = try MacWorkspaceSecurePath.openRoot(workspace.base) + defer { Darwin.close(rootDescriptor) } + + // Checked once as a directory, then replaced by a link before use. + let first = try MacWorkspaceSecurePath.openDirectory(["a", "b"], below: rootDescriptor) + Darwin.close(first) + try FileManager.default.moveItem(at: workspace.url("a"), to: workspace.url("a-moved")) + try FileManager.default.createSymbolicLink(at: workspace.url("a"), withDestinationURL: outside.base) + + #expect(Self.rejection { try MacWorkspaceSecurePath.openDirectory(["a", "b"], below: rootDescriptor) } == .symbolicLinkNotFollowed) + } + + @Test func linkSwappedInMidWalkCannotRedirectTheHeldDescriptor() throws { + let workspace = try TemporaryWorkspace() + let outside = try TemporaryWorkspace() + defer { + workspace.remove() + outside.remove() + } + try workspace.write("a/b/marker.txt", "inside") + try outside.write("b/marker.txt", "outside") + let rootDescriptor = try MacWorkspaceSecurePath.openRoot(workspace.base) + defer { Darwin.close(rootDescriptor) } + + var swapError: (any Error)? + let directory = try MacWorkspaceSecurePath.openDirectory(["a", "b"], below: rootDescriptor) { index in + guard index == 0 else { return } + // `a` is open; replace it by a link to the outside tree before + // `b` is resolved. + do { + try FileManager.default.moveItem(at: workspace.url("a"), to: workspace.url("a-moved")) + try FileManager.default.createSymbolicLink(at: workspace.url("a"), withDestinationURL: outside.base) + } catch { + swapError = error + } + } + defer { Darwin.close(directory) } + #expect(swapError == nil) + #expect(try Self.readText(named: "marker.txt", in: directory) == "inside") + } + + // MARK: Paging and visibility + + @Test func directoryPagesFollowTreeOrderWithoutDuplicates() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + for index in 1...12 { try workspace.write("file\(index).txt", "\(index)") } + for index in 1...3 { try workspace.makeDirectory("dir\(index)") } + let service = Self.makeService() + let root = workspace.root(id: "w1") + + var names: [String] = [] + var kinds: [PluginWorkspaceEntryKind] = [] + var cursor: String? + var pages = 0 + repeat { + let page = try await service.readDirectory("", in: root, caller: Self.caller, cursor: cursor, limit: 4) + #expect(page.entries.count <= 4) + #expect(page.directory == PluginWorkspaceResource(rootID: "w1", path: "")) + names += page.entries.map(\.name) + kinds += page.entries.map(\.kind) + cursor = page.nextCursor + pages += 1 + } while cursor != nil && pages < 10 + + #expect(pages == 4) + #expect(names == ["dir1", "dir2", "dir3"] + (1...12).map { "file\($0).txt" }) + #expect(Set(names).count == names.count) + #expect(kinds.prefix(3).allSatisfy { $0 == .directory }) + } + + @Test func cursorIsBoundToItsDirectoryAndRejectsTampering() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + for index in 1...3 { try workspace.write("src/f\(index).txt", "x") } + try workspace.write("docs/readme.md", "x") + let service = Self.makeService() + let root = workspace.root(id: "w1") + + let first = try await service.readDirectory("src", in: root, caller: Self.caller, cursor: nil, limit: 1) + let cursor = try #require(first.nextCursor) + let second = try await service.readDirectory("src", in: root, caller: Self.caller, cursor: cursor, limit: 1) + #expect(second.entries.map(\.resource.path) == ["src/f2.txt"]) + + #expect(await Self.failure { try await service.readDirectory("docs", in: root, caller: Self.caller, cursor: cursor, limit: 1) } + == .invalidCursor) + #expect(await Self.failure { try await service.readDirectory("src", in: root, caller: Self.caller, cursor: "not a cursor", limit: 1) } + == .invalidCursor) + let otherRoot = workspace.root(id: "w2") + #expect(await Self.failure { try await service.readDirectory("src", in: otherRoot, caller: Self.caller, cursor: cursor, limit: 1) } + == .invalidCursor) + } + + @Test func hugeDirectoryIsCappedAndPagesAreClamped() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + for index in 1...12 { try workspace.write("big/f\(index)", "") } + for index in 1...8 { try workspace.write("small/f\(index)", "") } + var budgets = PluginWorkspaceFileBudgets() + budgets.maximumDirectoryScanEntries = 10 + budgets.maximumDirectoryPageSize = 5 + let service = Self.makeService(budgets: budgets) + let root = workspace.root(id: "w1") + + #expect(await Self.failure { try await service.readDirectory("big", in: root, caller: Self.caller, cursor: nil, limit: nil) } + == .limitExceeded(.directoryEntries)) + let page = try await service.readDirectory("small", in: root, caller: Self.caller, cursor: nil, limit: 10_000) + #expect(page.entries.count == 5) + #expect(page.nextCursor != nil) + } + + @Test func listingsHideWhatTheProjectTreeHidesButExplicitReadsWork() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + try workspace.write("node_modules/pkg/index.js", "x") + try workspace.write(".DS_Store", "x") + try workspace.write("generated.log", "x") + try workspace.write("main.swift", "x") + let service = Self.makeService() + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: ["*.log"]) + let root = workspace.root(id: "w1", visibilityRules: rules) + + let listing = try await service.readDirectory("", in: root, caller: Self.caller, cursor: nil, limit: nil) + #expect(listing.entries.map(\.name) == ["main.swift"]) + let hidden = try await service.readFile("generated.log", in: root, caller: Self.caller, maximumBytes: nil) + #expect(hidden.content == .text("x", encoding: "utf-8")) + } + + // MARK: Reading + + @Test func binaryFileIsReturnedAsBytes() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let bytes = Data([0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x7F, 0x80]) + try workspace.write("image.bin", bytes) + let service = Self.makeService() + + let contents = try await service.readFile("image.bin", in: workspace.root(id: "w1"), caller: Self.caller, maximumBytes: nil) + #expect(contents.content == .binary(bytes)) + #expect(contents.byteCount == bytes.count) + #expect(!contents.isTruncated) + #expect(contents.identity == MacDocumentEncoding.identity(bytes)) + #expect(contents.source == .disk) + } + + @Test func readOverLimitIsTruncatedAtACharacterBoundary() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + try workspace.write("notes.txt", "abc\u{4e2d}def") + var budgets = PluginWorkspaceFileBudgets() + budgets.maximumReadBytes = 8 + let service = Self.makeService(budgets: budgets) + let root = workspace.root(id: "w1") + + let cut = try await service.readFile("notes.txt", in: root, caller: Self.caller, maximumBytes: 5) + #expect(cut.isTruncated) + #expect(cut.byteCount == 5) + #expect(cut.identity == nil) + #expect(cut.content == .text("abc", encoding: "utf-8")) + + let clamped = try await service.readFile("notes.txt", in: root, caller: Self.caller, maximumBytes: 1_000) + #expect(clamped.byteCount == 8) + #expect(clamped.isTruncated) + + try workspace.write("short.txt", "abc") + let whole = try await service.readFile("short.txt", in: root, caller: Self.caller, maximumBytes: 3) + #expect(!whole.isTruncated) + #expect(whole.identity == MacDocumentEncoding.identity(Data("abc".utf8))) + } + + @Test func readingADirectoryOrMissingFileFails() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + try workspace.makeDirectory("src") + let service = Self.makeService() + let root = workspace.root(id: "w1") + + #expect(await Self.failure { try await service.readFile("src", in: root, caller: Self.caller, maximumBytes: nil) } == .notRegularFile) + #expect(await Self.failure { try await service.readFile("", in: root, caller: Self.caller, maximumBytes: nil) } == .notRegularFile) + #expect(await Self.failure { try await service.readFile("missing.txt", in: root, caller: Self.caller, maximumBytes: nil) } == .notFound) + let directory = try await service.stat("src", in: root, caller: Self.caller) + #expect(directory.kind == .directory) + #expect(directory.resource == PluginWorkspaceResource(rootID: "w1", path: "src")) + } + + // MARK: Roots + + @Test func remoteAuthorityAndRemotePlaceholdersAreRejected() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let service = Self.makeService(isRemote: { $0.lastPathComponent == "placeholder" }) + let remote = PluginWorkspaceRoot(id: "r1", url: workspace.base, authority: .remote("ssh-remote+host"), generation: 1) + try workspace.makeDirectory("placeholder") + let placeholder = PluginWorkspaceRoot(id: "r2", url: workspace.url("placeholder"), authority: .local, generation: 1) + + for root in [remote, placeholder] { + #expect(await Self.failure { try await service.readDirectory("", in: root, caller: Self.caller, cursor: nil, limit: nil) } + == .unsupportedAuthority) + #expect(await Self.failure { try await service.readFile("a.txt", in: root, caller: Self.caller, maximumBytes: nil) } + == .unsupportedAuthority) + #expect(await Self.failure { try await service.findText(.init(pattern: "x"), in: root, caller: Self.caller, cursor: nil, limit: nil) } + == .unsupportedAuthority) + #expect(Self.rejection { _ = try service.watch("", recursive: true, in: root, caller: Self.caller) { _ in } } + == .unsupportedAuthority) + } + #expect(service.activeWatchStreamCount == 0) + } + + @Test func closedAndOlderGenerationsAreStale() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + try workspace.write("a.txt", "a") + let service = Self.makeService() + + _ = try await service.stat("a.txt", in: workspace.root(id: "w1", generation: 2), caller: Self.caller) + #expect(await Self.failure { try await service.stat("a.txt", in: workspace.root(id: "w1", generation: 1), caller: Self.caller) } + == .staleWorkspace) + service.closeWorkspace(rootID: "w1", generation: 2) + #expect(await Self.failure { try await service.stat("a.txt", in: workspace.root(id: "w1", generation: 2), caller: Self.caller) } + == .staleWorkspace) + _ = try await service.stat("a.txt", in: workspace.root(id: "w1", generation: 3), caller: Self.caller) + } + + @Test func twoWorkspacesNeverShareResultsOrEvents() async throws { + let first = try TemporaryWorkspace() + let second = try TemporaryWorkspace() + defer { + first.remove() + second.remove() + } + try first.write("one.txt", "1") + try second.write("two.txt", "2") + let watchers = FakeChangeSources() + let delivery = ManualDelivery() + let service = Self.makeService(watchers: watchers, delivery: delivery) + let rootOne = first.root(id: "w1") + let rootTwo = second.root(id: "w2") + + let listingOne = try await service.readDirectory("", in: rootOne, caller: Self.caller, cursor: nil, limit: nil) + let listingTwo = try await service.readDirectory("", in: rootTwo, caller: Self.caller, cursor: nil, limit: nil) + #expect(listingOne.entries.map(\.resource) == [PluginWorkspaceResource(rootID: "w1", path: "one.txt")]) + #expect(listingTwo.entries.map(\.resource) == [PluginWorkspaceResource(rootID: "w2", path: "two.txt")]) + + let eventsOne = EventRecorder() + let eventsTwo = EventRecorder() + let subscriptionOne = try service.watch("", recursive: true, in: rootOne, caller: Self.caller, onEvent: eventsOne.record) + let subscriptionTwo = try service.watch("", recursive: true, in: rootTwo, caller: Self.caller, onEvent: eventsTwo.record) + defer { + subscriptionOne.dispose() + subscriptionTwo.dispose() + } + #expect(watchers.sources.count == 2) + try watchers.sources[0].send(["one.txt"]) + delivery.drain() + #expect(eventsOne.events.map(\.changedPaths) == [["one.txt"]]) + #expect(eventsOne.events.first?.rootID == "w1") + #expect(eventsTwo.events.isEmpty) + } + + // MARK: Watch + + @Test func subscriptionsShareOneSourceAndAreReleasedByOwnership() throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let watchers = FakeChangeSources() + let service = Self.makeService(watchers: watchers, delivery: ManualDelivery()) + let root = workspace.root(id: "w1") + + let first = try service.watch("", recursive: true, in: root, caller: Self.caller) { _ in } + let second = try service.watch("src", recursive: false, in: root, caller: Self.caller) { _ in } + _ = try service.watch("docs", recursive: true, in: root, caller: Self.otherCaller) { _ in } + #expect(watchers.sources.count == 1) + #expect(watchers.sources[0].isRunning) + #expect(service.activeWatchSubscriptionCount == 3) + + first.dispose() + first.dispose() + #expect(service.activeWatchSubscriptionCount == 2) + service.releaseResources(of: Self.caller) + #expect(service.activeWatchSubscriptionCount == 1) + #expect(watchers.sources[0].isRunning) + second.dispose() + + service.closeWorkspace(rootID: "w1", generation: 1) + #expect(service.activeWatchSubscriptionCount == 0) + #expect(service.activeWatchStreamCount == 0) + #expect(!watchers.sources[0].isRunning) + #expect(Self.rejection { _ = try service.watch("", recursive: true, in: root, caller: Self.caller) { _ in } } == .staleWorkspace) + } + + @Test func newGenerationReleasesOldWatchersAndDropsTheirLateEvents() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let watchers = FakeChangeSources() + let delivery = ManualDelivery() + let service = Self.makeService(watchers: watchers, delivery: delivery) + let oldEvents = EventRecorder() + let newEvents = EventRecorder() + + _ = try service.watch("", recursive: true, in: workspace.root(id: "w1", generation: 1), caller: Self.caller, onEvent: oldEvents.record) + _ = try await service.stat("", in: workspace.root(id: "w1", generation: 2), caller: Self.caller) + #expect(service.activeWatchSubscriptionCount == 0) + #expect(!watchers.sources[0].isRunning) + + let current = try service.watch("", recursive: true, in: workspace.root(id: "w1", generation: 2), caller: Self.caller, onEvent: newEvents.record) + defer { current.dispose() } + // The stopped source of generation 1 still calls back late. + try watchers.sources[0].send(["stale.txt"], evenIfStopped: true) + try watchers.sources[1].send(["fresh.txt"]) + delivery.drain() + + #expect(oldEvents.events.isEmpty) + #expect(newEvents.events.map(\.changedPaths) == [["fresh.txt"]]) + #expect(newEvents.events.map(\.generation) == [2]) + } + + @Test func eventsAreCoalescedFilteredAndCollapseToRescanOnOverflow() throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let watchers = FakeChangeSources() + let delivery = ManualDelivery() + var budgets = PluginWorkspaceFileBudgets() + budgets.maximumPendingWatchPaths = 3 + let service = Self.makeService(budgets: budgets, watchers: watchers, delivery: delivery) + let root = workspace.root(id: "w1") + let recursive = EventRecorder() + let shallow = EventRecorder() + + let all = try service.watch("", recursive: true, in: root, caller: Self.caller, onEvent: recursive.record) + let src = try service.watch("src", recursive: false, in: root, caller: Self.caller, onEvent: shallow.record) + defer { + all.dispose() + src.dispose() + } + let source = watchers.sources[0] + + try source.send(["src/a.swift", "src/deep/b.swift"]) + try source.send(["src/a.swift", "node_modules/pkg/index.js", "README.md"]) + #expect(delivery.pendingCount == 2) + delivery.drain() + #expect(recursive.events.map(\.changedPaths) == [["src/a.swift", "src/deep/b.swift", "README.md"]]) + #expect(recursive.events.map(\.sequence) == [1]) + #expect(shallow.events.map(\.changedPaths) == [["src/a.swift"]]) + + try source.send(["a", "b", "c", "d"]) + delivery.drain() + #expect(recursive.events.last?.requiresRescan == true) + #expect(recursive.events.last?.changedPaths == []) + #expect(recursive.events.last?.sequence == 2) + + source.sendBatch(DirectoryChangeBatch(requiresFullRescan: true)) + delivery.drain() + #expect(shallow.events.last?.requiresRescan == true) + } + + @Test func disposedSubscriptionReceivesNothingAlreadyQueued() throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let watchers = FakeChangeSources() + let delivery = ManualDelivery() + let service = Self.makeService(watchers: watchers, delivery: delivery) + let events = EventRecorder() + + let subscription = try service.watch("", recursive: true, in: workspace.root(id: "w1"), caller: Self.caller, onEvent: events.record) + try watchers.sources[0].send(["a.txt"]) + subscription.dispose() + delivery.drain() + #expect(events.events.isEmpty) + #expect(!watchers.sources[0].isRunning) + } + + @Test func watchSubscriptionsAreCappedPerCaller() throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + var budgets = PluginWorkspaceFileBudgets() + budgets.maximumWatchSubscriptionsPerCaller = 2 + let service = Self.makeService(budgets: budgets, watchers: FakeChangeSources(), delivery: ManualDelivery()) + let root = workspace.root(id: "w1") + + _ = try service.watch("a", recursive: true, in: root, caller: Self.caller) { _ in } + _ = try service.watch("b", recursive: true, in: root, caller: Self.caller) { _ in } + #expect(Self.rejection { _ = try service.watch("c", recursive: true, in: root, caller: Self.caller) { _ in } } + == .limitExceeded(.watchSubscriptions)) + _ = try service.watch("c", recursive: true, in: root, caller: Self.otherCaller) { _ in } + service.releaseResources(of: Self.caller) + service.releaseResources(of: Self.otherCaller) + #expect(service.activeWatchStreamCount == 0) + } + + // MARK: Search + + @Test func findTextPagesDiskResultsInsideTheRootOnly() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let search = FakeSearch() + search.textResults = { rootURL in + [ + FileSearchResult(url: rootURL.appendingPathComponent("a.swift"), line: 3, preview: "let x = 1"), + FileSearchResult(url: rootURL.appendingPathComponent("src/b.swift"), line: 7, preview: "x += 1"), + FileSearchResult(url: URL(fileURLWithPath: "/elsewhere/c.swift"), line: 1, preview: "x"), + FileSearchResult(url: rootURL.appendingPathComponent("d.swift"), line: 9, preview: "x()"), + FileSearchResult(url: rootURL.appendingPathComponent("e.swift"), line: nil, preview: "", kind: .file), + ] + } + var budgets = PluginWorkspaceFileBudgets() + budgets.searchResultCap = 5 + let service = Self.makeService(budgets: budgets, search: search) + let root = workspace.root(id: "w1") + let query = PluginWorkspaceFindTextQuery(pattern: "x") + + let first = try await service.findText(query, in: root, caller: Self.caller, cursor: nil, limit: 2) + #expect(first.matches.map(\.resource.path) == ["a.swift", "src/b.swift"]) + #expect(first.matches.map(\.line) == [3, 7]) + #expect(first.isTruncated) + #expect(first.source == .disk) + let cursor = try #require(first.nextCursor) + let second = try await service.findText(query, in: root, caller: Self.caller, cursor: cursor, limit: 2) + #expect(second.matches.map(\.resource.path) == ["d.swift"]) + #expect(second.nextCursor == nil) + + let otherQuery = PluginWorkspaceFindTextQuery(pattern: "y") + #expect(await Self.failure { try await service.findText(otherQuery, in: root, caller: Self.caller, cursor: cursor, limit: 2) } + == .invalidCursor) + } + + @Test func findFilesUsesTheFileProviderAndReportsUnavailableIndex() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let search = FakeSearch() + search.fileResults = { rootURL in + [FileSearchResult(url: rootURL.appendingPathComponent("Sources/App.swift"), line: nil, preview: "", kind: .file)] + } + let service = Self.makeService(search: search) + let root = workspace.root(id: "w1") + + let page = try await service.findFiles(.init(query: "App"), in: root, caller: Self.caller, cursor: nil, limit: nil) + #expect(page.matches == [PluginWorkspaceFileMatch(resource: PluginWorkspaceResource(rootID: "w1", path: "Sources/App.swift"))]) + #expect(!page.isTruncated) + #expect(search.requestedKinds == [[.file]]) + + search.fileResults = { _ in nil } + #expect(await Self.failure { try await service.findFiles(.init(query: "App"), in: root, caller: Self.caller, cursor: nil, limit: nil) } + == .searchUnavailable) + } + + @Test func cancelledSearchReturnsAtOnceAndConcurrencyIsBudgeted() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + let entered = TestGate() + let release = TestGate() + defer { release.open() } + let search = FakeSearch() + search.textResults = { _ in + entered.open() + // test-stability: bounded on the service's worker queue; the test releases the gate in defer. + _ = release.waitSynchronously(timeout: 5) + return [] + } + var budgets = PluginWorkspaceFileBudgets() + budgets.maximumConcurrentOperationsPerCaller = 1 + let service = Self.makeService(budgets: budgets, search: search) + let root = workspace.root(id: "w1") + + let task = Task { + try await service.findText(.init(pattern: "slow"), in: root, caller: Self.caller, cursor: nil, limit: nil) + } + #expect(await entered.waitUntilOpen(timeout: .seconds(5))) + #expect(await Self.failure { try await service.stat("", in: root, caller: Self.caller) } == .limitExceeded(.concurrentOperations)) + _ = try await service.stat("", in: root, caller: Self.otherCaller) + + task.cancel() + let result = await task.result + #expect(Self.failure(of: result) == .cancelled) + #expect(!release.isOpen) + } + + // MARK: Helpers + + private static func makeService( + budgets: PluginWorkspaceFileBudgets = .default, + search: FakeSearch = FakeSearch(), + watchers: FakeChangeSources = FakeChangeSources(), + delivery: ManualDelivery = ManualDelivery(), + isRemote: @escaping @Sendable (URL) -> Bool = { _ in false } + ) -> MacPluginWorkspaceFileService { + MacPluginWorkspaceFileService( + budgets: budgets, + searchOperations: search, + watcherFactory: watchers, + isRemoteWorkspaceRoot: isRemote, + makeWatchDelivery: { delivery } + ) + } + + private static func failure(_ body: () async throws -> Value) async -> PluginWorkspaceFileError? { + do { + _ = try await body() + return nil + } catch { + return error as? PluginWorkspaceFileError + } + } + + private static func rejection(_ body: () throws -> Value) -> PluginWorkspaceFileError? { + do { + _ = try body() + return nil + } catch { + return error as? PluginWorkspaceFileError + } + } + + private static func failure(of result: Result) -> PluginWorkspaceFileError? { + guard case let .failure(error) = result else { return nil } + return error as? PluginWorkspaceFileError + } + + private static func readText(named name: String, in directory: Int32) throws -> String? { + let file = try MacWorkspaceSecurePath.openRegularFile(named: name, in: directory) + defer { Darwin.close(file.descriptor) } + var buffer = [UInt8](repeating: 0, count: 256) + let count = buffer.withUnsafeMutableBytes { Darwin.read(file.descriptor, $0.baseAddress, 256) } + guard count >= 0 else { return nil } + return String(decoding: buffer[0.. URL { + base.appendingPathComponent(relativePath) + } + + func root( + id: String, + generation: UInt64 = 1, + visibilityRules: FileVisibilityRules = .default + ) -> PluginWorkspaceRoot { + PluginWorkspaceRoot(id: id, url: base, authority: .local, generation: generation, visibilityRules: visibilityRules) + } + + func write(_ relativePath: String, _ text: String) throws { + try write(relativePath, Data(text.utf8)) + } + + func write(_ relativePath: String, _ data: Data) throws { + let target = url(relativePath) + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: target) + } + + func makeDirectory(_ relativePath: String) throws { + try FileManager.default.createDirectory(at: url(relativePath), withIntermediateDirectories: true) + } + + func remove() { + try? FileManager.default.removeItem(at: base) + } +} + +private final class FakeChangeSources: DirectoryWatcherFactory, @unchecked Sendable { + final class Source: DirectoryChangeSource, @unchecked Sendable { + let workspaceRoot: URL + private let onChange: @Sendable (DirectoryChangeBatch) -> Void + private let lock = NSLock() + private var running = false + + init(workspaceRoot: URL, onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void) { + self.workspaceRoot = workspaceRoot + self.onChange = onChange + } + + var isRunning: Bool { lock.withLock { running } } + func start() { lock.withLock { running = true } } + func stop() { lock.withLock { running = false } } + + /// Reports workspace-relative paths as the absolute real paths + /// FSEvents would deliver. + func send(_ relativePaths: [String], evenIfStopped: Bool = false) throws { + guard evenIfStopped || isRunning else { throw CocoaError(.featureUnsupported) } + onChange(DirectoryChangeBatch(workspacePaths: relativePaths.map { workspaceRoot.appendingPathComponent($0).path })) + } + + func sendBatch(_ batch: DirectoryChangeBatch) { + onChange(batch) + } + } + + private let lock = NSLock() + private var made: [Source] = [] + + var sources: [Source] { lock.withLock { made } } + + func make( + configuration: DirectoryWatchConfiguration, + visibilityRules: FileVisibilityRules, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) -> any DirectoryChangeSource { + let source = Source(workspaceRoot: configuration.workspaceRoot, onChange: onChange) + lock.withLock { made.append(source) } + return source + } +} + +/// Holds deliveries until the test drains them on its own thread. +private final class ManualDelivery: PluginWorkspaceWatchDelivery, @unchecked Sendable { + private let lock = NSLock() + private var queued: [@Sendable () -> Void] = [] + + var pendingCount: Int { lock.withLock { queued.count } } + + func schedule(_ work: @escaping @Sendable () -> Void) { + lock.withLock { queued.append(work) } + } + + func drain() { + while true { + let next: (@Sendable () -> Void)? = lock.withLock { queued.isEmpty ? nil : queued.removeFirst() } + guard let next else { return } + next() + } + } +} + +private final class EventRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [PluginWorkspaceWatchEvent] = [] + + var events: [PluginWorkspaceWatchEvent] { lock.withLock { recorded } } + + var record: @Sendable (PluginWorkspaceWatchEvent) -> Void { + { [self] event in lock.withLock { recorded.append(event) } } + } +} + +private final class FakeSearch: SearchOperations, @unchecked Sendable { + private let lock = NSLock() + private var textProvider: @Sendable (URL) -> [FileSearchResult]? = { _ in [] } + private var fileProvider: @Sendable (URL) -> [FileSearchResult]? = { _ in [] } + private var kinds: [Set] = [] + + var textResults: @Sendable (URL) -> [FileSearchResult]? { + get { lock.withLock { textProvider } } + set { lock.withLock { textProvider = newValue } } + } + + var fileResults: @Sendable (URL) -> [FileSearchResult]? { + get { lock.withLock { fileProvider } } + set { lock.withLock { fileProvider = newValue } } + } + + var requestedKinds: [Set] { lock.withLock { kinds } } + + func search(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> [FileSearchResult]? { + textResults(rootURL) + } + + func searchEverywhere( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules, + includeGeneratedFileNames: Bool, + kinds: Set + ) -> SearchEverywhereResults? { + lock.withLock { self.kinds.append(kinds) } + return fileResults(rootURL).map { SearchEverywhereResults(fileMatches: $0) } + } + + func previewReplacement( + at rootURL: URL, + query: String, + replacement: String, + options: ProjectSearchOptions, + paths: [String], + textOverrides: [String: String], + visibilityRules: SearchVisibilityRules + ) -> [ProjectReplacementFile]? { nil } + + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} From 97e7ddd2af0638e0d91be93bf626ef5ec5977a1e Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 10:22:23 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat(macos):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=AE=BF=E4=B8=BB=E7=9A=84=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E6=89=93=E5=BC=80=E3=80=81HTML=20=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E3=80=81=E5=A4=96=E9=83=A8=E6=B5=8F=E8=A7=88=E5=99=A8=E5=92=8C?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=B6=88=E6=81=AF=E5=86=85=E9=83=A8=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 docs/development/macos-plugin-api-plan.md 中 API-15、API-16 和 API-17 最小部分的宿主内部服务。只提供 Swift 协议和实现,不对插件公开,不修改插件契约、 Rust capabilities 和 PluginTypes 的 hostCapabilities,授权与调用分发留给 M0。 作用域与资源 - 每次调用显式携带插件、窗口、工作区和工作区代际,不回退到当前活动窗口。 - PluginHostScopeGate 在窗口缺失、远程工作区、工作区或代际变化时拒绝,并在每个 等待点之后再次校验,窗口关闭或工作区替换后不再放光标、不再启动浏览器。 - 资源使用“根 ID + 工作区相对路径”,在符号链接解析后按带尾部斜杠的前缀比较, 拒绝路径逃逸、兄弟目录前缀、控制字符和超长路径。 editor.open / reveal - 复用 documentFeature.openFileAsync 和文档 owner:已打开的文档只激活不重载, 未保存缓冲区保持原样;没打开才从磁盘加载一次。 - activation 为 select 时调用 selectEditorTab,等同点击标签,把后台标签切成前台 标签,编辑器切片读选区、改文本前需先走这条路径;background 不改前台标签、 不放光标。结果中 isActive 表示标签在前台,windowIsForeground 表示窗口在前台。 - 后台窗口只在自己的窗口内工作,不提升窗口、不激活应用。 - range 只落到起点光标并在结果里明示;可替换预览标签暂不支持,明确拒绝。 - reveal 要求文件已打开,否则返回 documentNotOpen。 preview.openHTML - 仅接受 .html/.htm,走 editor.open 的 select 路径,内嵌预览使用当前缓冲区。 - 仅预览模式是标签视图的 @State,暂不支持,明确拒绝。 browser.openExternal - 网址只允许 http/https,必须有主机名,禁止用户信息、空白和控制字符,限长 4096 字节;其它 scheme 一律拒绝,不追加内容、不执行脚本。 - 本地文件只接工作区内的 .html/.htm,让系统浏览器读磁盘;打开前先让编辑器交出 未报告的输入,缓冲区脏或编辑器未确认都按“有未保存修改”处理。调用方可选择 openSavedFile 并在结果里明示,或 requireSaved 返回 unsavedChanges。 - PlatformUI 新增 openWebURLInBrowser;MacHTMLBrowserOpener 新增 openWeb, 自身再次校验协议和主机,只向 Launch Services 查询处理浏览器。 window.showInformation / Warning / Error - 每个窗口一个 PluginHostMessageFeatureModel,WorkbenchView 左下角用 PluginHostMessageStack 原样显示插件文字和按钮,不走会翻译文案的宿主通知栈。 - 最多同时显示 3 条,其余排队;返回用户选择的按钮序号或关闭、窗口关闭、 工作区替换、调用取消等结束原因,每条消息恰好结束一次。 - 限额:同一插件每窗口 10 秒 5 条、同时 3 条,每窗口共 10 条;文字 2000 字符, 按钮 3 个、每个 40 字符。超频返回 rateLimited 并向用户提示一次,队列满返回 messageQueueFull;限频时间由调用方注入。 - 宿主自己的 20 条文案补齐 shared/i18n 中英词条并重新生成。 HTML 预览与 plugin bridge 隔离 - 核对结论:预览使用全新的 WKWebViewConfiguration,唯一脚本处理器是应用内容世界里 的 litheScrollbarFrame;lithe 消息处理器和 lithe-plugin 协议只在 MacPluginWebPage,项目页面和外部浏览器都拿不到 bridge,无需改代码。 - 新增 HTMLPreviewBridgeIsolationTests 作为回归测试。 测试与文档 - 新增 PluginHostEditorServiceTests、PluginHostBrowserServiceTests、 PluginHostMessageServiceTests(含限频器与编辑器同步等待)、 HTMLPreviewBridgeIsolationTests,并扩展 HTMLBrowserOpenerTests;测试使用假窗口、 注入时钟和有界等待,不依赖 sleep。 - 新增 Agent Note 2026-10-30-plugin-host-open-preview-services.md,记录决策、 前台标签与前台窗口的区别、未保存语义、bridge 隔离证据、运行期不写发行包、 已知限制和被否方案。 本次未在本机编译或运行 Swift 测试。 --- ...10-30-plugin-host-open-preview-services.md | 144 ++++++ macos/Resources/en.lproj/Localizable.strings | 40 ++ .../zh-Hans.lproj/Localizable.strings | 40 ++ .../Composition/AppModelFeatureGraph.swift | 2 + .../Features/PluginHostBrowserService.swift | 119 +++++ .../Features/PluginHostEditorFlush.swift | 49 ++ .../Features/PluginHostEditorService.swift | 112 +++++ .../PluginHostMessageFeatureModel.swift | 133 +++++ .../Features/PluginHostMessageService.swift | 144 ++++++ .../Features/PluginHostPreviewService.swift | 71 +++ .../Features/PluginHostScope.swift | 334 +++++++++++++ .../Features/PluginHostWindowServices.swift | 51 ++ .../Sources/Lithe/Core/Ports/PlatformUI.swift | 7 + .../AppModel/AppModel+PluginHostWindow.swift | 126 +++++ .../Lithe/Models/AppModel/AppModel.swift | 3 + .../ProjectSessionManager+PluginHost.swift | 12 + .../MacOS/UI/MacHTMLBrowserOpener.swift | 21 +- .../Platform/MacOS/UI/MacPlatformUI.swift | 4 + .../Workbench/PluginHostMessageStack.swift | 94 ++++ .../Lithe/Views/Workbench/WorkbenchView.swift | 4 + .../LitheTests/HTMLBrowserOpenerTests.swift | 57 +++ .../HTMLPreviewBridgeIsolationTests.swift | 46 ++ .../PluginHostBrowserServiceTests.swift | 226 +++++++++ .../PluginHostEditorServiceTests.swift | 423 ++++++++++++++++ .../PluginHostMessageServiceTests.swift | 469 ++++++++++++++++++ .../LitheTests/PluginHostTestSupport.swift | 254 ++++++++++ shared/i18n/locales/en-US.json | 22 +- shared/i18n/locales/zh-CN.json | 22 +- shared/i18n/messages.json | 225 +++++++++ 29 files changed, 3249 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostMessageService.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostScope.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift create mode 100644 macos/Sources/Lithe/Models/Workspace/ProjectSessionManager+PluginHost.swift create mode 100644 macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift create mode 100644 macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift create mode 100644 macos/Tests/LitheTests/PluginHostBrowserServiceTests.swift create mode 100644 macos/Tests/LitheTests/PluginHostEditorServiceTests.swift create mode 100644 macos/Tests/LitheTests/PluginHostMessageServiceTests.swift create mode 100644 macos/Tests/LitheTests/PluginHostTestSupport.swift diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md new file mode 100644 index 0000000000..d1a9a22f00 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md @@ -0,0 +1,144 @@ +# Agent 笔记:插件宿主的编辑器打开、HTML 预览、外部浏览器与窗口消息服务 + +状态:已实现 + +## 先说结论 + +macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`preview.openHTML`、`browser.openExternal`)和 API-17 的最小部分(`window.showInformation/Warning/Error`)先在宿主里做成**内部服务**:一组 Swift 协议加实现,不对插件公开,也不碰插件契约。每次调用都必须显式带上“哪个插件、哪个窗口、哪个工作区(含代际)”,服务不会回退到“当前活动窗口”;资源用“根 ID + 工作区相对路径”表示,不收绝对路径。 + +三条最容易被改坏的规则:已经打开的文档,不管有没有未保存修改,都不会被磁盘内容替换;后台窗口的调用只在它自己的窗口里干活,不抢焦点;项目 HTML 和外部导航永远拿不到插件 bridge(页面与宿主通信的消息通道)。`editor.open` 用 `.select` 激活时,会像点击标签一样把文档切成前台标签,这是插件读选区、改文本前必须先做的一步。 + +后续 M0(激活与作用域协议)把这些服务接到插件调用分发和权限授予上;本篇不涉及授权,也不做错误码映射。 + +## 问题 + +计划里这几项能力都只写了“谁能调、该怎么限制”,没有写落在哪一层、什么情况下算越界。直接把 `AppModel`(一个项目窗口的总入口对象)暴露给插件调度有三个风险: + +1. 插件调用拿到的是“此刻活动的窗口”。用户在调用过程中切换窗口或工作区,文件就会在错误的地方打开,或者消息弹到另一个项目里。 +2. 打开文件的现成入口(`documentFeature.openFileAsync`)只负责加载,不负责“让标签到前台”,也不区分“已经打开且有未保存修改”的情况,需要在外面明确约定,不然插件会以为 `open` 之后文档就在前台。 +3. 外部浏览器和 HTML 预览的边界不清楚:本地文件到底读磁盘还是读缓冲区、未保存时怎么告知、能不能传 `javascript:` 之类的地址,都没有定。 + +## 决策 + +### 1. 窗口端口加作用域闸门,服务只依赖协议 + +`PluginHostWindowPort`(一个窗口对宿主服务暴露的最小能力)由 `AppModel` 在 `AppModel+PluginHostWindow.swift` 里实现;`PluginHostWindowDirectory`(按窗口 ID 找窗口的目录)由 `ProjectSessionManager` 实现。服务只持有目录,每次调用先过 `PluginHostScopeGate`: + +- 窗口不存在或没有工作区:`windowUnavailable`。 +- 工作区是远程的:`remoteWorkspaceUnsupported`,在检查路径之前就拒绝,插件不会从错误信息里得知远端有哪些文件。 +- 调用带的工作区路径或代际和窗口现状不一致:`workspaceChanged`。 +- 所有会等待的步骤(加载文件、等编辑器回报输入、等用户回答)结束后再过一次闸门,窗口关了或工作区换了就失败,不再放置光标、不再启动浏览器。 + +闸门只弱引用目录,窗口关闭后服务不会让它残留。 + +### 2. 资源是“根 ID + 相对路径”,校验在规范化之后 + +`PluginHostResourceResolver` 先拒绝畸形路径(空、过长、以 `/` 开头或结尾、含控制字符、反斜杠、`.` 或 `..` 段),再按根 ID 找根目录,最后在符号链接解析后的路径上做“带尾部斜杠的前缀比较”。所以: + +- `link/secret.txt` 指向工作区外,得到 `outsideWorkspace`。 +- 兄弟目录 `/x/workspace-evil` 不会因为名字以 `/x/workspace` 开头而被放行。 +- 返回给调用方的仍然是工作区自己的拼写(不是解析后的路径),这样已打开的缓冲区能按同一个路径匹配到。 + +### 3. `editor.open` 与前台标签 + +`PluginHostEditorService.open` 的顺序:闸门、选项检查(`tab == .preview` 当前不支持,明确拒绝,不降级成普通标签)、范围校验(行列从 1 开始,结束不早于开始)、解析资源、读打开状态、再过闸门、交给窗口打开、再过闸门、放置光标。 + +- 已打开的文档只会被激活,不会重新读盘。未保存的缓冲区保持原样,这是 API-15 的硬要求,对应测试 `anOpenDirtyBufferIsKeptAndNeverReloadedFromDisk`。 +- `activation == .select`:加载(如需要)后调用 `selectEditorTab(.document(id))`,效果等同用户点击标签:跨编辑器分组、盖在 diff / 终端 / Agent 标签之上也会切到前台。结果里的 `document.isActive` 就是这个标签是否在前台。 +- `activation == .background`:只保证文档已打开,不改前台标签,也不放光标。 +- `reveal`:文件没打开时返回 `documentNotOpen`,不替插件加载文件。 +- 范围只落到起点:编辑器的导航目标(`EditorNavigationTarget`)没有“结束位置”,所以插件传了范围也只放光标,结果用 `appliedSelection == .caret(起点)` 明说,不假装选中了。 + +“前台标签”和“前台窗口”是两件事。`isActive` 看标签:当前窗口的活动文档、没有 diff 盖着、聚焦的编辑器分组也指向它。窗口是否在前台来自 `snapshot.isForeground`,只放在结果的 `windowIsForeground` 里。后台窗口的调用会在自己的窗口里把标签切到前台,但不会提升窗口、不会激活应用。已知限制:Monaco 的 `navigate` 会让网页里的编辑器拿到 DOM 焦点,但不会提升窗口。 + +### 4. `preview.openHTML` + +内嵌预览是文件自己的编辑器标签里的一个面板,吃的是编辑器的当前缓冲区,所以未保存的修改会显示在预览里(结果的 `usesLiveBuffer` 永远是 `true`,用来和系统浏览器对照)。服务只做两件事:检查扩展名是 `.html` / `.htm`(不区分大小写,否则 `notHTMLDocument`),再走 `editor.open` 的 `.select` 路径。“只显示预览”(`.previewOnly`)当前是每个标签视图里的 `@State`,服务没法设置,所以明确拒绝为 `unsupportedOption`。 + +### 5. `browser.openExternal` 与未保存状态 + +- 网址:只收 `http` / `https`,必须有主机名,不能带用户名或密码,不能含空白或控制字符,长度不超过 4096 字节,协议名会转成小写。`file:`、`javascript:`、`data:`、`lithe-plugin:`、`mailto:` 等一律是 `unsupportedURL`。服务不往地址里追加任何内容,也不执行脚本。 +- 本地文件:只收工作区里的 `.html` / `.htm`,系统浏览器读的是**磁盘上的文件**。 +- 未保存状态:先问窗口取文档状态,且要求窗口先让编辑器把还没交出来的输入报上来(`flushingEditor: true`)。`hasUnsavedChanges = document.isDirty || !editorConfirmed`:缓冲区脏,或者编辑器在 2 秒内没确认交完输入,都算“可能比磁盘新”。调用方用 `unsaved` 选择:`.openSavedFile` 照样打开并在结果里写明 `savedFile(hasUnsavedChanges: true)`;`.requireSaved` 直接返回 `unsavedChanges`,让插件先保存。没打开的文件没有缓冲区,视为没有未保存修改。 +- 启动由平台适配器 `MacHTMLBrowserOpener.openWeb` 做:它自己再检查一遍协议和主机名,只向 Launch Services(macOS 的应用启动服务)问“谁处理这个地址”,再用那个浏览器打开,不会借道其他应用。适配器的失败统一映射成 `browserUnavailable`;任务取消照原样抛出。 + +### 6. HTML 预览与外部导航没有插件 bridge(已核对) + +插件 bridge 只存在于 `MacPluginWebPage`:消息处理器 `lithe` 注册在 `.page` 世界,自定义协议 `lithe-plugin` 也只在这里注册。内嵌 HTML 预览用的是全新的 `WKWebViewConfiguration`,它唯一的脚本消息处理器是 `litheScrollbarFrame`,注册在 `HTMLPreviewScrollbars.contentWorld`(应用自己的内容世界),项目页面的脚本世界里看不到任何 `window.webkit.messageHandlers.*`。`litheEditor` 只在 Monaco 的 `lithe-editor` 配置上。导航策略 `HTMLPreviewNavigationPolicy` 取消 `file:` 和未知协议;外部浏览器是另一个进程,天然没有 bridge。 + +结论是不需要修代码,只加回归测试:`HTMLPreviewBridgeIsolationTests` 在真实的预览 web view 里探测页面世界的三个处理器名都是 `undefined`,同时用应用世界里的 `object` 作为对照,证明探测方法本身有效;另一个用例断言预览配置里没有 `lithe-plugin` 的协议处理器。以后给预览配置新增脚本处理器或协议处理器,这两个测试会失败。 + +### 7. 窗口消息单独做一个模型和视图,原样显示 + +宿主的通知栈会翻译文案和键,而插件的文字和按钮必须原样显示,所以插件消息不走它:每个项目窗口有一个 `PluginHostMessageFeatureModel`(挂在 `AppModelFeatureGraph.pluginMessages`),`WorkbenchView` 左下角叠一个 `PluginHostMessageStack`。 + +- 同时最多显示 3 条,其余按顺序排队;用户点按钮返回 `selected(index:)`,关闭返回 `dismissed(.user)`;窗口关闭或工作区替换时,显示中和排队中的消息都以 `dismissed(.windowClosed / .workspaceChanged)` 结束;调用方任务被取消则以 `dismissed(.cancelled)` 结束,不留悬空的消息。每条消息恰好结束一次。 +- 限额:同一个插件对同一个窗口,每 10 秒最多 5 条;同一个插件在同一个窗口最多 3 条在显示或排队;同一个窗口最多 10 条;文字最多 2000 个字符,按钮最多 3 个、每个最多 40 个字符;文字只允许换行和制表符这两种控制字符,按钮不允许任何控制字符。超出频率返回 `rateLimited`,同时向用户用宿主通知提示一次“某插件发送消息过快”(同一窗口同一时间窗内只提示一次,宿主文案走 `L10n.string` 和 `shared/i18n/`);超出队列返回 `messageQueueFull`。 +- 时间由调用方注入(`now`),限频逻辑 `PluginHostRateLimiter` 是纯值类型,测试不需要等待。 + +### 8. 运行期不写发行包 + +这些服务只读磁盘元数据(`FileStorage.metadata`)、读写内存状态、向 Launch Services 查询并启动浏览器,不创建任何文件。浏览器打开的是用户工作区里的 HTML,不涉及 `Contents/Resources`、插件目录或安装目录,不影响代码签名与 Sparkle 增量更新的基线。因此不新增资源路径,也不需要新增 bundle 哈希验证;`scripts/macos/verify/verify-runtime-bundle-immutability.sh` 照常覆盖。 + +### 正确做法 + +- 新服务一律从 `PluginHostCallContext` 取窗口和工作区,进入时过一次 `PluginHostScopeGate`,每次 `await` 之后再过一次。 +- 要让标签切到前台,调用 `.select`;要读选区或编辑前,先确认 `isActive`。 +- 要给用户看插件的原话,走 `PluginHostMessageFeatureModel`;宿主自己的文案走 `L10n.string`。 + +### 不要这样做 + +- 不要在服务里读文件内容或用磁盘内容覆盖已打开的缓冲区。 +- 不要在 `AppModel` 之外的层引用“当前活动窗口”。 +- 不要把预览 web view 的配置和 `MacPluginWebPage` 的配置共用,也不要给预览加消息处理器。 +- 不要因为某个插件的名字写分支;这些服务对所有插件一视同仁,差异只来自调用上下文和后续的授权。 + +## 考虑过的备选方案 + +### 备选方案一:直接让插件调度调用 `AppModel` + +最省事,不用多写协议。没有采用,因为 `AppModel` 面太宽,无法保证每个调用都显式带窗口和代际;协议化以后,服务可以用假窗口测试所有“窗口在等待期间消失”的路径,这些路径靠真实 UI 很难稳定复现。 + +### 备选方案二:把插件消息放进宿主通知栈 + +少一个视图和模型。没有采用,因为通知栈会翻译文案、合并重复项并按宿主规则自动消失,而插件消息必须原样显示、带按钮、等用户回答,并且要按窗口和插件限额。 + +### 备选方案三:`.preview` 标签和 `.previewOnly` 先做近似实现 + +可以把 `.preview` 当普通标签、`.previewOnly` 当并排预览,插件调用不会失败。没有采用,因为近似实现会让插件以为得到了可替换标签或纯预览,之后一旦真实支持就是行为变更。现在明确返回 `unsupportedOption`,等编辑器有可替换标签和可设置的预览模式后再放开。 + +### 备选方案四:外部浏览器打开前强制保存 + +最不容易让用户困惑。没有采用,因为保存会改磁盘文件,不应由一次“打开浏览器”的调用静默触发;改为让调用方选择 `.openSavedFile`(明说未保存)或 `.requireSaved`(先保存再来)。 + +## 后果 + +- 收益:窗口和工作区在每个等待点都被复核;已打开文档的未保存修改不会丢;外部浏览器的可启动目标有明确的白名单;预览与插件 bridge 隔离有回归测试守着。 +- 代价:多了一层协议和假对象;范围只落到光标;没有可替换的预览标签;消息堆叠在左下角,可能盖住左侧工具栏的一部分。 +- 已知限制:路径检查和打开之间存在竞态(先检查再打开,之间文件可被替换成指向外部的链接);大小写或 Unicode 规范化不同的路径别名可能产生第二个缓冲区;媒体和二进制文件返回 `openFailed`;`loadFile` 失败时可能在目标窗口弹出“无法打开”的通知。 +- 需要重新评估的触发条件:编辑器支持范围选择或可替换预览标签;预览配置新增脚本处理器;窗口之外的文件根(多根工作区)进入 `PluginHostRoot` 列表。 + +## 验证 + +- `./scripts/agent-notes/verify-agent-notes.sh` +- `./scripts/architecture/verify-service-boundaries.sh` +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` +- `node scripts/i18n/verify-app-language-localization.mjs` +- `node scripts/i18n/generate-i18n.mjs --check` +- `macos/Tests/LitheTests/` 下的 `PluginHostEditorServiceTests`、`PluginHostBrowserServiceTests`、`PluginHostMessageServiceTests`、`HTMLPreviewBridgeIsolationTests` 和 `HTMLBrowserOpenerTests`(需要在 Mac 上用 `xcodebuild test` 运行)。 + +## 适用范围 + +- `macos/Sources/Lithe/Application/Features/PluginHostScope.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostMessageService.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift` +- `macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift` +- `macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift` +- `macos/Sources/Lithe/Models/Workspace/ProjectSessionManager+PluginHost.swift` +- `macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift` +- `macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift` +- `macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift` diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 113e6dc714..8483e54750 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -45,6 +45,7 @@ "%@ provider (inside the plugin)" = "%@ provider (inside the plugin)"; "%@ provider (installed on this Mac)" = "%@ provider (installed on this Mac)"; "%@ returned an invalid language-server provider." = "%@ returned an invalid language-server provider."; +"%@ sent messages too quickly, so some were not shown." = "%@ sent messages too quickly, so some were not shown."; "%@ shares its contents with other paths. Save as Independent File updates only this path and breaks its hard-link relationship. Update All Hard Links writes to the shared file, including links outside this project; it is not atomic and an interrupted write may leave partial contents. Cancel keeps your edits unsaved." = "%@ shares its contents with other paths. Save as Independent File updates only this path and breaks its hard-link relationship. Update All Hard Links writes to the shared file, including links outside this project; it is not atomic and an interrupted write may leave partial contents. Cancel keeps your edits unsaved."; "%@ started a task that is still running" = "%@ started a task that is still running"; "%@ test run timed out after %@ seconds." = "%@ test run timed out after %@ seconds."; @@ -1934,6 +1935,7 @@ "Line Breakpoints" = "Line Breakpoints"; "Line breakpoint actions" = "Line breakpoint actions"; "Linear" = "Linear"; +"Lines and columns start at 1, and a range must not end before it starts." = "Lines and columns start at 1, and a range must not end before it starts."; "Lines kept for scrolling back in new terminal sessions." = "Lines kept for scrolling back in new terminal sessions."; "Linked this conversation to a pull request" = "Linked this conversation to a pull request"; "Linked worktree" = "Linked worktree"; @@ -2436,6 +2438,7 @@ "No upstream configured" = "No upstream configured"; "No usages found" = "No usages found"; "No verified Homebrew formula is configured for %@." = "No verified Homebrew formula is configured for %@."; +"No web browser could be opened." = "No web browser could be opened."; "No worktrees" = "No worktrees"; "Node.js %lld or later" = "Node.js %lld or later"; "Node.js %lld or later is required; found %@." = "Node.js %lld or later is required; found %@."; @@ -2494,8 +2497,10 @@ "One sentence focused on the main change" = "One sentence focused on the main change"; "One test: %@" = "One test: %@"; "One-time choices override repository preferences, which override application defaults. Git configuration is changed only when you save or clear a field below." = "One-time choices override repository preferences, which override application defaults. Git configuration is changed only when you save or clear a field below."; +"Only .html and .htm files can be shown in a browser." = "Only .html and .htm files can be shown in a browser."; "Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used." = "Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used."; "Only ask for actions detected as potentially unsafe" = "Only ask for actions detected as potentially unsafe"; +"Only http and https addresses with a host name can be opened." = "Only http and https addresses with a host name can be opened."; "Only local files can be attached to an Agent message." = "Only local files can be attached to an Agent message."; "Only regular files can be opened as text. Devices, sockets and named pipes are not supported." = "Only regular files can be opened as text. Devices, sockets and named pipes are not supported."; "Only source files of the current language can be opened from outside the workspace" = "Only source files of the current language can be opened from outside the workspace"; @@ -2967,6 +2972,7 @@ "Remote URL" = "Remote URL"; "Remote folder" = "Remote folder"; "Remote pending documents exceeded the startup limit. Wait for the language server to start." = "Remote pending documents exceeded the startup limit. Wait for the language server to start."; +"Remote workspaces are not supported by this operation yet." = "Remote workspaces are not supported by this operation yet."; "Remote: %@" = "Remote: %@"; "Removal pending" = "Removal pending"; "Remove" = "Remove"; @@ -3859,8 +3865,11 @@ "The field value does not match its declaration or exceeds its size limit." = "The field value does not match its declaration or exceeds its size limit."; "The field value is invalid or exceeds 1024 UTF-8 bytes." = "The field value is invalid or exceeds 1024 UTF-8 bytes."; "The file could not be decoded with the selected or detected encoding. Check its encoding or open it in an external editor." = "The file could not be decoded with the selected or detected encoding. Check its encoding or open it in an external editor."; +"The file could not be opened as a text document." = "The file could not be opened as a text document."; "The file could not be read. Check its availability and try again." = "The file could not be read. Check its availability and try again."; "The file has changed since the Agent edit. Open the diff and review it manually." = "The file has changed since the Agent edit. Open the diff and review it manually."; +"The file has unsaved changes. Save it first, because the browser shows the saved file." = "The file has unsaved changes. Save it first, because the browser shows the saved file."; +"The file is not open in this window." = "The file is not open in this window."; "The file is too large to compare." = "The file is too large to compare."; "The file no longer exists or was moved. Refresh the project and try again." = "The file no longer exists or was moved. Refresh the project and try again."; "The file or its hard links changed while saving. Your edits are still unsaved. Check the file and save again." = "The file or its hard links changed while saving. Your edits are still unsaved. Check the file and save again."; @@ -3874,6 +3883,7 @@ "The host could not be reached. Check the host name, the port, and your network." = "The host could not be reached. Check the host name, the port, and your network."; "The host could not create the plugin's workspace state directory." = "The host could not create the plugin's workspace state directory."; "The host does not serve %@." = "The host does not serve %@."; +"The host does not support the requested option yet." = "The host does not support the requested option yet."; "The host has no bridge method %@." = "The host has no bridge method %@."; "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts." = "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts."; "The image destination is outside the current workspace" = "The image destination is outside the current workspace"; @@ -3891,6 +3901,7 @@ "The local Codex account changed. Reconnect to refresh quota." = "The local Codex account changed. Reconnect to refresh quota."; "The local adapter is at or above the verified version." = "The local adapter is at or above the verified version."; "The location service did not answer." = "The location service did not answer."; +"The message text or its buttons are empty, too long, or too many." = "The message text or its buttons are empty, too long, or too many."; "The model is too large; some entries are not shown." = "The model is too large; some entries are not shown."; "The monitored JVM has exited or changed." = "The monitored JVM has exited or changed."; "The name of the artifact the build produces." = "The name of the artifact the build produces."; @@ -3963,6 +3974,11 @@ "The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state."; "The request has no secret." = "The request has no secret."; "The required tool is not available on the remote host." = "The required tool is not available on the remote host."; +"The resource does not exist." = "The resource does not exist."; +"The resource is not a regular file." = "The resource is not a regular file."; +"The resource is outside the workspace." = "The resource is outside the workspace."; +"The resource names a root this window does not offer." = "The resource names a root this window does not offer."; +"The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"." = "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"."; "The response stopped at the model's token limit." = "The response stopped at the model's token limit."; "The saved provider run configuration is unavailable." = "The saved provider run configuration is unavailable."; "The selected AI provider has an invalid API URL or model." = "The selected AI provider has an invalid API URL or model."; @@ -4017,6 +4033,8 @@ "The version control client could not be run. Check that it is installed." = "The version control client could not be run. Check that it is installed."; "The version control provider failed to start." = "The version control provider failed to start."; "The version control provider stopped before it was ready." = "The version control provider stopped before it was ready."; +"The window is closed or has no workspace open." = "The window is closed or has no workspace open."; +"The window now shows a different workspace than the one this call was made for." = "The window now shows a different workspace than the one this call was made for."; "The working copy is locked by an interrupted action. Run Cleanup, then try again." = "The working copy is locked by an interrupted action. Run Cleanup, then try again."; "The working copy is out of date. Update it, then commit again." = "The working copy is out of date. Update it, then commit again."; "The working copy was cleaned up." = "The working copy was cleaned up."; @@ -4093,12 +4111,14 @@ "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept."; "This plugin adds no options to this page." = "This plugin adds no options to this page."; "This plugin already has an installation in progress. Try again after it finishes." = "This plugin already has an installation in progress. Try again after it finishes."; +"This plugin already has too many messages waiting." = "This plugin already has too many messages waiting."; "This plugin declares no features." = "This plugin declares no features."; "This plugin declares no settings." = "This plugin declares no settings."; "This plugin has no backend that can run now." = "This plugin has no backend that can run now."; "This plugin is maintained and signed by Lithe. Report problems in the source repository." = "This plugin is maintained and signed by Lithe. Report problems in the source repository."; "This plugin is maintained by a community developer, not by Lithe. Lithe verified that the download matches the plugin repository, and will show what the plugin can run before installing it. Report problems with the plugin in its own repository." = "This plugin is maintained by a community developer, not by Lithe. Lithe verified that the download matches the plugin repository, and will show what the plugin can run before installing it. Report problems with the plugin in its own repository."; "This plugin requires a newer version of Lithe." = "This plugin requires a newer version of Lithe."; +"This plugin sent messages too quickly." = "This plugin sent messages too quickly."; "This preview contains changes that have not been officially released and may be unstable." = "This preview contains changes that have not been officially released and may be unstable."; "This project" = "This project"; "This project is not a Git repository" = "This project is not a Git repository"; @@ -4875,6 +4895,7 @@ "native.arg1ProviderInstalledOnThisMac" = "%1$@ provider (installed on this Mac)"; "native.arg1ProviderRunsWithArg2" = "%1$@ provider (runs with %2$@)"; "native.arg1Tools" = "%1$d tools"; +"native.argSentMessagesTooQuicklySoSomeWereNotShown" = "%1$@ sent messages too quickly, so some were not shown."; "native.arguments" = "Arguments"; "native.argumentsFromThePage" = "Arguments from the page:"; "native.argumentsOnePerLine" = "Arguments (one per line)"; @@ -6525,6 +6546,7 @@ "native.lineColumn" = "[Line] [:column]:"; "native.lineLld" = "%1$@, Line %2$lld, %3$@"; "native.linear" = "Linear"; +"native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts" = "Lines and columns start at 1, and a range must not end before it starts."; "native.linesKeptForScrollingBackInNewTerminalSessions" = "Lines kept for scrolling back in new terminal sessions."; "native.linkedThisConversationToAPullRequest" = "Linked this conversation to a pull request"; "native.linkedWorktree" = "Linked worktree"; @@ -7091,6 +7113,7 @@ "native.noUpstreamConfigured" = "No upstream configured"; "native.noUsagesFound" = "No usages found"; "native.noVerifiedHomebrewFormulaIsConfiguredFor" = "No verified Homebrew formula is configured for %1$@."; +"native.noWebBrowserCouldBeOpened" = "No web browser could be opened."; "native.noWorktrees" = "No worktrees"; "native.nodeJsDebuggingRequiresTheOfficialVscodeJs" = "Node.js debugging requires the official vscode-js-debug standalone DAP bundle. Set LITHE_JS_DEBUG_PATH to its dapDebugServer.js file or unpack it under .lithe/toolchains/js-debug."; "native.nodeJsLldOrLater" = "Node.js %1$lld or later"; @@ -7153,6 +7176,8 @@ "native.oneUsage" = "1 usage"; "native.onlyAPIKeysAreUsedTheAgentSOwnAccountSignInSuchAsAChatGPTLoginIsNeverUsed" = "Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used."; "native.onlyAskForActionsDetectedAsPotentiallyUnsafe" = "Only ask for actions detected as potentially unsafe"; +"native.onlyHtmlAndHtmFilesCanBeShownInABrowser" = "Only .html and .htm files can be shown in a browser."; +"native.onlyHttpAndHttpsAddressesWithAHostNameCanBeOpened" = "Only http and https addresses with a host name can be opened."; "native.onlyLocalFilesCanBeAttachedToAnAgentMessage" = "Only local files can be attached to an Agent message."; "native.onlySourceFilesOfTheCurrentLanguageCanBeOpenedFromOutsideTheWorkspace" = "Only source files of the current language can be opened from outside the workspace"; "native.onlyTheAPIURLModelAndAPIKeyAreSavedOtherCLIOptionsContinueToUseYourLocalConfig36d9d4cdfd" = "Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields."; @@ -7624,6 +7649,7 @@ "native.remoteTerminalInputBusy" = "The remote terminal is busy. Wait before sending more input."; "native.remoteTerminalInvalidDirectory" = "The terminal directory is outside the remote workspace."; "native.remoteURL" = "Remote URL"; +"native.remoteWorkspacesAreNotSupportedByThisOperationYet" = "Remote workspaces are not supported by this operation yet."; "native.removalPending" = "Removal pending"; "native.removeAll" = "Remove All"; "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChange7ffdbe003e" = "Remove '%1$@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning."; @@ -8444,7 +8470,10 @@ "native.theFeaturedMcpCatalogCouldNotBeLoaded" = "The featured MCP catalog could not be loaded."; "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit" = "The field value does not match its declaration or exceeds its size limit."; "native.theFieldValueIsInvalidOrExceeds1024UTF8Bytes" = "The field value is invalid or exceeds 1024 UTF-8 bytes."; +"native.theFileCouldNotBeOpenedAsATextDocument" = "The file could not be opened as a text document."; "native.theFileHasChangedSinceTheAgentEditOpenTheDiffAndReviewItManually" = "The file has changed since the Agent edit. Open the diff and review it manually."; +"native.theFileHasUnsavedChangesSaveItFirstBecauseTheBrowserShowsTheSavedFile" = "The file has unsaved changes. Save it first, because the browser shows the saved file."; +"native.theFileIsNotOpenInThisWindow" = "The file is not open in this window."; "native.theFileIsTooLargeToCompare" = "The file is too large to compare."; "native.theFileSystemThatHoldsTheHomeDirectory" = "The file system that holds the home directory."; "native.theFileWillBeCreatedWhenYouSave" = "The file will be created when you save."; @@ -8464,6 +8493,7 @@ "native.theHostCouldNotBeReachedCheckTheHostNameThePortAndYourNetwork" = "The host could not be reached. Check the host name, the port, and your network."; "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory" = "The host could not create the plugin's workspace state directory."; "native.theHostDoesNotServe" = "The host does not serve %1$@."; +"native.theHostDoesNotSupportTheRequestedOptionYet" = "The host does not support the requested option yet."; "native.theHostHasNoBridgeMethod" = "The host has no bridge method %1$@."; "native.theHostKeyWasNotAcceptedIfTheServerWasReinstalledRemoveItsOldEntryFromSshKnownHosts" = "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts."; "native.theImageDestinationIsOutsideTheCurrentWorkspace" = "The image destination is outside the current workspace"; @@ -8505,6 +8535,7 @@ "native.theMcpRegistryCouldNotBeLoadedTryAgainOrAddAServerManually" = "The MCP Registry could not be loaded. Try again or add a server manually."; "native.theMcpRegistryIsUnreachableCheckTheNetworkOrProxy" = "The MCP Registry is unreachable. Check the network or proxy."; "native.theMcpRegistryRequestTimedOutTryAgain" = "The MCP Registry request timed out. Try again."; +"native.theMessageTextOrItsButtonsAreEmptyTooLongOrTooMany" = "The message text or its buttons are empty, too long, or too many."; "native.theModelIsTooLargeSomeEntriesAreNotShown" = "The model is too large; some entries are not shown."; "native.theMonitoredJVMHasExitedOrChanged" = "The monitored JVM has exited or changed."; "native.theNameOfTheArtifactTheBuildProduces" = "The name of the artifact the build produces."; @@ -8572,6 +8603,11 @@ "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshThePreviewToRetry" = "The repository changed or another Git operation is running. Refresh the preview to retry."; "native.theRepositoryWillBeOpenedInDetachedHEADState" = "The repository will be opened in detached HEAD state."; "native.theRequestHasNoSecret" = "The request has no secret."; +"native.theResourceDoesNotExist" = "The resource does not exist."; +"native.theResourceIsNotARegularFile" = "The resource is not a regular file."; +"native.theResourceIsOutsideTheWorkspace" = "The resource is outside the workspace."; +"native.theResourceNamesARootThisWindowDoesNotOffer" = "The resource names a root this window does not offer."; +"native.theResourcePathMustBeRelativeUseSeparatorsAndNotContainOr" = "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"."; "native.theResponseStoppedAtTheModelSTokenLimit" = "The response stopped at the model's token limit."; "native.theSelectedAIProviderHasAnInvalidAPIURLOrModel" = "The selected AI provider has an invalid API URL or model."; "native.theSelectedAIProviderHasNoAPIKey" = "The selected AI provider has no API key."; @@ -8625,6 +8661,8 @@ "native.theVersionControlClientCouldNotBeRunCheckThatItIsInstalled" = "The version control client could not be run. Check that it is installed."; "native.theVersionControlProviderFailedToStart" = "The version control provider failed to start."; "native.theVersionControlProviderStoppedBeforeItWasReady" = "The version control provider stopped before it was ready."; +"native.theWindowIsClosedOrHasNoWorkspaceOpen" = "The window is closed or has no workspace open."; +"native.theWindowNowShowsADifferentWorkspaceThanTheOneThisCallWasMadeFor" = "The window now shows a different workspace than the one this call was made for."; "native.theWorkingCopyIsLockedByAnInterruptedActionRunCleanupThenTryAgain" = "The working copy is locked by an interrupted action. Run Cleanup, then try again."; "native.theWorkingCopyIsOutOfDateUpdateItThenCommitAgain" = "The working copy is out of date. Update it, then commit again."; "native.theWorkingCopyWasCleanedUp" = "The working copy was cleaned up."; @@ -8695,12 +8733,14 @@ "native.thisPermanentlyDeletesUncommittedAndUntrackedFilesInTheBranchItselfIsKept" = "This permanently deletes uncommitted and untracked files in '%1$@'. The branch itself is kept."; "native.thisPluginAddsNoOptionsToThisPage" = "This plugin adds no options to this page."; "native.thisPluginAlreadyHasAnInstallationInProgress" = "This plugin already has an installation in progress. Try again after it finishes."; +"native.thisPluginAlreadyHasTooManyMessagesWaiting" = "This plugin already has too many messages waiting."; "native.thisPluginDeclaresNoFeatures" = "This plugin declares no features."; "native.thisPluginDeclaresNoSettings" = "This plugin declares no settings."; "native.thisPluginHasNoBackendThatCanRun" = "This plugin has no backend that can run now."; "native.thisPluginIsMaintainedAndSignedByLitheReportProblemsInTheSourceRepository" = "This plugin is maintained and signed by Lithe. Report problems in the source repository."; "native.thisPluginIsMaintainedByACommunityDeveloperNotByLitheLitheVerifiedThatTheDownl1989920b43" = "This plugin is maintained by a community developer, not by Lithe. Lithe verified that the download matches the plugin repository, and will show what the plugin can run before installing it. Report problems with the plugin in its own repository."; "native.thisPluginRequiresANewerVersionOfLithe" = "This plugin requires a newer version of Lithe."; +"native.thisPluginSentMessagesTooQuickly" = "This plugin sent messages too quickly."; "native.thisPreviewContainsChangesThatHaveNotBeenOfficiallyReleasedAndMayBeUnstable" = "This preview contains changes that have not been officially released and may be unstable."; "native.thisProject" = "This project"; "native.thisProjectIsNotAGitRepository" = "This project is not a Git repository"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index ff12c68612..da9df01c4f 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -45,6 +45,7 @@ "%@ provider (inside the plugin)" = "%@ Provider(插件内置)"; "%@ provider (installed on this Mac)" = "%@ Provider(使用本机已安装的程序)"; "%@ returned an invalid language-server provider." = "%@ 返回了无效的语言服务器 Provider。"; +"%@ sent messages too quickly, so some were not shown." = "%@ 发送消息过快,部分消息未显示。"; "%@ shares its contents with other paths. Save as Independent File updates only this path and breaks its hard-link relationship. Update All Hard Links writes to the shared file, including links outside this project; it is not atomic and an interrupted write may leave partial contents. Cancel keeps your edits unsaved." = "%@ 与其他路径指向同一份文件内容。“保存为独立文件”只更新当前路径,并解除它与其他路径的硬链接关系。“更新所有硬链接”直接修改共享文件,包括项目外指向它的路径;此操作不是原子保存,中断可能导致内容不完整。“取消”将保留未保存的编辑。"; "%@ started a task that is still running" = "%@ 启动的任务仍在运行"; "%@ test run timed out after %@ seconds." = "%@ 测试运行在 %@ 秒后超时。"; @@ -1934,6 +1935,7 @@ "Line Breakpoints" = "行断点"; "Line breakpoint actions" = "行断点操作"; "Linear" = "Linear"; +"Lines and columns start at 1, and a range must not end before it starts." = "行号和列号从 1 开始,范围的终点不能早于起点。"; "Lines kept for scrolling back in new terminal sessions." = "新建终端会话保留的可回滚行数。"; "Linked this conversation to a pull request" = "已将此对话关联到拉取请求"; "Linked worktree" = "关联工作树"; @@ -2436,6 +2438,7 @@ "No upstream configured" = "未设置上游分支"; "No usages found" = "没有找到调用位置"; "No verified Homebrew formula is configured for %@." = "没有为 %@ 配置经过验证的 Homebrew formula。"; +"No web browser could be opened." = "无法打开网页浏览器。"; "No worktrees" = "没有工作树"; "Node.js %lld or later" = "Node.js %lld 或更高版本"; "Node.js %lld or later is required; found %@." = "需要 Node.js %lld 或更高版本,当前为 %@。"; @@ -2494,8 +2497,10 @@ "One sentence focused on the main change" = "聚焦主要更改的一句话"; "One test: %@" = "单个测试:%@"; "One-time choices override repository preferences, which override application defaults. Git configuration is changed only when you save or clear a field below." = "单次选项优先于仓库偏好,仓库偏好优先于应用默认值。只有保存或清除下方字段时才会修改 Git 配置。"; +"Only .html and .htm files can be shown in a browser." = "只有 .html 和 .htm 文件可以在浏览器中显示。"; "Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used." = "只使用 API 密钥登录,不会使用 Agent 自带的账号登录(例如 ChatGPT 登录)。"; "Only ask for actions detected as potentially unsafe" = "自动审批,仅在检测到潜在风险时请求你的确认。"; +"Only http and https addresses with a host name can be opened." = "只能打开带主机名的 http 和 https 地址。"; "Only local files can be attached to an Agent message." = "Agent 消息仅支持添加本地文件。"; "Only regular files can be opened as text. Devices, sockets and named pipes are not supported." = "只能将普通文件作为文本打开,不支持设备、套接字和命名管道。"; "Only source files of the current language can be opened from outside the workspace" = "工作区外只能打开当前语言的源码文件"; @@ -2967,6 +2972,7 @@ "Remote URL" = "远程地址"; "Remote folder" = "远程文件夹"; "Remote pending documents exceeded the startup limit. Wait for the language server to start." = "远程语言服务启动前的待同步文档已达到上限,请等待启动完成。"; +"Remote workspaces are not supported by this operation yet." = "此操作暂不支持远程工作区。"; "Remote: %@" = "远程:%@"; "Removal pending" = "等待移除"; "Remove" = "移除"; @@ -3859,8 +3865,11 @@ "The field value does not match its declaration or exceeds its size limit." = "字段值不符合声明或超过大小限制。"; "The field value is invalid or exceeds 1024 UTF-8 bytes." = "字段值无效或超过 1024 个 UTF-8 字节。"; "The file could not be decoded with the selected or detected encoding. Check its encoding or open it in an external editor." = "无法按所选或自动识别的编码解码文件。请检查文件编码,或使用外部编辑器打开。"; +"The file could not be opened as a text document." = "无法将该文件作为文本文档打开。"; "The file could not be read. Check its availability and try again." = "无法读取文件。请检查文件是否可访问后重试。"; "The file has changed since the Agent edit. Open the diff and review it manually." = "文件内容与 Agent 修改存在冲突,请打开差异手动确认。"; +"The file has unsaved changes. Save it first, because the browser shows the saved file." = "文件有未保存的更改。请先保存,因为浏览器显示的是已保存的文件。"; +"The file is not open in this window." = "该文件未在此窗口中打开。"; "The file is too large to compare." = "文件过大,无法比较。"; "The file no longer exists or was moved. Refresh the project and try again." = "文件已不存在或已被移动。请刷新项目后重试。"; "The file or its hard links changed while saving. Your edits are still unsaved. Check the file and save again." = "保存期间文件或硬链接发生了变化。编辑内容尚未保存,请检查文件后重新保存。"; @@ -3874,6 +3883,7 @@ "The host could not be reached. Check the host name, the port, and your network." = "无法连接到主机,请检查主机名、端口和网络。"; "The host could not create the plugin's workspace state directory." = "宿主无法创建插件的工作区状态目录。"; "The host does not serve %@." = "宿主不提供 %@。"; +"The host does not support the requested option yet." = "宿主暂不支持所请求的选项。"; "The host has no bridge method %@." = "宿主没有桥接方法 %@。"; "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts." = "主机密钥未被接受。如果服务器重装过,请从 ~/.ssh/known_hosts 中删除它的旧记录。"; "The image destination is outside the current workspace" = "图片目标位置在当前工作区之外"; @@ -3891,6 +3901,7 @@ "The local Codex account changed. Reconnect to refresh quota." = "本机 Codex 账号已变更,请重新连接以刷新额度。"; "The local adapter is at or above the verified version." = "本地适配器版本等于或高于已核验版本。"; "The location service did not answer." = "地区查询服务没有响应。"; +"The message text or its buttons are empty, too long, or too many." = "消息文本或按钮为空、过长或过多。"; "The model is too large; some entries are not shown." = "模型过大,部分条目未显示。"; "The monitored JVM has exited or changed." = "监控的 JVM 已退出或发生变化。"; "The name of the artifact the build produces." = "构建产物的名称。"; @@ -3963,6 +3974,11 @@ "The repository will be opened in detached HEAD state." = "仓库将以 detached HEAD 状态打开。"; "The request has no secret." = "请求不包含密钥。"; "The required tool is not available on the remote host." = "远程主机上没有可用的所需工具。"; +"The resource does not exist." = "资源不存在。"; +"The resource is not a regular file." = "资源不是常规文件。"; +"The resource is outside the workspace." = "资源位于工作区之外。"; +"The resource names a root this window does not offer." = "资源指定的根目录不在此窗口提供的范围内。"; +"The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"." = "资源路径必须是相对路径,使用“/”分隔,且不能包含“.”或“..”。"; "The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; "The saved provider run configuration is unavailable." = "已保存的提供方运行配置不可用。"; "The selected AI provider has an invalid API URL or model." = "所选 AI 服务商的 API 地址或模型无效。"; @@ -4017,6 +4033,8 @@ "The version control client could not be run. Check that it is installed." = "无法运行版本控制客户端。请检查它是否已安装。"; "The version control provider failed to start." = "版本控制 Provider 启动失败。"; "The version control provider stopped before it was ready." = "版本控制 Provider 在就绪前已停止。"; +"The window is closed or has no workspace open." = "窗口已关闭或没有打开工作区。"; +"The window now shows a different workspace than the one this call was made for." = "窗口现在显示的工作区与本次调用针对的工作区不同。"; "The working copy is locked by an interrupted action. Run Cleanup, then try again." = "工作副本被中断的操作锁定。请先执行清理,然后重试。"; "The working copy is out of date. Update it, then commit again." = "工作副本已过期。请先更新,再重新提交。"; "The working copy was cleaned up." = "工作副本已清理。"; @@ -4093,12 +4111,14 @@ "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "这会永久删除“%@”中的未提交文件和未跟踪文件,但会保留对应分支。"; "This plugin adds no options to this page." = "该插件未在此页添加选项。"; "This plugin already has an installation in progress. Try again after it finishes." = "该插件已有安装正在进行,请在完成后重试。"; +"This plugin already has too many messages waiting." = "此插件已有过多消息在等待。"; "This plugin declares no features." = "该插件没有声明功能。"; "This plugin declares no settings." = "该插件没有声明设置。"; "This plugin has no backend that can run now." = "此插件目前没有可运行的后端。"; "This plugin is maintained and signed by Lithe. Report problems in the source repository." = "该插件由 Lithe 维护并签名,问题请在源码仓库反馈。"; "This plugin is maintained by a community developer, not by Lithe. Lithe verified that the download matches the plugin repository, and will show what the plugin can run before installing it. Report problems with the plugin in its own repository." = "插件由社区开发者维护,并非 Lithe 官方出品。Lithe 已校验下载内容与插件仓库一致,并会在安装前列出插件能运行的程序。插件问题请到插件自己的仓库反馈。"; "This plugin requires a newer version of Lithe." = "该插件需要更新版本的 Lithe。"; +"This plugin sent messages too quickly." = "此插件发送消息过快。"; "This preview contains changes that have not been officially released and may be unstable." = "此预览版包含尚未正式发布的改动,可能不稳定。"; "This project" = "此项目"; "This project is not a Git repository" = "当前项目不是 Git 仓库"; @@ -4875,6 +4895,7 @@ "native.arg1ProviderInstalledOnThisMac" = "%1$@ Provider(使用本机已安装的程序)"; "native.arg1ProviderRunsWithArg2" = "%1$@ Provider(使用 %2$@ 运行)"; "native.arg1Tools" = "%1$d 个工具"; +"native.argSentMessagesTooQuicklySoSomeWereNotShown" = "%1$@ 发送消息过快,部分消息未显示。"; "native.arguments" = "参数"; "native.argumentsFromThePage" = "页面提供的参数:"; "native.argumentsOnePerLine" = "参数(每行一个)"; @@ -6525,6 +6546,7 @@ "native.lineColumn" = "[行] [:列]:"; "native.lineLld" = "%1$@,第 %2$lld 行,%3$@"; "native.linear" = "Linear"; +"native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts" = "行号和列号从 1 开始,范围的终点不能早于起点。"; "native.linesKeptForScrollingBackInNewTerminalSessions" = "新建终端会话保留的可回滚行数。"; "native.linkedThisConversationToAPullRequest" = "已将此对话关联到拉取请求"; "native.linkedWorktree" = "关联工作树"; @@ -7091,6 +7113,7 @@ "native.noUpstreamConfigured" = "未设置上游分支"; "native.noUsagesFound" = "没有找到调用位置"; "native.noVerifiedHomebrewFormulaIsConfiguredFor" = "没有为 %1$@ 配置经过验证的 Homebrew formula。"; +"native.noWebBrowserCouldBeOpened" = "无法打开网页浏览器。"; "native.noWorktrees" = "没有工作树"; "native.nodeJsDebuggingRequiresTheOfficialVscodeJs" = "Node.js 调试需要官方的 vscode-js-debug 独立 DAP 包。请将 LITHE_JS_DEBUG_PATH 设置为其 dapDebugServer.js 文件,或将其解压到 .lithe/toolchains/js-debug。"; "native.nodeJsLldOrLater" = "Node.js %1$lld 或更高版本"; @@ -7153,6 +7176,8 @@ "native.oneUsage" = "1 处使用"; "native.onlyAPIKeysAreUsedTheAgentSOwnAccountSignInSuchAsAChatGPTLoginIsNeverUsed" = "只使用 API 密钥登录,不会使用 Agent 自带的账号登录(例如 ChatGPT 登录)。"; "native.onlyAskForActionsDetectedAsPotentiallyUnsafe" = "自动审批,仅在检测到潜在风险时请求你的确认。"; +"native.onlyHtmlAndHtmFilesCanBeShownInABrowser" = "只有 .html 和 .htm 文件可以在浏览器中显示。"; +"native.onlyHttpAndHttpsAddressesWithAHostNameCanBeOpened" = "只能打开带主机名的 http 和 https 地址。"; "native.onlyLocalFilesCanBeAttachedToAnAgentMessage" = "Agent 消息仅支持添加本地文件。"; "native.onlySourceFilesOfTheCurrentLanguageCanBeOpenedFromOutsideTheWorkspace" = "工作区外只能打开当前语言的源码文件"; "native.onlyTheAPIURLModelAndAPIKeyAreSavedOtherCLIOptionsContinueToUseYourLocalConfig36d9d4cdfd" = "仅保存 API 地址、模型和 API 密钥,其他 CLI 选项继续沿用本机配置。密钥与设置分开存储。编辑时显示这些已保存字段的配置模板。"; @@ -7624,6 +7649,7 @@ "native.remoteTerminalInputBusy" = "远程终端正忙,请稍后再发送输入。"; "native.remoteTerminalInvalidDirectory" = "终端目录不在远程工作区内。"; "native.remoteURL" = "远程地址"; +"native.remoteWorkspacesAreNotSupportedByThisOperationYet" = "此操作暂不支持远程工作区。"; "native.removalPending" = "等待移除"; "native.removeAll" = "全部移除"; "native.removeAndItsCheckoutDirectoryTheBranchIsKeptIfGitRefusesBecauseFilesHaveChange7ffdbe003e" = "删除“%1$@”及其检出目录?对应分支会保留。如果 Git 因文件已更改而拒绝删除,你可以继续查看单独的强制删除警告。"; @@ -8444,7 +8470,10 @@ "native.theFeaturedMcpCatalogCouldNotBeLoaded" = "无法加载内置 MCP 精选目录。"; "native.theFieldValueDoesNotMatchItsDeclarationOrExceedsItsSizeLimit" = "字段值不符合声明或超过大小限制。"; "native.theFieldValueIsInvalidOrExceeds1024UTF8Bytes" = "字段值无效或超过 1024 个 UTF-8 字节。"; +"native.theFileCouldNotBeOpenedAsATextDocument" = "无法将该文件作为文本文档打开。"; "native.theFileHasChangedSinceTheAgentEditOpenTheDiffAndReviewItManually" = "文件内容与 Agent 修改存在冲突,请打开差异手动确认。"; +"native.theFileHasUnsavedChangesSaveItFirstBecauseTheBrowserShowsTheSavedFile" = "文件有未保存的更改。请先保存,因为浏览器显示的是已保存的文件。"; +"native.theFileIsNotOpenInThisWindow" = "该文件未在此窗口中打开。"; "native.theFileIsTooLargeToCompare" = "文件过大,无法比较。"; "native.theFileSystemThatHoldsTheHomeDirectory" = "主目录所在的文件系统。"; "native.theFileWillBeCreatedWhenYouSave" = "保存时将创建此文件。"; @@ -8464,6 +8493,7 @@ "native.theHostCouldNotBeReachedCheckTheHostNameThePortAndYourNetwork" = "无法连接到主机,请检查主机名、端口和网络。"; "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory" = "宿主无法创建插件的工作区状态目录。"; "native.theHostDoesNotServe" = "宿主不提供 %1$@。"; +"native.theHostDoesNotSupportTheRequestedOptionYet" = "宿主暂不支持所请求的选项。"; "native.theHostHasNoBridgeMethod" = "宿主没有桥接方法 %1$@。"; "native.theHostKeyWasNotAcceptedIfTheServerWasReinstalledRemoveItsOldEntryFromSshKnownHosts" = "主机密钥未被接受。如果服务器重装过,请从 ~/.ssh/known_hosts 中删除它的旧记录。"; "native.theImageDestinationIsOutsideTheCurrentWorkspace" = "图片目标位置在当前工作区之外"; @@ -8505,6 +8535,7 @@ "native.theMcpRegistryCouldNotBeLoadedTryAgainOrAddAServerManually" = "无法加载 MCP Registry。请重试,或手动添加服务器。"; "native.theMcpRegistryIsUnreachableCheckTheNetworkOrProxy" = "无法连接 MCP Registry,请检查网络或代理。"; "native.theMcpRegistryRequestTimedOutTryAgain" = "MCP Registry 请求超时,请重试。"; +"native.theMessageTextOrItsButtonsAreEmptyTooLongOrTooMany" = "消息文本或按钮为空、过长或过多。"; "native.theModelIsTooLargeSomeEntriesAreNotShown" = "模型过大,部分条目未显示。"; "native.theMonitoredJVMHasExitedOrChanged" = "监控的 JVM 已退出或发生变化。"; "native.theNameOfTheArtifactTheBuildProduces" = "构建产物的名称。"; @@ -8572,6 +8603,11 @@ "native.theRepositoryChangedOrAnotherGitOperationIsRunningRefreshThePreviewToRetry" = "仓库已更改或有其他 Git 操作正在运行。请刷新预览后重试。"; "native.theRepositoryWillBeOpenedInDetachedHEADState" = "仓库将以 detached HEAD 状态打开。"; "native.theRequestHasNoSecret" = "请求不包含密钥。"; +"native.theResourceDoesNotExist" = "资源不存在。"; +"native.theResourceIsNotARegularFile" = "资源不是常规文件。"; +"native.theResourceIsOutsideTheWorkspace" = "资源位于工作区之外。"; +"native.theResourceNamesARootThisWindowDoesNotOffer" = "资源指定的根目录不在此窗口提供的范围内。"; +"native.theResourcePathMustBeRelativeUseSeparatorsAndNotContainOr" = "资源路径必须是相对路径,使用“/”分隔,且不能包含“.”或“..”。"; "native.theResponseStoppedAtTheModelSTokenLimit" = "回复已达到模型的 token 上限,已停止。"; "native.theSelectedAIProviderHasAnInvalidAPIURLOrModel" = "所选 AI 服务商的 API 地址或模型无效。"; "native.theSelectedAIProviderHasNoAPIKey" = "所选 AI 服务商没有 API 密钥。"; @@ -8625,6 +8661,8 @@ "native.theVersionControlClientCouldNotBeRunCheckThatItIsInstalled" = "无法运行版本控制客户端。请检查它是否已安装。"; "native.theVersionControlProviderFailedToStart" = "版本控制 Provider 启动失败。"; "native.theVersionControlProviderStoppedBeforeItWasReady" = "版本控制 Provider 在就绪前已停止。"; +"native.theWindowIsClosedOrHasNoWorkspaceOpen" = "窗口已关闭或没有打开工作区。"; +"native.theWindowNowShowsADifferentWorkspaceThanTheOneThisCallWasMadeFor" = "窗口现在显示的工作区与本次调用针对的工作区不同。"; "native.theWorkingCopyIsLockedByAnInterruptedActionRunCleanupThenTryAgain" = "工作副本被中断的操作锁定。请先执行清理,然后重试。"; "native.theWorkingCopyIsOutOfDateUpdateItThenCommitAgain" = "工作副本已过期。请先更新,再重新提交。"; "native.theWorkingCopyWasCleanedUp" = "工作副本已清理。"; @@ -8695,12 +8733,14 @@ "native.thisPermanentlyDeletesUncommittedAndUntrackedFilesInTheBranchItselfIsKept" = "这会永久删除“%1$@”中的未提交文件和未跟踪文件,但会保留对应分支。"; "native.thisPluginAddsNoOptionsToThisPage" = "该插件未在此页添加选项。"; "native.thisPluginAlreadyHasAnInstallationInProgress" = "该插件已有安装正在进行,请在完成后重试。"; +"native.thisPluginAlreadyHasTooManyMessagesWaiting" = "此插件已有过多消息在等待。"; "native.thisPluginDeclaresNoFeatures" = "该插件没有声明功能。"; "native.thisPluginDeclaresNoSettings" = "该插件没有声明设置。"; "native.thisPluginHasNoBackendThatCanRun" = "此插件目前没有可运行的后端。"; "native.thisPluginIsMaintainedAndSignedByLitheReportProblemsInTheSourceRepository" = "该插件由 Lithe 维护并签名,问题请在源码仓库反馈。"; "native.thisPluginIsMaintainedByACommunityDeveloperNotByLitheLitheVerifiedThatTheDownl1989920b43" = "插件由社区开发者维护,并非 Lithe 官方出品。Lithe 已校验下载内容与插件仓库一致,并会在安装前列出插件能运行的程序。插件问题请到插件自己的仓库反馈。"; "native.thisPluginRequiresANewerVersionOfLithe" = "该插件需要更新版本的 Lithe。"; +"native.thisPluginSentMessagesTooQuickly" = "此插件发送消息过快。"; "native.thisPreviewContainsChangesThatHaveNotBeenOfficiallyReleasedAndMayBeUnstable" = "此预览版包含尚未正式发布的改动,可能不稳定。"; "native.thisProject" = "此项目"; "native.thisProjectIsNotAGitRepository" = "当前项目不是 Git 仓库"; diff --git a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift index c0658c536c..dc3930efd2 100644 --- a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift @@ -10,6 +10,8 @@ import LitheWorkspaceModule final class AppModelFeatureGraph { let keyboardShortcut: KeyboardShortcutFeatureModel let notification: WorkbenchNotificationFeatureModel + /// Plugin messages show their own text verbatim, so they are not part of `notification`. + let pluginMessages = PluginHostMessageFeatureModel() let workbench: WorkbenchFeatureModel let commitDraft: CommitDraftFeatureModel let searchSession: SearchSessionFeatureModel diff --git a/macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift b/macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift new file mode 100644 index 0000000000..53217463c8 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift @@ -0,0 +1,119 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +/// What the system browser may be asked to open. +enum PluginHostExternalTarget: Equatable, Sendable { + /// An http or https address, as the plugin wrote it. + case webURL(String) + /// An `.html` or `.htm` file of the window's workspace. + case workspaceFile(PluginHostResource) +} + +/// What to do when a local file has edits the browser cannot see. +enum PluginHostUnsavedPolicy: Equatable, Sendable { + /// Open the saved file and say so in the result. + case openSavedFile + /// Refuse with `unsavedChanges`, so the caller can save first. + case requireSaved +} + +struct PluginHostExternalOpenRequest: Equatable, Sendable { + let context: PluginHostCallContext + let target: PluginHostExternalTarget + var unsaved: PluginHostUnsavedPolicy = .openSavedFile + + init(context: PluginHostCallContext, target: PluginHostExternalTarget, unsaved: PluginHostUnsavedPolicy = .openSavedFile) { + self.context = context + self.target = target + self.unsaved = unsaved + } +} + +struct PluginHostExternalOpenResult: Equatable, Sendable { + enum Content: Equatable, Sendable { + /// A web page the browser fetches itself. + case webPage + /// The file as saved on disk. `hasUnsavedChanges` tells that the + /// editor holds a newer buffer the browser does not show. + case savedFile(hasUnsavedChanges: Bool) + } + + let openedURL: URL + let content: Content +} + +/// Opens things in the system's web browser. The platform adapter owns the +/// actual launch; the service has already checked what may be launched. +@MainActor +protocol PluginHostExternalOpening: AnyObject { + func openWebURL(_ url: URL) async throws + func openLocalHTMLFile(_ url: URL) async throws +} + +/// `browser.openExternal`. +/// +/// Remote addresses are limited to http and https with a host name and no +/// embedded credentials. Local files must be HTML files under the window's +/// workspace and are opened as saved on disk. Nothing is appended to an +/// address or file, and no script is run. +@MainActor +final class PluginHostBrowserService { + static let maxURLBytes = 4096 + static let allowedSchemes: Set = ["http", "https"] + + private let gate: PluginHostScopeGate + private let resolver: PluginHostResourceResolver + private let opener: any PluginHostExternalOpening + + init( + directory: any PluginHostWindowDirectory, resolver: PluginHostResourceResolver, + opener: any PluginHostExternalOpening + ) { + gate = PluginHostScopeGate(directory: directory) + self.resolver = resolver + self.opener = opener + } + + func openExternal(_ request: PluginHostExternalOpenRequest) async throws -> PluginHostExternalOpenResult { + let scope = try gate.enter(request.context) + switch request.target { + case .webURL(let raw): + let url = try Self.validatedWebURL(raw) + try await launch { try await opener.openWebURL(url) } + return PluginHostExternalOpenResult(openedURL: url, content: .webPage) + case .workspaceFile(let resource): + guard PluginHostPreviewService.isHTML(resource.relativePath) else { throw PluginHostError.notHTMLDocument } + let url = try resolver.resolveFile(resource, roots: scope.snapshot.roots) + // The editor first reports input it has not handed over, so a + // file the user is typing in is not mistaken for a saved one. + let state = await scope.port.hostDocumentState(at: url, flushingEditor: true) + let hasUnsavedChanges = state?.hasUnsavedChanges ?? false + if hasUnsavedChanges && request.unsaved == .requireSaved { throw PluginHostError.unsavedChanges } + _ = try gate.enter(request.context) + try await launch { try await opener.openLocalHTMLFile(url) } + return PluginHostExternalOpenResult(openedURL: url, content: .savedFile(hasUnsavedChanges: hasUnsavedChanges)) + } + } + + private func launch(_ action: () async throws -> Void) async throws { + do { try await action() } catch is CancellationError { throw CancellationError() } catch { + throw PluginHostError.browserUnavailable + } + } + + /// The address as a URL, or `unsupportedURL`. + static func validatedWebURL(_ raw: String) throws -> URL { + guard !raw.isEmpty, raw.utf8.count <= maxURLBytes, + !raw.unicodeScalars.contains(where: { $0.value <= 0x20 || $0.value == 0x7f || $0.properties.isWhitespace }), + var components = URLComponents(string: raw), + let scheme = components.scheme?.lowercased(), allowedSchemes.contains(scheme), + let host = components.host, !host.isEmpty, + components.user == nil, components.password == nil else { + throw PluginHostError.unsupportedURL + } + components.scheme = scheme + guard let url = components.url else { throw PluginHostError.unsupportedURL } + return url + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift new file mode 100644 index 0000000000..841bbeb98e --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift @@ -0,0 +1,49 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +/// Waits for the editor to hand over input it has not reported yet, with a +/// bound. +/// +/// The editor's synchronization callback may never arrive (a web view that is +/// loading or gone), and a service must not hang on it. The answer is true +/// only when the editor confirmed; a timeout or a failure is false, so the +/// caller treats the buffer as possibly newer than it knows. +@MainActor +enum PluginHostEditorFlush { + static let defaultTimeout: Duration = .seconds(2) + + /// `request` starts the synchronization and must call its argument once + /// with the outcome. Later calls are ignored. + static func run( + timeout: Duration = defaultTimeout, + sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, + request: (@escaping @MainActor (Bool) -> Void) -> Void + ) async -> Bool { + let waiter = Waiter() + return await withCheckedContinuation { (continuation: CheckedContinuation) in + waiter.continuation = continuation + // Started before the request, so a request that answers at once + // cancels it instead of leaving it behind. + waiter.timeout = Task { @MainActor in + do { try await sleep(timeout) } catch { return } + waiter.finish(false) + } + request { waiter.finish($0) } + } + } + + @MainActor + private final class Waiter { + var continuation: CheckedContinuation? + var timeout: Task? + + func finish(_ confirmed: Bool) { + guard let continuation else { return } + self.continuation = nil + timeout?.cancel() + timeout = nil + continuation.resume(returning: confirmed) + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift b/macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift new file mode 100644 index 0000000000..e0c85b32d9 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift @@ -0,0 +1,112 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +enum PluginHostEditorTab: Equatable, Sendable { + /// A normal tab of the window's editor. + case regular + /// A replaceable preview tab. The editor has none yet, so asking for it + /// is refused rather than quietly opened as a regular tab. + case preview +} + +struct PluginHostEditorOpenRequest: Equatable, Sendable { + let context: PluginHostCallContext + let resource: PluginHostResource + /// Where to put the caret. The editor can place a caret but not select a + /// range yet, so only `range.start` takes effect; the result says so. + var range: PluginHostTextRange? + var activation: PluginHostEditorActivation = .select + var tab: PluginHostEditorTab = .regular + + init( + context: PluginHostCallContext, resource: PluginHostResource, range: PluginHostTextRange? = nil, + activation: PluginHostEditorActivation = .select, tab: PluginHostEditorTab = .regular + ) { + self.context = context + self.resource = resource + self.range = range + self.activation = activation + self.tab = tab + } +} + +/// What the editor did with a requested range. +enum PluginHostAppliedSelection: Equatable, Sendable { + case none + case caret(PluginHostPosition) +} + +struct PluginHostEditorOpenResult: Equatable, Sendable { + let document: PluginHostDocumentState + /// The file already had a buffer, whose text the call left as it was. + let wasAlreadyOpen: Bool + let appliedSelection: PluginHostAppliedSelection + /// False when the user is in another window. The call still did its + /// work in the named window and did not raise it. + let windowIsForeground: Bool +} + +/// `editor.open` and `editor.reveal` for one workspace file. +/// +/// It reuses the window's document owner: a file that is already open keeps +/// its buffer, dirty or not, and the service reads no file contents itself. +/// It never raises a window or activates the application, so a call for a +/// background window cannot take the user's focus. +/// +/// `.select` is also how a plugin brings a background tab to the front: the +/// editor slice reads selections and applies edits only for the foreground +/// tab, so a plugin calls `editor.open` first and finds `document.isActive` +/// true in the result. `.background` leaves the tabs as they are. +@MainActor +final class PluginHostEditorService { + private let gate: PluginHostScopeGate + private let resolver: PluginHostResourceResolver + + init(directory: any PluginHostWindowDirectory, resolver: PluginHostResourceResolver) { + gate = PluginHostScopeGate(directory: directory) + self.resolver = resolver + } + + /// Opens the file in the window, loading it from disk only when the window has no buffer for it. + func open(_ request: PluginHostEditorOpenRequest) async throws -> PluginHostEditorOpenResult { + try await perform(request, requiresOpenDocument: false) + } + + /// Brings an open file to the caret position. Fails with `documentNotOpen` instead of loading it. + func reveal(_ request: PluginHostEditorOpenRequest) async throws -> PluginHostEditorOpenResult { + try await perform(request, requiresOpenDocument: true) + } + + private func perform( + _ request: PluginHostEditorOpenRequest, requiresOpenDocument: Bool + ) async throws -> PluginHostEditorOpenResult { + let scope = try gate.enter(request.context) + guard request.tab == .regular else { throw PluginHostError.unsupportedOption } + if let range = request.range { try PluginHostPositionValidation.validate(range) } + let url = try resolver.resolveFile(request.resource, roots: scope.snapshot.roots) + + let before = await scope.port.hostDocumentState(at: url, flushingEditor: false) + let wasAlreadyOpen = before?.isOpen == true + if requiresOpenDocument && !wasAlreadyOpen { throw PluginHostError.documentNotOpen } + _ = try gate.enter(request.context) + + guard let document = await scope.port.hostOpenDocument(at: url, activation: request.activation) else { + throw PluginHostError.openFailed + } + // The window may have closed or switched workspace while the file loaded. + let current = try gate.enter(request.context) + + var applied = PluginHostAppliedSelection.none + if request.activation == .select, let start = request.range?.start { + current.port.hostPlaceCaret(in: url, at: start) + applied = .caret(start) + } + return PluginHostEditorOpenResult( + document: document, + wasAlreadyOpen: wasAlreadyOpen, + appliedSelection: applied, + windowIsForeground: current.snapshot.isForeground + ) + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift b/macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift new file mode 100644 index 0000000000..07cebc6149 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostMessageFeatureModel.swift @@ -0,0 +1,133 @@ +import Combine +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +enum PluginHostMessageSeverity: String, Equatable, Sendable { + case information + case warning + case error +} + +/// Why a message ended without the user choosing one of its buttons. +enum PluginHostMessageDismissal: Equatable, Sendable { + /// The user closed the message. + case user + /// The window closed while the message was showing or waiting. + case windowClosed + /// The window opened another workspace. + case workspaceChanged + /// The plugin's call was cancelled. + case cancelled +} + +enum PluginHostMessageOutcome: Equatable, Sendable { + /// Index into the request's buttons. + case selected(index: Int) + case dismissed(PluginHostMessageDismissal) +} + +/// A plugin message as shown. Text and button titles are the plugin's own +/// words and are never translated or rewritten by the host. +struct PluginHostMessageEntry: Identifiable, Equatable, Sendable { + let id: UUID + let pluginID: String + let pluginName: String + let severity: PluginHostMessageSeverity + let text: String + let actions: [String] + let workspaceGeneration: Int +} + +/// The plugin message surface of one window. +/// +/// Plugin text must be shown verbatim, which the host notification stack +/// does not do (it translates its text and keys), so plugin messages live +/// here. Up to `visibleLimit` messages show at once; the rest wait in order. +/// Every presented message ends exactly once, with the user's choice or a +/// dismissal. +@MainActor +final class PluginHostMessageFeatureModel: ObservableObject { + static let visibleLimit = 3 + /// Bound for cancellations that arrive before their message registers. + private static let cancelledBeforePresentLimit = 64 + + @Published private(set) var visible: [PluginHostMessageEntry] = [] + @Published private(set) var queued: [PluginHostMessageEntry] = [] + private var continuations: [UUID: CheckedContinuation] = [:] + private var cancelledBeforePresent: Set = [] + + /// Messages showing or waiting. + var pendingCount: Int { visible.count + queued.count } + + func pendingCount(forPlugin pluginID: String) -> Int { + (visible + queued).filter { $0.pluginID == pluginID }.count + } + + /// Shows `entry` (or queues it) and waits until the user answers or the + /// message is dismissed. A cancelled caller dismisses its own message. + func present(_ entry: PluginHostMessageEntry) async -> PluginHostMessageOutcome { + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + if cancelledBeforePresent.remove(entry.id) != nil || Task.isCancelled { + continuation.resume(returning: .dismissed(.cancelled)) + return + } + continuations[entry.id] = continuation + if visible.count < Self.visibleLimit { + visible.append(entry) + } else { + queued.append(entry) + } + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancelPresentation(of: entry.id) } + } + } + + /// The user pressed button `index` of a showing message. + func choose(_ id: UUID, index: Int) { + guard let entry = visible.first(where: { $0.id == id }), + entry.actions.indices.contains(index) else { return } + finish(id, with: .selected(index: index)) + } + + /// The user closed a showing message. + func dismiss(_ id: UUID) { + guard visible.contains(where: { $0.id == id }) else { return } + finish(id, with: .dismissed(.user)) + } + + /// Ends every showing and waiting message, for a closing window or a + /// replaced workspace. + func dismissAll(_ reason: PluginHostMessageDismissal) { + let ids = (visible + queued).map(\.id) + visible = [] + queued = [] + for id in ids { continuations.removeValue(forKey: id)?.resume(returning: .dismissed(reason)) } + } + + private func cancelPresentation(of id: UUID) { + guard continuations[id] != nil else { + // The caller was cancelled before its message registered. Remember + // it so the message is not shown for a call that is already gone. + if cancelledBeforePresent.count >= Self.cancelledBeforePresentLimit { cancelledBeforePresent.removeAll() } + cancelledBeforePresent.insert(id) + return + } + finish(id, with: .dismissed(.cancelled)) + } + + private func finish(_ id: UUID, with outcome: PluginHostMessageOutcome) { + visible.removeAll { $0.id == id } + queued.removeAll { $0.id == id } + continuations.removeValue(forKey: id)?.resume(returning: outcome) + promoteQueued() + } + + private func promoteQueued() { + while visible.count < Self.visibleLimit, !queued.isEmpty { + visible.append(queued.removeFirst()) + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostMessageService.swift b/macos/Sources/Lithe/Application/Features/PluginHostMessageService.swift new file mode 100644 index 0000000000..656f369c64 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostMessageService.swift @@ -0,0 +1,144 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +struct PluginHostMessageRequest: Equatable, Sendable { + let context: PluginHostCallContext + let severity: PluginHostMessageSeverity + /// Shown as written; the host does not translate or rewrite it. + let text: String + /// Button titles, shown as written. The answer is an index into this list. + let actions: [String] + + init(context: PluginHostCallContext, severity: PluginHostMessageSeverity, text: String, actions: [String] = []) { + self.context = context + self.severity = severity + self.text = text + self.actions = actions + } +} + +struct PluginHostMessageLimits: Equatable, Sendable { + /// Messages one plugin may send to one window within `interval`. + var maxRequestsPerInterval = 5 + var interval: TimeInterval = 10 + /// Messages of one plugin showing or waiting in one window. + var maxPendingPerPlugin = 3 + /// Messages of all plugins showing or waiting in one window. + var maxPendingPerWindow = 10 + var maxTextLength = 2000 + var maxActions = 3 + var maxActionLength = 40 +} + +/// Counts requests per key over a sliding window. The caller supplies the +/// time, so tests run without sleeping. +struct PluginHostRateLimiter { + enum Admission: Equatable { + case allowed + /// `shouldNotify` is true for the first denial in a window, so the + /// user hears about a flood once rather than once per message. + case denied(shouldNotify: Bool) + } + + private var requests: [String: [TimeInterval]] = [:] + private var lastNotice: [String: TimeInterval] = [:] + private let maxRequests: Int + private let interval: TimeInterval + + init(maxRequests: Int, interval: TimeInterval) { + self.maxRequests = maxRequests + self.interval = interval + } + + mutating func admit(key: String, now: TimeInterval) -> Admission { + prune(now: now) + var recent = requests[key] ?? [] + guard recent.count < maxRequests else { + requests[key] = recent + let noticed = lastNotice[key].map { now - $0 < interval } ?? false + if !noticed { lastNotice[key] = now } + return .denied(shouldNotify: !noticed) + } + recent.append(now) + requests[key] = recent + return .allowed + } + + private mutating func prune(now: TimeInterval) { + for (key, times) in requests { + let live = times.filter { now - $0 < interval } + if live.isEmpty { requests[key] = nil } else { requests[key] = live } + } + for (key, time) in lastNotice where now - time >= interval { lastNotice[key] = nil } + } +} + +/// `window.showInformation`, `showWarning` and `showError`. +/// +/// A message belongs to one window, so it shows there even when the user is +/// working in another one, and ends when that window closes. A plugin gets +/// a bounded number of messages: a request over the rate or the queue limit +/// is refused, and the user is told once that the plugin was throttled. +@MainActor +final class PluginHostMessageService { + private let gate: PluginHostScopeGate + private let limits: PluginHostMessageLimits + private let now: @MainActor () -> TimeInterval + private var limiter: PluginHostRateLimiter + + init( + directory: any PluginHostWindowDirectory, + limits: PluginHostMessageLimits = PluginHostMessageLimits(), + now: @escaping @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } + ) { + gate = PluginHostScopeGate(directory: directory) + self.limits = limits + self.now = now + limiter = PluginHostRateLimiter(maxRequests: limits.maxRequestsPerInterval, interval: limits.interval) + } + + /// Shows the message and waits for the user's choice or a dismissal. + func show(_ request: PluginHostMessageRequest) async throws -> PluginHostMessageOutcome { + try validate(request) + let scope = try gate.enter(request.context) + let caller = request.context.caller + let key = "\(caller.pluginID)\n\(request.context.windowID.uuidString)" + if case .denied(let shouldNotify) = limiter.admit(key: key, now: now()) { + if shouldNotify { scope.port.hostNoteMessageLimit(pluginName: caller.pluginName) } + throw PluginHostError.rateLimited + } + let center = scope.port.hostMessages + guard center.pendingCount(forPlugin: caller.pluginID) < limits.maxPendingPerPlugin, + center.pendingCount < limits.maxPendingPerWindow else { + throw PluginHostError.messageQueueFull + } + let entry = PluginHostMessageEntry( + id: UUID(), pluginID: caller.pluginID, pluginName: caller.pluginName, + severity: request.severity, text: request.text, actions: request.actions, + workspaceGeneration: scope.snapshot.workspaceGeneration + ) + return await center.present(entry) + } + + private func validate(_ request: PluginHostMessageRequest) throws { + let text = request.text + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + text.count <= limits.maxTextLength, + !Self.hasControlCharacters(text, allowing: ["\n", "\t"]), + request.actions.count <= limits.maxActions else { + throw PluginHostError.invalidMessage + } + for action in request.actions { + guard !action.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + action.count <= limits.maxActionLength, + !Self.hasControlCharacters(action, allowing: []) else { + throw PluginHostError.invalidMessage + } + } + } + + private static func hasControlCharacters(_ text: String, allowing allowed: Set) -> Bool { + text.unicodeScalars.contains { ($0.value < 0x20 || $0.value == 0x7f) && !allowed.contains($0) } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift b/macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift new file mode 100644 index 0000000000..66c9c46fcc --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift @@ -0,0 +1,71 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +enum PluginHostPreviewPresentation: Equatable, Sendable { + /// The editor and the preview side by side, the HTML default. + case editorAndPreview + /// The preview alone. The editor area keeps this choice per tab as view + /// state, so a service cannot set it yet; asking for it is refused. + case previewOnly +} + +struct PluginHostPreviewOpenRequest: Equatable, Sendable { + let context: PluginHostCallContext + let resource: PluginHostResource + var presentation: PluginHostPreviewPresentation = .editorAndPreview + /// A caret to place in the editor next to the preview. + var range: PluginHostTextRange? + + init( + context: PluginHostCallContext, resource: PluginHostResource, + presentation: PluginHostPreviewPresentation = .editorAndPreview, range: PluginHostTextRange? = nil + ) { + self.context = context + self.resource = resource + self.presentation = presentation + self.range = range + } +} + +struct PluginHostPreviewOpenResult: Equatable, Sendable { + let editor: PluginHostEditorOpenResult + /// The embedded preview renders the editor's live buffer, so it shows + /// unsaved edits. This is always true for the embedded preview and is + /// reported so a caller can contrast it with the system browser. + let usesLiveBuffer: Bool + let presentation: PluginHostPreviewPresentation +} + +/// `preview.openHTML`: shows an HTML file in the embedded preview of the +/// window's editor. +/// +/// The preview is a pane of the file's own editor tab, fed with the buffer +/// the user is editing. Its web view has no plugin bridge (see the Agent +/// Note), so project HTML cannot call back into the host. +@MainActor +final class PluginHostPreviewService { + static let htmlExtensions: Set = ["html", "htm"] + + private let editor: PluginHostEditorService + + init(editor: PluginHostEditorService) { + self.editor = editor + } + + func openHTML(_ request: PluginHostPreviewOpenRequest) async throws -> PluginHostPreviewOpenResult { + guard request.presentation == .editorAndPreview else { throw PluginHostError.unsupportedOption } + guard Self.isHTML(request.resource.relativePath) else { throw PluginHostError.notHTMLDocument } + let result = try await editor.open(PluginHostEditorOpenRequest( + context: request.context, resource: request.resource, range: request.range, + activation: .select, tab: .regular + )) + return PluginHostPreviewOpenResult( + editor: result, usesLiveBuffer: true, presentation: .editorAndPreview + ) + } + + static func isHTML(_ relativePath: String) -> Bool { + htmlExtensions.contains((relativePath as NSString).pathExtension.lowercased()) + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostScope.swift b/macos/Sources/Lithe/Application/Features/PluginHostScope.swift new file mode 100644 index 0000000000..72b1c14359 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostScope.swift @@ -0,0 +1,334 @@ +import Foundation +import LitheModuleAPI + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +/// Who asks. The host never guesses this from the focused window: every +/// service call names its plugin, window and workspace explicitly. +struct PluginHostCaller: Equatable, Sendable { + let pluginID: String + let pluginName: String +} + +/// The window and workspace opening one call is bound to. A call whose +/// window closed, or whose workspace was replaced since the plugin received +/// these values, fails instead of acting on whatever is open now. +struct PluginHostCallContext: Equatable, Sendable { + let caller: PluginHostCaller + /// The project session (window) the plugin was activated for. + let windowID: UUID + let workspaceURL: URL + let workspaceGeneration: Int + + init(caller: PluginHostCaller, windowID: UUID, workspaceURL: URL, workspaceGeneration: Int) { + self.caller = caller + self.windowID = windowID + self.workspaceURL = workspaceURL.standardizedFileURL + self.workspaceGeneration = workspaceGeneration + } +} + +/// A folder a plugin may name resources under. +struct PluginHostRoot: Equatable, Sendable { + let id: String + let url: URL +} + +/// A resource as plugins name it: a root ID plus a "/"-separated path +/// relative to that root. Never an absolute path or a `file:` URL. +struct PluginHostResource: Equatable, Sendable { + /// The workspace folder of a window; the only root of a local window. + static let workspaceRootID = "workspace" + + let rootID: String + let relativePath: String +} + +/// 1-based line and column; the column counts UTF-16 code units like the +/// editor's own positions. +struct PluginHostPosition: Equatable, Sendable { + let line: Int + let column: Int +} + +struct PluginHostTextRange: Equatable, Sendable { + let start: PluginHostPosition + let end: PluginHostPosition +} + +/// What the host knows about one window at the moment of a call. +struct PluginHostWindowSnapshot: Equatable, Sendable { + let windowID: UUID + let workspaceURL: URL + let workspaceGeneration: Int + /// A workspace whose files live on another machine. v1 refuses these. + let isRemoteWorkspace: Bool + /// True while this is the window the user is working in. A call never + /// changes it: no service raises a window or activates the application. + let isForeground: Bool + let roots: [PluginHostRoot] +} + +/// One document as the window's document owner holds it. +struct PluginHostDocumentState: Equatable, Sendable { + let url: URL + /// False when the owner holds no buffer for the file. + let isOpen: Bool + /// The buffer differs from the file on disk, or its latest input could + /// not be confirmed. + let hasUnsavedChanges: Bool + /// The file is the foreground tab of the window's editor: the selected tab + /// of the focused editor group, with no diff, terminal or Agent tab in + /// front of it. A tab that is open but not in front is a background tab, + /// and later editor calls (selection, edits) require the foreground, so a + /// plugin brings the file forward with `editor.open` first. This says + /// nothing about which window the user is in; see `isForeground`. + let isActive: Bool + let isReadOnly: Bool +} + +enum PluginHostEditorActivation: Equatable, Sendable { + /// Bring the file to the front of the window's editor: it becomes the + /// foreground tab (the way a click on its tab does, also across editor + /// groups) and `PluginHostDocumentState.isActive` is true afterwards. The + /// window itself is never raised. + case select + /// Keep the file open without changing the active tab or caret. + case background +} + +/// What a window offers a service: state reads and the few edits a service +/// may make. The AppModel of a project session implements it. +@MainActor +protocol PluginHostWindowPort: AnyObject { + /// nil once the session closed or when no folder is open. + var hostSnapshot: PluginHostWindowSnapshot? { get } + /// Reads the document state. With `flushingEditor` the editor first + /// reports input it has not handed over yet, so the answer is not stale. + func hostDocumentState(at url: URL, flushingEditor: Bool) async -> PluginHostDocumentState? + /// Opens `url` from disk only when the owner has no buffer for it. An open + /// buffer, dirty or not, is kept as it is. nil when the file cannot be opened. + func hostOpenDocument(at url: URL, activation: PluginHostEditorActivation) async -> PluginHostDocumentState? + /// Moves the caret of `url`'s editor. The target is applied by the editor + /// when the file is visible; it never changes which window is key. + func hostPlaceCaret(in url: URL, at position: PluginHostPosition) + /// The window's plugin message surface. + var hostMessages: PluginHostMessageFeatureModel { get } + /// Tells the user that `pluginName` sent more messages than the host shows. + func hostNoteMessageLimit(pluginName: String) +} + +/// Finds a window by the ID a plugin was given for it. +@MainActor +protocol PluginHostWindowDirectory: AnyObject { + func hostWindow(id: UUID) -> (any PluginHostWindowPort)? +} + +/// A call accepted by the gate: the window and the snapshot it was +/// checked against. +@MainActor +struct PluginHostScope { + let port: any PluginHostWindowPort + let snapshot: PluginHostWindowSnapshot +} + +/// A host-side refusal with a stable code. The Core function bridge maps +/// `code` onto the contract's error codes; the message is for the log and +/// the plugin author, in the interface language. +enum PluginHostError: Error, Equatable, LocalizedError { + case invalidResourcePath + case unknownRoot + case outsideWorkspace + case resourceNotFound + case notRegularFile + case windowUnavailable + case workspaceChanged + case remoteWorkspaceUnsupported + case invalidPosition + case unsupportedOption + case documentNotOpen + case openFailed + case notHTMLDocument + case unsupportedURL + case unsavedChanges + case browserUnavailable + case invalidMessage + case rateLimited + case messageQueueFull + + var code: String { + switch self { + case .invalidResourcePath: "host.resource.invalidPath" + case .unknownRoot: "host.resource.unknownRoot" + case .outsideWorkspace: "host.resource.outsideWorkspace" + case .resourceNotFound: "host.resource.notFound" + case .notRegularFile: "host.resource.notRegularFile" + case .windowUnavailable: "host.window.unavailable" + case .workspaceChanged: "host.window.workspaceChanged" + case .remoteWorkspaceUnsupported: "host.window.remoteUnsupported" + case .invalidPosition: "host.editor.invalidPosition" + case .unsupportedOption: "host.option.unsupported" + case .documentNotOpen: "host.editor.documentNotOpen" + case .openFailed: "host.editor.openFailed" + case .notHTMLDocument: "host.preview.notHTML" + case .unsupportedURL: "host.browser.unsupportedURL" + case .unsavedChanges: "host.browser.unsavedChanges" + case .browserUnavailable: "host.browser.unavailable" + case .invalidMessage: "host.message.invalid" + case .rateLimited: "host.message.rateLimited" + case .messageQueueFull: "host.message.queueFull" + } + } + + var errorDescription: String? { + switch self { + case .invalidResourcePath: + L10n.string("The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\".") + case .unknownRoot: + L10n.string("The resource names a root this window does not offer.") + case .outsideWorkspace: + L10n.string("The resource is outside the workspace.") + case .resourceNotFound: + L10n.string("The resource does not exist.") + case .notRegularFile: + L10n.string("The resource is not a regular file.") + case .windowUnavailable: + L10n.string("The window is closed or has no workspace open.") + case .workspaceChanged: + L10n.string("The window now shows a different workspace than the one this call was made for.") + case .remoteWorkspaceUnsupported: + L10n.string("Remote workspaces are not supported by this operation yet.") + case .invalidPosition: + L10n.string("Lines and columns start at 1, and a range must not end before it starts.") + case .unsupportedOption: + L10n.string("The host does not support the requested option yet.") + case .documentNotOpen: + L10n.string("The file is not open in this window.") + case .openFailed: + L10n.string("The file could not be opened as a text document.") + case .notHTMLDocument: + L10n.string("Only .html and .htm files can be shown in a browser.") + case .unsupportedURL: + L10n.string("Only http and https addresses with a host name can be opened.") + case .unsavedChanges: + L10n.string("The file has unsaved changes. Save it first, because the browser shows the saved file.") + case .browserUnavailable: + L10n.string("No web browser could be opened.") + case .invalidMessage: + L10n.string("The message text or its buttons are empty, too long, or too many.") + case .rateLimited: + L10n.string("This plugin sent messages too quickly.") + case .messageQueueFull: + L10n.string("This plugin already has too many messages waiting.") + } + } +} + +/// Checks that a call still belongs to a live window and the workspace it +/// was made for. Services run it before they act and again after every +/// suspension, so a window that closed in between cannot be acted on. +@MainActor +struct PluginHostScopeGate { + private weak var directory: (any PluginHostWindowDirectory)? + + init(directory: any PluginHostWindowDirectory) { + self.directory = directory + } + + func enter(_ context: PluginHostCallContext) throws -> PluginHostScope { + guard let port = directory?.hostWindow(id: context.windowID), + let snapshot = port.hostSnapshot, + snapshot.windowID == context.windowID else { + throw PluginHostError.windowUnavailable + } + // Remote authority is refused before anything else is compared, so a + // plugin never learns whether a path exists on the other machine. + guard !snapshot.isRemoteWorkspace else { throw PluginHostError.remoteWorkspaceUnsupported } + guard snapshot.workspaceURL == context.workspaceURL, + snapshot.workspaceGeneration == context.workspaceGeneration else { + throw PluginHostError.workspaceChanged + } + return PluginHostScope(port: port, snapshot: snapshot) + } +} + +/// Turns a root ID and relative path into a file URL inside the root. +/// +/// The check is made on the canonical path (symbolic links resolved), so a +/// link inside the workspace that leads out of it is refused. The URL handed +/// back keeps the workspace's own spelling, which is what the document owner +/// keys its buffers by. The window between this check and the later open is +/// not closed here; the Agent Note lists it as a known limit. +struct PluginHostResourceResolver { + static let maxPathBytes = 4096 + + var canonicalize: (URL) -> URL + var metadata: (URL) -> FileMetadata? + + init( + canonicalize: @escaping (URL) -> URL = { $0.resolvingSymlinksInPath().standardizedFileURL }, + metadata: @escaping (URL) -> FileMetadata? + ) { + self.canonicalize = canonicalize + self.metadata = metadata + } + + init(fileStorage: any FileStorage) { + self.init(metadata: { fileStorage.metadata(for: $0) }) + } + + /// The URL of a regular file under one of `roots`. + func resolveFile(_ resource: PluginHostResource, roots: [PluginHostRoot]) throws -> URL { + let components = try Self.components(of: resource.relativePath) + guard let root = roots.first(where: { $0.id == resource.rootID }) else { + throw PluginHostError.unknownRoot + } + var candidate = root.url.standardizedFileURL + for component in components { candidate.appendPathComponent(component) } + let canonicalRoot = canonicalize(root.url).path + let canonicalCandidate = canonicalize(candidate) + let rootPrefix = canonicalRoot.hasSuffix("/") ? canonicalRoot : canonicalRoot + "/" + guard canonicalCandidate.path.hasPrefix(rootPrefix) else { throw PluginHostError.outsideWorkspace } + guard let attributes = metadata(canonicalCandidate) else { throw PluginHostError.resourceNotFound } + guard attributes.isRegularFile else { throw PluginHostError.notRegularFile } + return candidate + } + + /// Path pieces of a relative resource path, or `invalidResourcePath`. + static func components(of relativePath: String) throws -> [String] { + guard !relativePath.isEmpty, relativePath.utf8.count <= maxPathBytes, + !relativePath.hasPrefix("/"), !relativePath.hasSuffix("/") else { + throw PluginHostError.invalidResourcePath + } + // Control characters, including NUL, and backslashes never belong in a + // resource path; accepting them would let two spellings name one file. + guard !relativePath.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f || $0 == "\\" }) else { + throw PluginHostError.invalidResourcePath + } + let pieces = relativePath.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + guard !pieces.contains(where: { $0.isEmpty || $0 == "." || $0 == ".." }) else { + throw PluginHostError.invalidResourcePath + } + return pieces + } +} + +/// A 1-based position or range checked against the limits editors accept. +enum PluginHostPositionValidation { + static let maxLine = 10_000_000 + static let maxColumn = 1_000_000 + + static func validate(_ position: PluginHostPosition) throws { + guard (1...maxLine).contains(position.line), (1...maxColumn).contains(position.column) else { + throw PluginHostError.invalidPosition + } + } + + static func validate(_ range: PluginHostTextRange) throws { + try validate(range.start) + try validate(range.end) + let endsBeforeStart = range.end.line < range.start.line + || (range.end.line == range.start.line && range.end.column < range.start.column) + guard !endsBeforeStart else { throw PluginHostError.invalidPosition } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift b/macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift new file mode 100644 index 0000000000..73e81af786 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift @@ -0,0 +1,51 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +/// Opens web addresses and saved HTML files through the platform's browser +/// handling. The browser service has already decided what may be opened. +@MainActor +final class PluginHostPlatformBrowser: PluginHostExternalOpening { + private let platformUI: any PlatformUI + + init(platformUI: any PlatformUI) { + self.platformUI = platformUI + } + + func openWebURL(_ url: URL) async throws { + try await platformUI.openWebURLInBrowser(url) + } + + func openLocalHTMLFile(_ url: URL) async throws { + try await platformUI.openHTMLInBrowser(url) + } +} + +/// The host-internal services behind `editor.open`, `editor.reveal`, +/// `preview.openHTML`, `browser.openExternal` and `window.show*`. +/// +/// They are not a plugin API. The function bridge that routes plugin calls +/// builds a `PluginHostCallContext` from the plugin's activation and calls +/// these, after it has checked the plugin's grants. +@MainActor +struct PluginHostWindowServices { + let editor: PluginHostEditorService + let preview: PluginHostPreviewService + let browser: PluginHostBrowserService + let messages: PluginHostMessageService + + init( + directory: any PluginHostWindowDirectory, + fileStorage: any FileStorage, + platformUI: any PlatformUI + ) { + let resolver = PluginHostResourceResolver(fileStorage: fileStorage) + let editor = PluginHostEditorService(directory: directory, resolver: resolver) + self.editor = editor + preview = PluginHostPreviewService(editor: editor) + browser = PluginHostBrowserService( + directory: directory, resolver: resolver, opener: PluginHostPlatformBrowser(platformUI: platformUI) + ) + messages = PluginHostMessageService(directory: directory) + } +} diff --git a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift index 2fa5c60485..71a4ee5bdd 100644 --- a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift +++ b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift @@ -12,6 +12,9 @@ protocol PlatformUI: AnyObject { func revealInFileBrowser(_ url: URL) func open(_ url: URL) func openHTMLInBrowser(_ url: URL) async throws + /// Opens an http or https address in the web browser. The platform + /// refuses any other scheme. + func openWebURLInBrowser(_ url: URL) async throws func copyToClipboard(_ value: String) func copyFilesToClipboard(_ urls: [URL]) -> Bool func fileURLsFromClipboard() -> [URL] @@ -30,6 +33,10 @@ extension PlatformUI { throw CocoaError(.featureUnsupported) } + func openWebURLInBrowser(_ url: URL) async throws { + throw CocoaError(.featureUnsupported) + } + func copyFilesToClipboard(_ urls: [URL]) -> Bool { false } func fileURLsFromClipboard() -> [URL] { [] } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift new file mode 100644 index 0000000000..2fba3d6c21 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift @@ -0,0 +1,126 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +/// What the plugin host services may read and do in this project window. +/// +/// Every method here acts on this window only; nothing raises the window, +/// activates the application, or reads a file's contents. A project window's +/// `id` is the window ID that plugins are given. +@MainActor +extension AppModel: PluginHostWindowPort { + var hostSnapshot: PluginHostWindowSnapshot? { + guard let identity = currentWorkspaceIdentity else { return nil } + return PluginHostWindowSnapshot( + windowID: id, + workspaceURL: identity.url, + workspaceGeneration: identity.generation, + // The same test the terminal uses to tell a remote workspace's + // local placeholder root from a local folder. + isRemoteWorkspace: services.remoteConnections?.workspaces.contains(identity.url) == true, + isForeground: workspaceSessionCoordinator.isActive, + roots: [PluginHostRoot(id: PluginHostResource.workspaceRootID, url: identity.url)] + ) + } + + var hostMessages: PluginHostMessageFeatureModel { featureGraph.pluginMessages } + + func hostDocumentState(at url: URL, flushingEditor: Bool) async -> PluginHostDocumentState? { + guard currentWorkspaceIdentity != nil else { return nil } + let normalized = url.standardizedFileURL + guard let document = hostDocument(matching: normalized) else { + return PluginHostDocumentState( + url: normalized, isOpen: false, hasUnsavedChanges: false, isActive: false, isReadOnly: false + ) + } + var editorConfirmed = true + if flushingEditor { editorConfirmed = await hostFlushEditor(of: document) } + // The window or the tab may have gone while the editor was answering. + guard currentWorkspaceIdentity != nil, openDocuments.contains(where: { $0 === document }) else { return nil } + return PluginHostDocumentState( + url: normalized, + isOpen: true, + // A buffer whose latest input the editor did not confirm may be + // newer than the model knows, so it counts as unsaved. + hasUnsavedChanges: document.isDirty || !editorConfirmed, + isActive: hostIsForegroundTab(document), + isReadOnly: document.isReadOnly + ) + } + + func hostOpenDocument(at url: URL, activation: PluginHostEditorActivation) async -> PluginHostDocumentState? { + guard let identity = currentWorkspaceIdentity else { return nil } + let normalized = url.standardizedFileURL + // Images and other media open in their own viewer, which has no text + // buffer to report; the caller is told the file cannot be opened. + guard MediaDocumentKind.from(url: normalized) == nil else { return nil } + if activation == .select { + // The same preparation as opening a file from the project tree. + suggestJavaPluginIfNeeded(files: [normalized]) + editorTabOrderFeature.repositoryDiffSelected = false + editorNavigationTarget = nil + } + // An open buffer is only activated, never reloaded, so unsaved edits stay. + await documentFeature.openFileAsync( + normalized, isReadOnly: false, displayPath: nil, activateWhenReady: activation == .select + ) + guard currentWorkspaceIdentity == identity, + let document = hostDocument(matching: normalized) else { return nil } + if activation == .select { + // Selecting the tab like a click does puts it in front across + // editor groups and over a diff, terminal or Agent tab. + selectEditorTab(.document(document.id)) + } + return PluginHostDocumentState( + url: normalized, + isOpen: true, + hasUnsavedChanges: document.isDirty, + isActive: hostIsForegroundTab(document), + isReadOnly: document.isReadOnly + ) + } + + func hostPlaceCaret(in url: URL, at position: PluginHostPosition) { + guard currentWorkspaceIdentity != nil else { return } + // Plugin positions start at 1; the editor's navigation target starts at 0. + editorNavigationTarget = EditorNavigationTarget( + url: url.standardizedFileURL, + line: max(0, position.line - 1), + utf16Column: max(0, position.column - 1) + ) + } + + func hostNoteMessageLimit(pluginName: String) { + showNotification("%@ sent messages too quickly, so some were not shown.", arguments: [pluginName]) + } + + /// Ends the plugin messages of this window, for a closing window or a replaced workspace. + func dismissPluginHostMessages(_ reason: PluginHostMessageDismissal) { + featureGraph.pluginMessages.dismissAll(reason) + } + + private func hostDocument(matching url: URL) -> EditorDocument? { + let path = url.standardizedFileURL.path + return openDocuments.first { $0.url.standardizedFileURL.path == path } + } + + /// True when the file's tab is the one in front: selected in the focused + /// editor group, with no diff tab over it. + private func hostIsForegroundTab(_ document: EditorDocument) -> Bool { + guard activeDocumentID == document.id, !editorTabOrderFeature.repositoryDiffSelected else { return false } + // Before the editor area first mounts nothing is focused yet; the + // active document then is the foreground one. + guard let focused = editorTabOrderFeature.focusedItem else { return true } + return focused == .document(document.id) + } + + /// True when the editor confirmed it handed over all input; false on failure or timeout. + private func hostFlushEditor(of document: EditorDocument) async -> Bool { + guard document.needsEditorSynchronization else { return true } + return await PluginHostEditorFlush.run { done in + document.withSynchronizedEditor { result in + if case .success = result { done(true) } else { done(false) } + } + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 37e066cf59..eb91f52b58 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -712,6 +712,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { // closing its workspace. Revoke page requests before teardown can yield. services.pluginWebPages.closeAll() closePluginWorkspaceContext() + dismissPluginHostMessages(.windowClosed) workbenchFeature.endLayoutSession() // VCS providers too: their pending refreshes, or a scan still loading // the workspace, would otherwise start a process for a closed window. @@ -939,6 +940,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } private func prepareForWorkspaceOpen(at normalizedURL: URL) { + dismissPluginHostMessages(.workspaceChanged) documentLanguageCoordinator?.stop() beginModuleRuntimeShutdown() // A workspace root is a hard language-server ownership boundary. Stop @@ -999,6 +1001,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { private func finishWorkspaceClose(for workspaceURL: URL) { closePluginWorkspaceContext() + dismissPluginHostMessages(.workspaceChanged) releaseRemoteWorkspace(at: workspaceURL) ideCapabilities.disable() documentLanguageCoordinator?.stop() diff --git a/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager+PluginHost.swift b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager+PluginHost.swift new file mode 100644 index 0000000000..5322ab8a47 --- /dev/null +++ b/macos/Sources/Lithe/Models/Workspace/ProjectSessionManager+PluginHost.swift @@ -0,0 +1,12 @@ +import Foundation + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +/// Finds the project window a plugin was given the ID of. A closed window is +/// no longer a session, so its ID finds nothing. +@MainActor +extension ProjectSessionManager: PluginHostWindowDirectory { + func hostWindow(id: UUID) -> (any PluginHostWindowPort)? { + session(for: id) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift index a4ba2b0ccb..62c7fd11dd 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift @@ -22,12 +22,27 @@ struct MacHTMLBrowserOpener { } guard try isRegularFile(url) else { throw CocoaError(.fileReadNoSuchFile) } // This URL is only used to query Launch Services; no request is made. - guard let probe = URL(string: "https://example.invalid"), - let browser = applicationForURL(probe) else { + guard let probe = URL(string: "https://example.invalid") else { throw CocoaError(.fileReadUnsupportedScheme) } + try await openURLs([url], browser(handling: probe)) + } + + /// Opens an http or https address in the web browser. Other schemes, and + /// addresses without a host, are refused so a caller cannot reach another + /// application through this entry. + func openWeb(_ url: URL) async throws { + guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), + url.host?.isEmpty == false else { + throw CocoaError(.fileReadUnsupportedScheme) + } + try await openURLs([url], browser(handling: url)) + } + + private func browser(handling url: URL) throws -> URL { + guard let browser = applicationForURL(url) else { throw CocoaError(.serviceApplicationNotFound, userInfo: [ NSLocalizedDescriptionKey: L10n.string(key: "Choose a default web browser in System Settings, then retry.") ]) } - try await openURLs([url], browser) + return browser } } diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift index 084b48cb01..35aaf096c7 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift @@ -72,6 +72,10 @@ final class MacPlatformUI: PlatformUI { try await MacHTMLBrowserOpener().open(url) } + func openWebURLInBrowser(_ url: URL) async throws { + try await MacHTMLBrowserOpener().openWeb(url) + } + func copyToClipboard(_ value: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(value, forType: .string) diff --git a/macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift b/macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift new file mode 100644 index 0000000000..f62f3674e0 --- /dev/null +++ b/macos/Sources/Lithe/Views/Workbench/PluginHostMessageStack.swift @@ -0,0 +1,94 @@ +import SwiftUI + +// Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md + +/// The plugin messages of one window, stacked at the window's lower left. +/// +/// Plugin text, plugin names and button titles are the plugin's own words: +/// they are drawn with `verbatim:`, so the host never looks them up in a +/// translation table or reads them as markup. Only the host's own control +/// (the close button's label) is translated. +struct PluginHostMessageStack: View { + @ObservedObject var messages: PluginHostMessageFeatureModel + + var body: some View { + if !messages.visible.isEmpty { + VStack(alignment: .leading, spacing: 2 * LitheTheme.Notification.shadowInset) { + ForEach(messages.visible) { entry in + PluginHostMessageBanner( + entry: entry, + choose: { messages.choose(entry.id, index: $0) }, + dismiss: { messages.dismiss(entry.id) } + ) + } + } + .padding(.leading, LitheTheme.Notification.edgeInset) + .padding(.bottom, LitheTheme.Metrics.statusBarHeight + LitheTheme.Notification.edgeInset) + } + } +} + +/// One plugin message: severity icon, plugin name, text and its buttons. +struct PluginHostMessageBanner: View { + let entry: PluginHostMessageEntry + let choose: (Int) -> Void + let dismiss: () -> Void + + private var iconPath: String { + switch entry.severity { + case .information: "expui/status/info.svg" + case .warning: "expui/status/warning.svg" + case .error: "expui/status/error.svg" + } + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + LitheIDEAIcon(resourcePath: iconPath, size: 16, preservesOriginalColors: true) + .padding(.top, 15) + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: entry.pluginName) + .font(LitheTheme.uiFont(size: 11, weight: .regular)) + .foregroundStyle(LitheTheme.Notification.moreForeground) + .lineLimit(1) + Text(verbatim: entry.text) + .font(LitheTheme.uiFont(size: 13, weight: .regular)) + .foregroundStyle(LitheTheme.Notification.foreground) + .lineLimit(8) + .fixedSize(horizontal: false, vertical: true) + if !entry.actions.isEmpty { + HStack(spacing: 12) { + ForEach(Array(entry.actions.enumerated()), id: \.offset) { index, title in + Button(action: { choose(index) }) { + Text(verbatim: title) + } + .buttonStyle(.litheNoPress) + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.accent) + .accessibilityIdentifier("plugin-host-message-action-\(index)") + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 12) + .padding(.bottom, 16) + } + .padding(.leading, 14) + .padding(.trailing, 32) + .frame(width: LitheTheme.Notification.width, alignment: .topLeading) + .litheNotificationSurface() + .overlay(alignment: .topTrailing) { + Button(action: dismiss) { + LitheIDEAIcon(resourcePath: "expui/general/close.svg", size: 16, preservesOriginalColors: true) + } + .buttonStyle(LitheIconButtonStyle(size: 20, cornerRadius: 4, + hoverBackground: LitheTheme.Notification.iconHover)) + .padding(.top, 7) + .padding(.trailing, 7) + .accessibilityLabel("Dismiss notification") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("plugin-host-message") + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9c96371ff4..3b7b220643 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -588,6 +588,10 @@ struct WorkbenchView: View { .padding(.bottom, LitheTheme.Metrics.statusBarHeight + LitheTheme.Notification.edgeInset) } } + .overlay(alignment: .bottomLeading) { + // Messages that plugins show in this window; the host's own balloons stay on the right. + PluginHostMessageStack(messages: model.hostMessages) + } .overlay { if model.isSearchEverywhereVisible, let feature = model.searchFeatureIfActive { SearchEverywhereView( diff --git a/macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift b/macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift index a7b2486139..8b39426317 100644 --- a/macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift +++ b/macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift @@ -53,4 +53,61 @@ struct HTMLBrowserOpenerTests { await #expect(throws: CocoaError.self) { try await opener.open(url) } } } + + // MARK: Web addresses + + @Test func webAddressesAreHandedToTheBrowserThatHandlesThem() async throws { + let browser = URL(fileURLWithPath: "/fixture/Browser.app") + for address in ["https://example.com/docs?q=1#top", "http://localhost:8080/", "HTTPS://Example.com/"] { + let url = try #require(URL(string: address)) + var probed: URL? + var opened: ([URL], URL)? + let opener = MacHTMLBrowserOpener( + applicationForURL: { probe in + probed = probe + return browser + }, + openURLs: { urls, application in opened = (urls, application) } + ) + + try await opener.openWeb(url) + + #expect(probed == url) + #expect(opened?.0 == [url]) + #expect(opened?.1 == browser) + } + } + + @Test func otherSchemesAndHostlessAddressesNeverReachLaunchServices() async { + let opener = MacHTMLBrowserOpener( + applicationForURL: { _ in + Issue.record("Invalid input must not reach Launch Services") + return nil + }, + openURLs: { _, _ in Issue.record("Invalid input must not be opened") } + ) + let refused = [ + "file:///etc/passwd", "javascript:alert(1)", "data:text/html,hi", "ftp://example.com/", + "mailto:someone@example.com", "lithe-plugin://plugin/index.html", "x-apple.systempreferences:", "https:///no-host", "https://" + ] + + for address in refused { + guard let url = URL(string: address) else { continue } + await #expect(throws: CocoaError.self, "address: \(address)") { try await opener.openWeb(url) } + } + } + + @Test func aMissingBrowserAndALaunchFailureAreReportedForWebAddresses() async throws { + let url = try #require(URL(string: "https://example.com/")) + let missing = MacHTMLBrowserOpener( + applicationForURL: { _ in nil }, + openURLs: { _, _ in Issue.record("Must not open without a browser") } + ) + await #expect(throws: CocoaError.self) { try await missing.openWeb(url) } + let failing = MacHTMLBrowserOpener( + applicationForURL: { _ in URL(fileURLWithPath: "/fixture/Browser.app") }, + openURLs: { _, _ in throw CocoaError(.fileReadNoSuchFile) } + ) + await #expect(throws: CocoaError.self) { try await failing.openWeb(url) } + } } diff --git a/macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift b/macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift new file mode 100644 index 0000000000..d626e414c4 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +import WebKit +@testable import Lithe + +/// Project HTML shown in the embedded preview must not reach the plugin +/// bridge: the plugin message handler and the plugin page scheme exist only +/// on plugin web pages. Evidence for the Agent Note on plugin host services. +@MainActor +struct HTMLPreviewBridgeIsolationTests { + /// The handlers a page script could post to, by the names the app uses + /// for the plugin bridge, the editor and the preview's own scroll bars. + private static let probe = """ + const handlers = window.webkit && window.webkit.messageHandlers; + return ['\(MacPluginWebPage.messageHandlerName)', 'litheEditor', '\(HTMLPreviewScrollbars.frameReportName)'] + .map(name => typeof (handlers && handlers[name])).join(','); + """ + + @Test func projectPagesSeeNoPluginBridgeOrEditorHandler() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + harness.show("ready

project page

") + #expect(await harness.waitForTitle("ready"), "Title was \(harness.webView.title ?? "nil")") + + // The page's own world, where project scripts run, sees no handler at all. + let pageWorld = try await harness.evaluateAsync(Self.probe) + #expect(pageWorld as? String == "undefined,undefined,undefined") + + // Control: the probe does see a handler that exists. The preview's own + // scroll bar reporter lives in the app's world, never in the page's. + let appWorld = try await harness.webView.callAsyncJavaScript( + Self.probe, arguments: [:], contentWorld: HTMLPreviewScrollbars.contentWorld + ) + #expect(appWorld as? String == "undefined,undefined,object") + } + + @Test func previewConfigurationHasNoPluginPageScheme() throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + let configuration = harness.webView.configuration + #expect(configuration.urlSchemeHandler(forURLScheme: MacPluginWebContent.scheme) == nil) + #expect(configuration.urlSchemeHandler(forURLScheme: HTMLPreviewURL.scheme) != nil) + } +} diff --git a/macos/Tests/LitheTests/PluginHostBrowserServiceTests.swift b/macos/Tests/LitheTests/PluginHostBrowserServiceTests.swift new file mode 100644 index 0000000000..a9cd79fd4f --- /dev/null +++ b/macos/Tests/LitheTests/PluginHostBrowserServiceTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct PluginHostBrowserServiceTests { + private func web(_ fixture: PluginHostFixture, _ address: String) -> PluginHostExternalOpenRequest { + PluginHostExternalOpenRequest(context: fixture.context(), target: .webURL(address)) + } + + private func file( + _ fixture: PluginHostFixture, _ relative: String, unsaved: PluginHostUnsavedPolicy = .openSavedFile + ) -> PluginHostExternalOpenRequest { + PluginHostExternalOpenRequest( + context: fixture.context(), target: .workspaceFile(fixture.resource(relative)), unsaved: unsaved + ) + } + + // MARK: Web addresses + + @Test func opensHTTPAndHTTPSAddressesAndLowercasesTheScheme() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + + let secure = try await fixture.browser.openExternal(web(fixture, "https://example.com/docs?q=1#top")) + let upper = try await fixture.browser.openExternal(web(fixture, "HTTP://example.com/a")) + + #expect(secure.content == .webPage) + #expect(secure.openedURL.absoluteString == "https://example.com/docs?q=1#top") + #expect(upper.openedURL.absoluteString == "http://example.com/a") + #expect(fixture.opener.webURLs.map(\.absoluteString) == ["https://example.com/docs?q=1#top", "http://example.com/a"]) + #expect(fixture.opener.htmlFiles.isEmpty) + // A web address never touches the window's documents. + #expect(fixture.window.openCalls.isEmpty) + #expect(fixture.window.flushedPaths.isEmpty) + } + + @Test func refusesEverythingThatIsNotAPlainWebAddress() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let refused = [ + "", "file:///etc/passwd", "javascript:alert(1)", "data:text/html,hi", + "ftp://example.com/a", "lithe-plugin://plugin/index.html", "mailto:someone@example.com", + "vbscript:msgbox(1)", "about:blank", "example.com", "//example.com/a", "/relative/path", + "https:///no-host", "https://", "http://user:secret@example.com/", "http://user@example.com/", + "https://exa mple.com/", " https://example.com/", "https://example.com/\n", "https://example.com/\u{0}", + "https://example.com/\u{85}", "https://example.com/" + String(repeating: "a", count: PluginHostBrowserService.maxURLBytes) + ] + + for address in refused { + await #expect(throws: PluginHostError.unsupportedURL, "address: \(address.debugDescription)") { + try await fixture.browser.openExternal(web(fixture, address)) + } + } + #expect(fixture.opener.webURLs.isEmpty) + #expect(fixture.opener.htmlFiles.isEmpty) + } + + @Test func aLauncherFailureIsReportedAndACancellationPassesThrough() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + + fixture.opener.failure = CocoaError(.fileNoSuchFile) + await #expect(throws: PluginHostError.browserUnavailable) { + try await fixture.browser.openExternal(web(fixture, "https://example.com/")) + } + await #expect(throws: PluginHostError.browserUnavailable) { + try await fixture.browser.openExternal(file(fixture, "a.html")) + } + + fixture.opener.failure = CancellationError() + await #expect(throws: CancellationError.self) { + try await fixture.browser.openExternal(web(fixture, "https://example.com/")) + } + } + + // MARK: Workspace files and unsaved edits + + @Test func openingASavedFileHandsTheBrowserTheFileOnDisk() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("site/index.html") + fixture.window.openBuffers[fixture.path("site/index.html")] = false + + let result = try await fixture.browser.openExternal(file(fixture, "site/index.html")) + + #expect(result.content == .savedFile(hasUnsavedChanges: false)) + #expect(result.openedURL.path == fixture.path("site/index.html")) + #expect(fixture.opener.htmlFiles.map(\.path) == [fixture.path("site/index.html")]) + #expect(fixture.opener.webURLs.isEmpty) + // The browser reads the disk, so the editor is not asked to open or reload anything. + #expect(fixture.window.openCalls.isEmpty) + #expect(fixture.window.diskLoads.isEmpty) + } + + @Test func aFileThatIsNotOpenHasNoUnsavedChanges() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + + let result = try await fixture.browser.openExternal(file(fixture, "a.html", unsaved: .requireSaved)) + + #expect(result.content == .savedFile(hasUnsavedChanges: false)) + #expect(fixture.opener.htmlFiles.count == 1) + } + + @Test func aDirtyBufferIsFlaggedInTheResultWhenTheCallerAcceptsTheSavedFile() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.window.openBuffers[fixture.path("a.html")] = true + + let result = try await fixture.browser.openExternal(file(fixture, "a.html", unsaved: .openSavedFile)) + + #expect(result.content == .savedFile(hasUnsavedChanges: true)) + #expect(fixture.opener.htmlFiles.map(\.path) == [fixture.path("a.html")]) + // The buffer itself is left as it was. + #expect(fixture.window.openBuffers[fixture.path("a.html")] == true) + } + + @Test func aDirtyBufferStopsTheLaunchWhenTheCallerRequiresASavedFile() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.window.openBuffers[fixture.path("a.html")] = true + + await #expect(throws: PluginHostError.unsavedChanges) { + try await fixture.browser.openExternal(file(fixture, "a.html", unsaved: .requireSaved)) + } + #expect(fixture.opener.htmlFiles.isEmpty) + } + + @Test func inputTheEditorHasNotReportedYetCountsAsUnsaved() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + // The buffer looks clean until the editor reports what was just typed. + fixture.window.openBuffers[fixture.path("a.html")] = false + fixture.window.pendingEditorInput = [fixture.path("a.html")] + + await #expect(throws: PluginHostError.unsavedChanges) { + try await fixture.browser.openExternal(file(fixture, "a.html", unsaved: .requireSaved)) + } + #expect(fixture.window.flushedPaths == [fixture.path("a.html")]) + #expect(fixture.opener.htmlFiles.isEmpty) + } + + @Test func onlyHTMLFilesOfTheWorkspaceMayBeLaunched() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("notes.md") + fixture.addFile("run.sh") + fixture.table.addFile("/fixture/outside/page.html") + fixture.table.links[fixture.path("link/page.html")] = "/fixture/outside/page.html" + + for path in ["notes.md", "run.sh"] { + await #expect(throws: PluginHostError.notHTMLDocument, "path: \(path)") { + try await fixture.browser.openExternal(file(fixture, path)) + } + } + await #expect(throws: PluginHostError.outsideWorkspace) { + try await fixture.browser.openExternal(file(fixture, "link/page.html")) + } + await #expect(throws: PluginHostError.invalidResourcePath) { + try await fixture.browser.openExternal(file(fixture, "../outside/page.html")) + } + await #expect(throws: PluginHostError.resourceNotFound) { + try await fixture.browser.openExternal(file(fixture, "missing.html")) + } + #expect(fixture.opener.htmlFiles.isEmpty) + } + + // MARK: Window and workspace scope + + @Test func remoteWorkspacesAreRefusedForFilesAndAddresses() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.updateSnapshot(isRemote: true) + + await #expect(throws: PluginHostError.remoteWorkspaceUnsupported) { + try await fixture.browser.openExternal(file(fixture, "a.html")) + } + await #expect(throws: PluginHostError.remoteWorkspaceUnsupported) { + try await fixture.browser.openExternal(web(fixture, "https://example.com/")) + } + #expect(fixture.opener.htmlFiles.isEmpty) + #expect(fixture.opener.webURLs.isEmpty) + } + + @Test func aMissingWindowAndAReplacedWorkspaceAreRefused() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + + await #expect(throws: PluginHostError.windowUnavailable) { + try await fixture.browser.openExternal(PluginHostExternalOpenRequest( + context: fixture.context(windowID: UUID()), target: .webURL("https://example.com/") + )) + } + await #expect(throws: PluginHostError.workspaceChanged) { + try await fixture.browser.openExternal(PluginHostExternalOpenRequest( + context: fixture.context(generation: PluginHostFixture.defaultGeneration + 1), + target: .workspaceFile(fixture.resource("a.html")) + )) + } + #expect(fixture.opener.htmlFiles.isEmpty) + #expect(fixture.opener.webURLs.isEmpty) + } + + @Test func aWindowThatClosesWhileTheEditorReportsItsInputNeverLaunchesTheBrowser() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.window.openBuffers[fixture.path("a.html")] = false + let directory = fixture.directory + let windowID = fixture.windowID + fixture.window.whileFlushing = { directory.windows[windowID] = nil } + + await #expect(throws: PluginHostError.windowUnavailable) { + try await fixture.browser.openExternal(file(fixture, "a.html")) + } + #expect(fixture.opener.htmlFiles.isEmpty) + } +} diff --git a/macos/Tests/LitheTests/PluginHostEditorServiceTests.swift b/macos/Tests/LitheTests/PluginHostEditorServiceTests.swift new file mode 100644 index 0000000000..5bab494d94 --- /dev/null +++ b/macos/Tests/LitheTests/PluginHostEditorServiceTests.swift @@ -0,0 +1,423 @@ +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct PluginHostEditorServiceTests { + // MARK: Opening and the document owner + + @Test func opensAFileThatIsNotOpenYetFromDiskExactlyOnce() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("src/index.html") + + let result = try await fixture.editor.open(fixture.openRequest("src/index.html")) + + #expect(!result.wasAlreadyOpen) + #expect(result.document.isOpen) + #expect(result.document.isActive) + #expect(!result.document.hasUnsavedChanges) + #expect(result.appliedSelection == PluginHostAppliedSelection.none) + #expect(fixture.window.diskLoads == [fixture.path("src/index.html")]) + } + + @Test func anOpenDirtyBufferIsKeptAndNeverReloadedFromDisk() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("page.html") + fixture.window.openBuffers[fixture.path("page.html")] = true + fixture.window.foregroundPath = fixture.path("page.html") + + let result = try await fixture.editor.open(fixture.openRequest("page.html")) + + #expect(result.wasAlreadyOpen) + #expect(result.document.hasUnsavedChanges) + #expect(fixture.window.diskLoads.isEmpty) + #expect(fixture.window.openBuffers[fixture.path("page.html")] == true) + } + + // MARK: Foreground tab + + @Test func selectBringsABackgroundTabToTheFrontAndKeepsItsUnsavedEdits() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.addFile("b.html") + fixture.window.openBuffers = [fixture.path("a.html"): true, fixture.path("b.html"): false] + fixture.window.foregroundPath = fixture.path("b.html") + // Before the call a.html is open but in the background, where the + // editor slice would refuse to read its selection or edit it. + let before = await fixture.window.hostDocumentState(at: fixture.url("a.html"), flushingEditor: false) + #expect(before?.isOpen == true) + #expect(before?.isActive == false) + + let result = try await fixture.editor.open(fixture.openRequest("a.html")) + + #expect(result.wasAlreadyOpen) + #expect(result.document.isActive) + #expect(fixture.window.foregroundPath == fixture.path("a.html")) + #expect(result.document.hasUnsavedChanges) + #expect(fixture.window.diskLoads.isEmpty) + // The tab that was in front is still open, now behind. + #expect(fixture.window.openBuffers[fixture.path("b.html")] == false) + let behind = await fixture.window.hostDocumentState(at: fixture.url("b.html"), flushingEditor: false) + #expect(behind?.isActive == false) + } + + @Test func selectingAClosedFileMakesItTheForegroundTab() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.addFile("b.html") + fixture.window.openBuffers = [fixture.path("a.html"): false] + fixture.window.foregroundPath = fixture.path("a.html") + + let result = try await fixture.editor.open(fixture.openRequest("b.html")) + + #expect(!result.wasAlreadyOpen) + #expect(result.document.isActive) + #expect(fixture.window.foregroundPath == fixture.path("b.html")) + } + + @Test func backgroundActivationLeavesTheForegroundTabAndCaretAlone() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.addFile("b.html") + fixture.window.openBuffers = [fixture.path("a.html"): false] + fixture.window.foregroundPath = fixture.path("a.html") + let range = PluginHostTextRange(start: .init(line: 3, column: 2), end: .init(line: 3, column: 9)) + + let result = try await fixture.editor.open(fixture.openRequest("b.html", range: range, activation: .background)) + + #expect(result.document.isOpen) + #expect(!result.document.isActive) + #expect(fixture.window.foregroundPath == fixture.path("a.html")) + #expect(fixture.window.caretPlacements.isEmpty) + #expect(result.appliedSelection == PluginHostAppliedSelection.none) + } + + @Test func aBackgroundWindowIsServedButNeverRaised() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.updateSnapshot(isForeground: false) + + let result = try await fixture.editor.open(fixture.openRequest("a.html")) + + // The file became the foreground tab of its own window, and the + // result says the window itself is not the one the user is in. + #expect(result.document.isActive) + #expect(!result.windowIsForeground) + #expect(fixture.window.snapshot?.isForeground == false) + } + + @Test func aCallForAnotherWindowNeverTouchesTheWindowInFront() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + let other = fixture.addWindow(isForeground: false) + let request = PluginHostEditorOpenRequest( + context: fixture.context(windowID: other.id), resource: fixture.resource("a.html") + ) + + _ = try await fixture.editor.open(request) + + #expect(other.window.openCalls.map(\.path) == [fixture.path("a.html")]) + #expect(fixture.window.openCalls.isEmpty) + } + + // MARK: Location + + @Test func selectingWithARangePlacesTheCaretAtItsStart() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + let start = PluginHostPosition(line: 12, column: 5) + let range = PluginHostTextRange(start: start, end: .init(line: 14, column: 1)) + + let result = try await fixture.editor.open(fixture.openRequest("a.html", range: range)) + + #expect(result.appliedSelection == .caret(start)) + #expect(fixture.window.caretPlacements == [ + .init(path: fixture.path("a.html"), position: start) + ]) + } + + @Test func invalidRangesAreRefusedBeforeAnythingOpens() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + let ranges = [ + PluginHostTextRange(start: .init(line: 0, column: 1), end: .init(line: 1, column: 1)), + PluginHostTextRange(start: .init(line: 1, column: 0), end: .init(line: 1, column: 1)), + PluginHostTextRange(start: .init(line: -3, column: 1), end: .init(line: 1, column: 1)), + PluginHostTextRange(start: .init(line: 5, column: 4), end: .init(line: 5, column: 3)), + PluginHostTextRange(start: .init(line: 5, column: 1), end: .init(line: 4, column: 9)), + PluginHostTextRange(start: .init(line: PluginHostPositionValidation.maxLine + 1, column: 1), end: .init(line: PluginHostPositionValidation.maxLine + 1, column: 1)) + ] + + for range in ranges { + await #expect(throws: PluginHostError.invalidPosition) { + try await fixture.editor.open(fixture.openRequest("a.html", range: range)) + } + } + #expect(fixture.window.openCalls.isEmpty) + } + + @Test func aReplaceablePreviewTabIsRefusedInsteadOfOpenedAsARegularTab() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + + await #expect(throws: PluginHostError.unsupportedOption) { + try await fixture.editor.open(fixture.openRequest("a.html", tab: .preview)) + } + #expect(fixture.window.openCalls.isEmpty) + } + + // MARK: Reveal + + @Test func revealNeverLoadsAFileThatIsNotOpen() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + + await #expect(throws: PluginHostError.documentNotOpen) { + try await fixture.editor.reveal(fixture.openRequest("a.html")) + } + #expect(fixture.window.openCalls.isEmpty) + #expect(fixture.window.diskLoads.isEmpty) + } + + @Test func revealBringsAnOpenBackgroundFileForwardAndPlacesTheCaret() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.addFile("b.html") + fixture.window.openBuffers = [fixture.path("a.html"): true, fixture.path("b.html"): false] + fixture.window.foregroundPath = fixture.path("b.html") + let position = PluginHostPosition(line: 2, column: 3) + let range = PluginHostTextRange(start: position, end: position) + + let result = try await fixture.editor.reveal(fixture.openRequest("a.html", range: range)) + + #expect(result.wasAlreadyOpen) + #expect(result.document.isActive) + #expect(result.document.hasUnsavedChanges) + #expect(result.appliedSelection == .caret(position)) + #expect(fixture.window.diskLoads.isEmpty) + } + + @Test func aFileTheOwnerCannotOpenAsTextIsReported() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("photo.bin") + fixture.window.unopenablePaths = [fixture.path("photo.bin")] + + await #expect(throws: PluginHostError.openFailed) { + try await fixture.editor.open(fixture.openRequest("photo.bin")) + } + } + + // MARK: Resources + + @Test func pathsThatLeaveTheWorkspaceOrAreMalformedAreRefused() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + let malformed = [ + "", "/etc/passwd", "../secret.txt", "a/../../secret.txt", "a/./b.html", "a//b.html", + "a/", "a\\b.html", "a\u{0}.html", "a\n.html", String(repeating: "x", count: PluginHostResourceResolver.maxPathBytes + 1) + ] + + for path in malformed { + await #expect(throws: PluginHostError.invalidResourcePath, "path: \(path.debugDescription)") { + try await fixture.editor.open(fixture.openRequest(path)) + } + } + #expect(fixture.window.openCalls.isEmpty) + } + + @Test func anUnknownRootAndASymbolicLinkOutOfTheWorkspaceAreRefused() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.table.addFile("/fixture/outside/secret.txt") + fixture.table.links[fixture.path("link/secret.txt")] = "/fixture/outside/secret.txt" + // A sibling folder that merely starts with the workspace's name. + fixture.table.addFile("/fixture/workspace-evil/x.html") + fixture.table.links[fixture.path("evil/x.html")] = "/fixture/workspace-evil/x.html" + + await #expect(throws: PluginHostError.unknownRoot) { + try await fixture.editor.open(PluginHostEditorOpenRequest( + context: fixture.context(), resource: fixture.resource("a.html", root: "other") + )) + } + for path in ["link/secret.txt", "evil/x.html"] { + await #expect(throws: PluginHostError.outsideWorkspace, "path: \(path)") { + try await fixture.editor.open(fixture.openRequest(path)) + } + } + #expect(fixture.window.openCalls.isEmpty) + } + + @Test func missingFilesAndFoldersAreRefused() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.table.addDirectory(fixture.path("folder")) + + await #expect(throws: PluginHostError.resourceNotFound) { + try await fixture.editor.open(fixture.openRequest("missing.html")) + } + await #expect(throws: PluginHostError.notRegularFile) { + try await fixture.editor.open(fixture.openRequest("folder")) + } + #expect(fixture.window.openCalls.isEmpty) + } + + // MARK: Window and workspace scope + + @Test func remoteWorkspacesAreRefusedBeforeAnythingElseIsChecked() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.updateSnapshot(isRemote: true) + + // The path does not even exist: the remote refusal comes first, so a + // plugin learns nothing about the other machine's files. + await #expect(throws: PluginHostError.remoteWorkspaceUnsupported) { + try await fixture.editor.open(fixture.openRequest("missing.html")) + } + #expect(fixture.window.openCalls.isEmpty) + } + + @Test func aClosedWindowAndAReplacedWorkspaceAreRefused() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + + await #expect(throws: PluginHostError.windowUnavailable) { + try await fixture.editor.open(PluginHostEditorOpenRequest( + context: fixture.context(windowID: UUID()), resource: fixture.resource("a.html") + )) + } + await #expect(throws: PluginHostError.workspaceChanged) { + try await fixture.editor.open(PluginHostEditorOpenRequest( + context: fixture.context(generation: PluginHostFixture.defaultGeneration + 1), + resource: fixture.resource("a.html") + )) + } + // Without a workspace the window has no snapshot at all. + fixture.window.snapshot = nil + await #expect(throws: PluginHostError.windowUnavailable) { + try await fixture.editor.open(fixture.openRequest("a.html")) + } + #expect(fixture.window.openCalls.isEmpty) + } + + @Test func aWindowThatClosesWhileTheFileLoadsGetsNoCaretAndNoResult() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + let directory = fixture.directory + let windowID = fixture.windowID + fixture.window.whileLoading = { directory.windows[windowID] = nil } + let range = PluginHostTextRange(start: .init(line: 1, column: 1), end: .init(line: 1, column: 2)) + + await #expect(throws: PluginHostError.windowUnavailable) { + try await fixture.editor.open(fixture.openRequest("a.html", range: range)) + } + #expect(fixture.window.caretPlacements.isEmpty) + } + + @Test func aWorkspaceReplacedWhileTheFileLoadsGetsNoCaretAndNoResult() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.html") + fixture.window.whileLoading = { [fixture] in + fixture.updateSnapshot(generation: PluginHostFixture.defaultGeneration + 1) + } + let range = PluginHostTextRange(start: .init(line: 1, column: 1), end: .init(line: 1, column: 2)) + + await #expect(throws: PluginHostError.workspaceChanged) { + try await fixture.editor.open(fixture.openRequest("a.html", range: range)) + } + #expect(fixture.window.caretPlacements.isEmpty) + } +} + +@MainActor +struct PluginHostPreviewServiceTests { + @Test func opensTheHTMLFileInItsEditorTabWithTheLiveBuffer() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("site/index.html") + fixture.addFile("other.html") + fixture.window.openBuffers = [fixture.path("site/index.html"): true, fixture.path("other.html"): false] + fixture.window.foregroundPath = fixture.path("other.html") + + let result = try await fixture.preview.openHTML(PluginHostPreviewOpenRequest( + context: fixture.context(), resource: fixture.resource("site/index.html") + )) + + #expect(result.usesLiveBuffer) + #expect(result.presentation == .editorAndPreview) + #expect(result.editor.document.isActive) + // The preview shows the buffer, so the unsaved edits stay in it. + #expect(result.editor.document.hasUnsavedChanges) + #expect(fixture.window.diskLoads.isEmpty) + #expect(fixture.window.foregroundPath == fixture.path("site/index.html")) + } + + @Test func acceptsEitherHTMLExtensionInAnyCase() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("a.HTML") + fixture.addFile("b.htm") + + for path in ["a.HTML", "b.htm"] { + let result = try await fixture.preview.openHTML(PluginHostPreviewOpenRequest( + context: fixture.context(), resource: fixture.resource(path) + )) + #expect(result.editor.document.isActive) + } + } + + @Test func refusesOtherFilesAndPreviewOnlyBeforeTouchingTheWindow() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("notes.md") + fixture.addFile("a.html") + + await #expect(throws: PluginHostError.notHTMLDocument) { + try await fixture.preview.openHTML(PluginHostPreviewOpenRequest( + context: fixture.context(), resource: fixture.resource("notes.md") + )) + } + await #expect(throws: PluginHostError.unsupportedOption) { + try await fixture.preview.openHTML(PluginHostPreviewOpenRequest( + context: fixture.context(), resource: fixture.resource("a.html"), presentation: .previewOnly + )) + } + #expect(fixture.window.openCalls.isEmpty) + } + + @Test func staysInsideTheWorkspaceAndTheNamedWindow() async { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.table.addFile("/fixture/outside/page.html") + fixture.table.links[fixture.path("link/page.html")] = "/fixture/outside/page.html" + + await #expect(throws: PluginHostError.outsideWorkspace) { + try await fixture.preview.openHTML(PluginHostPreviewOpenRequest( + context: fixture.context(), resource: fixture.resource("link/page.html") + )) + } + await #expect(throws: PluginHostError.windowUnavailable) { + try await fixture.preview.openHTML(PluginHostPreviewOpenRequest( + context: fixture.context(windowID: UUID()), resource: fixture.resource("link/page.html") + )) + } + #expect(fixture.window.openCalls.isEmpty) + } +} diff --git a/macos/Tests/LitheTests/PluginHostMessageServiceTests.swift b/macos/Tests/LitheTests/PluginHostMessageServiceTests.swift new file mode 100644 index 0000000000..be5d6a6d23 --- /dev/null +++ b/macos/Tests/LitheTests/PluginHostMessageServiceTests.swift @@ -0,0 +1,469 @@ +import Foundation +import Testing +@testable import Lithe + +@MainActor +struct PluginHostMessageServiceTests { + private typealias Outcome = Task + + private func request( + _ fixture: PluginHostFixture, _ text: String = "Build finished", + severity: PluginHostMessageSeverity = .information, actions: [String] = [], + caller: PluginHostCaller? = nil, windowID: UUID? = nil + ) -> PluginHostMessageRequest { + PluginHostMessageRequest( + context: fixture.context(windowID: windowID, caller: caller), + severity: severity, text: text, actions: actions + ) + } + + private func start(_ service: PluginHostMessageService, _ request: PluginHostMessageRequest) -> Outcome { + Task { @MainActor in try await service.show(request) } + } + + private func waitForPending( + _ messages: PluginHostMessageFeatureModel, _ count: Int, sourceLocation: SourceLocation = #_sourceLocation + ) async { + let reached = await waitForPluginHostMessages(messages) { messages.pendingCount == count } + #expect(reached, "Pending messages were \(messages.pendingCount), expected \(count)", sourceLocation: sourceLocation) + } + + // MARK: Answers + + @Test func theUsersChoiceComesBackAsTheIndexOfTheButton() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let service = fixture.messageService() + let messages = fixture.window.messages + + let outcome = start(service, request(fixture, actions: ["Open", "Later"])) + await waitForPending(messages, 1) + let shown = try #require(messages.visible.first) + messages.choose(shown.id, index: 1) + + #expect(try await outcome.value == .selected(index: 1)) + #expect(messages.pendingCount == 0) + } + + @Test func closingAMessageIsAnAnswerOfItsOwn() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let service = fixture.messageService() + let messages = fixture.window.messages + + let outcome = start(service, request(fixture, severity: .error, actions: ["Retry"])) + await waitForPending(messages, 1) + messages.dismiss(try #require(messages.visible.first).id) + + #expect(try await outcome.value == .dismissed(.user)) + } + + @Test func textAndButtonsAreShownExactlyAsThePluginWroteThem() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let service = fixture.messageService() + let messages = fixture.window.messages + let text = "Deploy 100% done\n next: %@ {arg1} \"quoted\" 部署完成" + let actions = ["View %@", "了解"] + + let outcome = start(service, request(fixture, text, severity: .warning, actions: actions)) + await waitForPending(messages, 1) + let shown = try #require(messages.visible.first) + + #expect(shown.text == text) + #expect(shown.actions == actions) + #expect(shown.severity == .warning) + #expect(shown.pluginID == fixture.caller.pluginID) + #expect(shown.pluginName == fixture.caller.pluginName) + #expect(shown.workspaceGeneration == PluginHostFixture.defaultGeneration) + messages.dismiss(shown.id) + _ = try await outcome.value + } + + @Test func aMessageWithoutButtonsIsAccepted() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let service = fixture.messageService() + let messages = fixture.window.messages + + let outcome = start(service, request(fixture)) + await waitForPending(messages, 1) + messages.dismiss(try #require(messages.visible.first).id) + + #expect(try await outcome.value == .dismissed(.user)) + } + + // MARK: Queue + + @Test func messagesBeyondTheVisibleLimitWaitAndAppearOnceRoomIsMade() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let messages = fixture.window.messages + var limits = PluginHostMessageLimits() + limits.maxPendingPerPlugin = 10 + let crowded = fixture.messageService(limits: limits) + + var outcomes: [Outcome] = [] + for index in 0...PluginHostMessageFeatureModel.visibleLimit { + outcomes.append(start(crowded, request(fixture, "message \(index)", actions: ["OK"]))) + await waitForPending(messages, index + 1) + } + #expect(messages.visible.map(\.text) == ["message 0", "message 1", "message 2"]) + #expect(messages.queued.map(\.text) == ["message 3"]) + + messages.choose(try #require(messages.visible.first).id, index: 0) + #expect(try await outcomes[0].value == .selected(index: 0)) + + // The waiting message took the freed place, in order. + #expect(messages.visible.map(\.text) == ["message 1", "message 2", "message 3"]) + #expect(messages.queued.isEmpty) + } + + @Test func aPluginCannotHaveMoreThanItsShareOfMessagesPending() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let service = fixture.messageService() + let messages = fixture.window.messages + let other = PluginHostCaller(pluginID: "com.example.other", pluginName: "Other") + + var outcomes: [Outcome] = [] + for index in 0.. Void)? + + let confirmed = await PluginHostEditorFlush.run( + timeout: .seconds(1), sleep: { _ in }, request: { late = $0 } + ) + // The editor answering after the bound must not resume anything twice. + late?(true) + + #expect(!confirmed) + } + + @Test func anAnswerThatArrivesLaterBeforeTheTimeoutIsUsed() async { + let confirmed = await PluginHostEditorFlush.run( + timeout: .seconds(3600), + sleep: parkUntilCancelled, + request: { answer in Task { @MainActor in answer(true) } } + ) + + #expect(confirmed) + } + + @Test func aRepeatedAnswerIsIgnored() async { + let confirmed = await PluginHostEditorFlush.run( + timeout: .seconds(3600), + sleep: parkUntilCancelled, + request: { answer in + answer(true) + answer(false) + } + ) + + #expect(confirmed) + } +} diff --git a/macos/Tests/LitheTests/PluginHostTestSupport.swift b/macos/Tests/LitheTests/PluginHostTestSupport.swift new file mode 100644 index 0000000000..9f09df520a --- /dev/null +++ b/macos/Tests/LitheTests/PluginHostTestSupport.swift @@ -0,0 +1,254 @@ +import Combine +import Foundation +@testable import Lithe + +/// Files of a pretend workspace: which paths exist and where links lead. +/// Nothing touches the disk, so the resolver tests are deterministic. +final class PluginHostFileTable { + var files: [String: FileMetadata] = [:] + var links: [String: String] = [:] + + func addFile(_ path: String) { + files[path] = FileMetadata(byteCount: 1, modificationDate: nil, isRegularFile: true, isDirectory: false) + } + + func addDirectory(_ path: String) { + files[path] = FileMetadata(byteCount: nil, modificationDate: nil, isRegularFile: false, isDirectory: true) + } + + func resolver() -> PluginHostResourceResolver { + PluginHostResourceResolver( + canonicalize: { [self] url in + let path = url.standardizedFileURL.path + return URL(fileURLWithPath: links[path] ?? path) + }, + metadata: { [self] url in files[url.path] } + ) + } +} + +/// A window as the services see it, modelling the document owner's rules: +/// an open buffer is never reloaded, only one tab is in front, and a file +/// that is not open is loaded from disk exactly once. +@MainActor +final class FakePluginHostWindow: PluginHostWindowPort { + struct OpenCall: Equatable { + let path: String + let activation: PluginHostEditorActivation + } + + struct CaretPlacement: Equatable { + let path: String + let position: PluginHostPosition + } + + var snapshot: PluginHostWindowSnapshot? + /// Open buffers by file path; true when the buffer holds unsaved edits. + var openBuffers: [String: Bool] = [:] + /// The file whose tab is in front, or nil when another kind of tab is. + var foregroundPath: String? + /// Files whose latest editor input is only reported when a flush is asked for. + var pendingEditorInput: Set = [] + var unopenablePaths: Set = [] + /// Runs while a file is loading, to model the window changing under the call. + var whileLoading: (@MainActor () -> Void)? + /// Runs while the editor is asked to report its input. + var whileFlushing: (@MainActor () -> Void)? + private(set) var diskLoads: [String] = [] + private(set) var openCalls: [OpenCall] = [] + private(set) var flushedPaths: [String] = [] + private(set) var caretPlacements: [CaretPlacement] = [] + private(set) var limitNotices: [String] = [] + let messages = PluginHostMessageFeatureModel() + + init(snapshot: PluginHostWindowSnapshot?) { + self.snapshot = snapshot + } + + var hostSnapshot: PluginHostWindowSnapshot? { snapshot } + var hostMessages: PluginHostMessageFeatureModel { messages } + + func hostDocumentState(at url: URL, flushingEditor: Bool) async -> PluginHostDocumentState? { + guard snapshot != nil else { return nil } + let path = url.standardizedFileURL.path + guard var isDirty = openBuffers[path] else { return state(path) } + if flushingEditor { + flushedPaths.append(path) + whileFlushing?() + if pendingEditorInput.contains(path) { isDirty = true } + } + return PluginHostDocumentState( + url: URL(fileURLWithPath: path), isOpen: true, hasUnsavedChanges: isDirty, + isActive: foregroundPath == path, isReadOnly: false + ) + } + + func hostOpenDocument(at url: URL, activation: PluginHostEditorActivation) async -> PluginHostDocumentState? { + let path = url.standardizedFileURL.path + openCalls.append(OpenCall(path: path, activation: activation)) + guard !unopenablePaths.contains(path) else { return nil } + if openBuffers[path] == nil { + openBuffers[path] = false + diskLoads.append(path) + } + if activation == .select { foregroundPath = path } + whileLoading?() + return state(path) + } + + func hostPlaceCaret(in url: URL, at position: PluginHostPosition) { + caretPlacements.append(CaretPlacement(path: url.standardizedFileURL.path, position: position)) + } + + func hostNoteMessageLimit(pluginName: String) { + limitNotices.append(pluginName) + } + + private func state(_ path: String) -> PluginHostDocumentState { + let isOpen = openBuffers[path] != nil + return PluginHostDocumentState( + url: URL(fileURLWithPath: path), isOpen: isOpen, hasUnsavedChanges: openBuffers[path] ?? false, + isActive: isOpen && foregroundPath == path, isReadOnly: false + ) + } +} + +@MainActor +final class FakePluginHostDirectory: PluginHostWindowDirectory { + var windows: [UUID: FakePluginHostWindow] = [:] + + func hostWindow(id: UUID) -> (any PluginHostWindowPort)? { windows[id] } +} + +@MainActor +final class RecordingExternalOpener: PluginHostExternalOpening { + private(set) var webURLs: [URL] = [] + private(set) var htmlFiles: [URL] = [] + var failure: (any Error)? + + func openWebURL(_ url: URL) async throws { + if let failure { throw failure } + webURLs.append(url) + } + + func openLocalHTMLFile(_ url: URL) async throws { + if let failure { throw failure } + htmlFiles.append(url) + } +} + +/// The time the rate limiter sees; tests move it instead of waiting. +@MainActor +final class PluginHostTestClock { + var now: TimeInterval = 1_000 +} + +/// One local window with a workspace, and every service wired to it. +/// +/// Call `tearDown()` in a `defer`: it ends the window's messages, and it keeps +/// the fixture alive until the test finishes, because the services only hold +/// the directory weakly. +@MainActor +final class PluginHostFixture { + static let workspaceURL = URL(fileURLWithPath: "/fixture/workspace", isDirectory: true) + static let defaultGeneration = 7 + + let windowID: UUID + let directory = FakePluginHostDirectory() + let table = PluginHostFileTable() + let window: FakePluginHostWindow + let opener = RecordingExternalOpener() + let clock = PluginHostTestClock() + let caller = PluginHostCaller(pluginID: "com.example.html-tools", pluginName: "HTML Tools") + let editor: PluginHostEditorService + let preview: PluginHostPreviewService + let browser: PluginHostBrowserService + + init() { + let id = UUID() + windowID = id + window = FakePluginHostWindow(snapshot: Self.snapshot(windowID: id)) + directory.windows[id] = window + editor = PluginHostEditorService(directory: directory, resolver: table.resolver()) + preview = PluginHostPreviewService(editor: editor) + browser = PluginHostBrowserService(directory: directory, resolver: table.resolver(), opener: opener) + } + + func tearDown() { + // The hooks may capture the fixture or the directory; drop them so nothing is kept alive. + window.whileLoading = nil + window.whileFlushing = nil + for fakeWindow in directory.windows.values { fakeWindow.messages.dismissAll(.cancelled) } + directory.windows.removeAll() + } + + static func snapshot( + windowID: UUID, isForeground: Bool = true, isRemote: Bool = false, + generation: Int = PluginHostFixture.defaultGeneration + ) -> PluginHostWindowSnapshot { + PluginHostWindowSnapshot( + windowID: windowID, workspaceURL: workspaceURL, workspaceGeneration: generation, + isRemoteWorkspace: isRemote, isForeground: isForeground, + roots: [PluginHostRoot(id: PluginHostResource.workspaceRootID, url: workspaceURL)] + ) + } + + /// Replaces the window's snapshot, as a user switching windows or workspaces would. + func updateSnapshot(isForeground: Bool = true, isRemote: Bool = false, generation: Int = PluginHostFixture.defaultGeneration) { + window.snapshot = Self.snapshot( + windowID: windowID, isForeground: isForeground, isRemote: isRemote, generation: generation + ) + } + + /// A second window, in the directory, for tests that must not leak into another window. + func addWindow(isForeground: Bool) -> (id: UUID, window: FakePluginHostWindow) { + let id = UUID() + let other = FakePluginHostWindow(snapshot: Self.snapshot(windowID: id, isForeground: isForeground)) + directory.windows[id] = other + return (id, other) + } + + func path(_ relative: String) -> String { "/fixture/workspace/" + relative } + func url(_ relative: String) -> URL { URL(fileURLWithPath: path(relative)) } + + /// A regular file of the workspace. + func addFile(_ relative: String) { table.addFile(path(relative)) } + + func resource(_ relative: String, root: String = PluginHostResource.workspaceRootID) -> PluginHostResource { + PluginHostResource(rootID: root, relativePath: relative) + } + + func context( + windowID: UUID? = nil, generation: Int = PluginHostFixture.defaultGeneration, caller: PluginHostCaller? = nil + ) -> PluginHostCallContext { + PluginHostCallContext( + caller: caller ?? self.caller, windowID: windowID ?? self.windowID, + workspaceURL: Self.workspaceURL, workspaceGeneration: generation + ) + } + + func openRequest( + _ relative: String, range: PluginHostTextRange? = nil, + activation: PluginHostEditorActivation = .select, tab: PluginHostEditorTab = .regular + ) -> PluginHostEditorOpenRequest { + PluginHostEditorOpenRequest( + context: context(), resource: resource(relative), range: range, activation: activation, tab: tab + ) + } + + func messageService(limits: PluginHostMessageLimits = PluginHostMessageLimits()) -> PluginHostMessageService { + let clock = clock + return PluginHostMessageService(directory: directory, limits: limits, now: { clock.now }) + } +} + +/// Waits until `isSatisfied` holds, re-checking after each change of the +/// message surface, and gives up at the deadline instead of hanging. +@MainActor +func waitForPluginHostMessages( + _ messages: PluginHostMessageFeatureModel, + timeout: DispatchTimeInterval = .seconds(5), + until isSatisfied: @escaping @MainActor () -> Bool +) async -> Bool { + await waitForChanges([messages.objectWillChange.eraseToAnyPublisher()], timeout: timeout, until: isSatisfied) +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index bcf4e95aa6..b06479366d 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9521,7 +9521,27 @@ "native.thePluginIsNotAllowedToUseTheHostService": "The plugin is not allowed to use the host service {arg1}.", "native.theParamsOfMustBeAnObject": "The params of {arg1} must be an object.", "native.theWorkspaceContextReferenceIsMalformed": "The workspace context reference is malformed.", - "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "The host could not create the plugin's workspace state directory." + "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "The host could not create the plugin's workspace state directory.", + "native.theResourcePathMustBeRelativeUseSeparatorsAndNotContainOr": "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\".", + "native.theResourceNamesARootThisWindowDoesNotOffer": "The resource names a root this window does not offer.", + "native.theResourceIsOutsideTheWorkspace": "The resource is outside the workspace.", + "native.theResourceDoesNotExist": "The resource does not exist.", + "native.theResourceIsNotARegularFile": "The resource is not a regular file.", + "native.theWindowIsClosedOrHasNoWorkspaceOpen": "The window is closed or has no workspace open.", + "native.theWindowNowShowsADifferentWorkspaceThanTheOneThisCallWasMadeFor": "The window now shows a different workspace than the one this call was made for.", + "native.remoteWorkspacesAreNotSupportedByThisOperationYet": "Remote workspaces are not supported by this operation yet.", + "native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts": "Lines and columns start at 1, and a range must not end before it starts.", + "native.theHostDoesNotSupportTheRequestedOptionYet": "The host does not support the requested option yet.", + "native.theFileIsNotOpenInThisWindow": "The file is not open in this window.", + "native.theFileCouldNotBeOpenedAsATextDocument": "The file could not be opened as a text document.", + "native.onlyHtmlAndHtmFilesCanBeShownInABrowser": "Only .html and .htm files can be shown in a browser.", + "native.onlyHttpAndHttpsAddressesWithAHostNameCanBeOpened": "Only http and https addresses with a host name can be opened.", + "native.theFileHasUnsavedChangesSaveItFirstBecauseTheBrowserShowsTheSavedFile": "The file has unsaved changes. Save it first, because the browser shows the saved file.", + "native.noWebBrowserCouldBeOpened": "No web browser could be opened.", + "native.theMessageTextOrItsButtonsAreEmptyTooLongOrTooMany": "The message text or its buttons are empty, too long, or too many.", + "native.thisPluginSentMessagesTooQuickly": "This plugin sent messages too quickly.", + "native.thisPluginAlreadyHasTooManyMessagesWaiting": "This plugin already has too many messages waiting.", + "native.argSentMessagesTooQuicklySoSomeWereNotShown": "{arg1} sent messages too quickly, so some were not shown." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 2b016e06e1..58130758b2 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9521,7 +9521,27 @@ "native.thePluginIsNotAllowedToUseTheHostService": "插件无权使用宿主服务 {arg1}。", "native.theParamsOfMustBeAnObject": "{arg1} 的参数必须是对象。", "native.theWorkspaceContextReferenceIsMalformed": "工作区上下文引用格式不正确。", - "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "宿主无法创建插件的工作区状态目录。" + "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "宿主无法创建插件的工作区状态目录。", + "native.theResourcePathMustBeRelativeUseSeparatorsAndNotContainOr": "资源路径必须是相对路径,使用“/”分隔,且不能包含“.”或“..”。", + "native.theResourceNamesARootThisWindowDoesNotOffer": "资源指定的根目录不在此窗口提供的范围内。", + "native.theResourceIsOutsideTheWorkspace": "资源位于工作区之外。", + "native.theResourceDoesNotExist": "资源不存在。", + "native.theResourceIsNotARegularFile": "资源不是常规文件。", + "native.theWindowIsClosedOrHasNoWorkspaceOpen": "窗口已关闭或没有打开工作区。", + "native.theWindowNowShowsADifferentWorkspaceThanTheOneThisCallWasMadeFor": "窗口现在显示的工作区与本次调用针对的工作区不同。", + "native.remoteWorkspacesAreNotSupportedByThisOperationYet": "此操作暂不支持远程工作区。", + "native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts": "行号和列号从 1 开始,范围的终点不能早于起点。", + "native.theHostDoesNotSupportTheRequestedOptionYet": "宿主暂不支持所请求的选项。", + "native.theFileIsNotOpenInThisWindow": "该文件未在此窗口中打开。", + "native.theFileCouldNotBeOpenedAsATextDocument": "无法将该文件作为文本文档打开。", + "native.onlyHtmlAndHtmFilesCanBeShownInABrowser": "只有 .html 和 .htm 文件可以在浏览器中显示。", + "native.onlyHttpAndHttpsAddressesWithAHostNameCanBeOpened": "只能打开带主机名的 http 和 https 地址。", + "native.theFileHasUnsavedChangesSaveItFirstBecauseTheBrowserShowsTheSavedFile": "文件有未保存的更改。请先保存,因为浏览器显示的是已保存的文件。", + "native.noWebBrowserCouldBeOpened": "无法打开网页浏览器。", + "native.theMessageTextOrItsButtonsAreEmptyTooLongOrTooMany": "消息文本或按钮为空、过长或过多。", + "native.thisPluginSentMessagesTooQuickly": "此插件发送消息过快。", + "native.thisPluginAlreadyHasTooManyMessagesWaiting": "此插件已有过多消息在等待。", + "native.argSentMessagesTooQuicklySoSomeWereNotShown": "{arg1} 发送消息过快,部分消息未显示。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 4de54c6eb8..d44f2fc480 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -90868,6 +90868,231 @@ "key": "The host could not create the plugin's workspace state directory.", "arguments": [] } + }, + { + "id": "native.theResourcePathMustBeRelativeUseSeparatorsAndNotContainOr", + "description": "Native UI: The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\".", + "platforms": [ + "macos" + ], + "macos": { + "key": "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\".", + "arguments": [] + } + }, + { + "id": "native.theResourceNamesARootThisWindowDoesNotOffer", + "description": "Native UI: The resource names a root this window does not offer.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The resource names a root this window does not offer.", + "arguments": [] + } + }, + { + "id": "native.theResourceIsOutsideTheWorkspace", + "description": "Native UI: The resource is outside the workspace.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The resource is outside the workspace.", + "arguments": [] + } + }, + { + "id": "native.theResourceDoesNotExist", + "description": "Native UI: The resource does not exist.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The resource does not exist.", + "arguments": [] + } + }, + { + "id": "native.theResourceIsNotARegularFile", + "description": "Native UI: The resource is not a regular file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The resource is not a regular file.", + "arguments": [] + } + }, + { + "id": "native.theWindowIsClosedOrHasNoWorkspaceOpen", + "description": "Native UI: The window is closed or has no workspace open.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The window is closed or has no workspace open.", + "arguments": [] + } + }, + { + "id": "native.theWindowNowShowsADifferentWorkspaceThanTheOneThisCallWasMadeFor", + "description": "Native UI: The window now shows a different workspace than the one this call was made for.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The window now shows a different workspace than the one this call was made for.", + "arguments": [] + } + }, + { + "id": "native.remoteWorkspacesAreNotSupportedByThisOperationYet", + "description": "Native UI: Remote workspaces are not supported by this operation yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote workspaces are not supported by this operation yet.", + "arguments": [] + } + }, + { + "id": "native.linesAndColumnsStartAtAndARangeMustNotEndBeforeItStarts", + "description": "Native UI: Lines and columns start at 1, and a range must not end before it starts.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lines and columns start at 1, and a range must not end before it starts.", + "arguments": [] + } + }, + { + "id": "native.theHostDoesNotSupportTheRequestedOptionYet", + "description": "Native UI: The host does not support the requested option yet.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The host does not support the requested option yet.", + "arguments": [] + } + }, + { + "id": "native.theFileIsNotOpenInThisWindow", + "description": "Native UI: The file is not open in this window.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The file is not open in this window.", + "arguments": [] + } + }, + { + "id": "native.theFileCouldNotBeOpenedAsATextDocument", + "description": "Native UI: The file could not be opened as a text document.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The file could not be opened as a text document.", + "arguments": [] + } + }, + { + "id": "native.onlyHtmlAndHtmFilesCanBeShownInABrowser", + "description": "Native UI: Only .html and .htm files can be shown in a browser.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only .html and .htm files can be shown in a browser.", + "arguments": [] + } + }, + { + "id": "native.onlyHttpAndHttpsAddressesWithAHostNameCanBeOpened", + "description": "Native UI: Only http and https addresses with a host name can be opened.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only http and https addresses with a host name can be opened.", + "arguments": [] + } + }, + { + "id": "native.theFileHasUnsavedChangesSaveItFirstBecauseTheBrowserShowsTheSavedFile", + "description": "Native UI: The file has unsaved changes. Save it first, because the browser shows the saved file.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The file has unsaved changes. Save it first, because the browser shows the saved file.", + "arguments": [] + } + }, + { + "id": "native.noWebBrowserCouldBeOpened", + "description": "Native UI: No web browser could be opened.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No web browser could be opened.", + "arguments": [] + } + }, + { + "id": "native.theMessageTextOrItsButtonsAreEmptyTooLongOrTooMany", + "description": "Native UI: The message text or its buttons are empty, too long, or too many.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The message text or its buttons are empty, too long, or too many.", + "arguments": [] + } + }, + { + "id": "native.thisPluginSentMessagesTooQuickly", + "description": "Native UI: This plugin sent messages too quickly.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin sent messages too quickly.", + "arguments": [] + } + }, + { + "id": "native.thisPluginAlreadyHasTooManyMessagesWaiting", + "description": "Native UI: This plugin already has too many messages waiting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This plugin already has too many messages waiting.", + "arguments": [] + } + }, + { + "id": "native.argSentMessagesTooQuicklySoSomeWereNotShown", + "description": "Native UI: %@ sent messages too quickly, so some were not shown.", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ sent messages too quickly, so some were not shown.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } } ] } From 23f281f965211a8e6d0bc6eede8418ecd67b94d4 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 10:48:49 +0800 Subject: [PATCH 08/16] fix(macos): isolate editor flush request to the main actor --- .../Lithe/Application/Features/PluginHostEditorFlush.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift index 841bbeb98e..7a78607497 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift @@ -18,7 +18,7 @@ enum PluginHostEditorFlush { static func run( timeout: Duration = defaultTimeout, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, - request: (@escaping @MainActor (Bool) -> Void) -> Void + request: @MainActor (@escaping @MainActor (Bool) -> Void) -> Void ) async -> Bool { let waiter = Waiter() return await withCheckedContinuation { (continuation: CheckedContinuation) in From 78d3be13f7c94440186afaad8004c7a5e7e98c04 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 10:58:36 +0800 Subject: [PATCH 09/16] feat(plugins): give each host error its own JSON-RPC code from protocol 3 Record the backend workspace context and host services in the platform feature matrix, and require one-to-one error codes in develop-lithe. --- .agents/skills/develop-lithe/SKILL.md | 4 ++ rust/lithe-core/src/lsp/interface/engine.rs | 15 ++--- .../src/lsp/interface/plugin_function.rs | 58 ++++++++++++++----- shared/contracts/plugins/v1/README.md | 33 ++++++++--- .../v1/fixtures/function-protocol.json | 14 ++--- .../plugin-backend-workspace-context.json | 32 ++++++++++ ...ugin-host-editor-file-window-services.json | 38 ++++++++++++ 7 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 shared/platform-feature-matrix/features/plugin-backend-workspace-context.json create mode 100644 shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index 1e0edaed84..6bc735d684 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -137,6 +137,10 @@ macOS 是当前参考产品;Windows 是独立实现,不得导入 Swift 源 - 行号从 1 开始;缺失位置用 `null`;列表及序列化结果保持确定性顺序。 - 应用契约要求时,异步操作显式区分 `idle`、`loading`、`ready`、`failed`。 - 返回稳定错误码与面向用户的消息;平台专属细节放入契约的 `details` 字段。 +- 新增 API 的返回码、错误码必须与错误类型一一对应:一个数值码(如 JSON-RPC `error.code`)不得同时代表 + 多种错误,调用方应能只凭数值码分支。标准语义(方法不存在、参数错误、取消)沿用 JSON-RPC/LSP 标准码, + 其余各占服务端自定义区间的独立码,并在契约文档和 fixture 中列出映射;已发布协议版本的旧码保持不变, + 从新版本起区分。 - 进程型功能保留 `operationID`、取消、超时和过期结果语义。 - 第二个平台依赖新共享行为前,先补充或更新共享 fixture。 - 命令名、JSON 字段、错误码和 C ABI 都是兼容面;变更时同步更新契约文档及所有消费者。 diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index ccf7b18ca7..d11f2db2e0 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -5893,15 +5893,16 @@ fn forward_host_request_locked( let backend_id = request.get("id").cloned().unwrap_or(Value::Null); if state.host_requests.len() >= super::plugin_function::MAX_PENDING_HOST_REQUESTS { let version = state.client.plugin_function_version.unwrap_or(0); + let code = if version >= super::plugin_function::CONTEXT_VERSION { + super::plugin_function::codes::HOST_LIMIT_EXCEEDED + } else { + super::plugin_function::codes::HOST_FAILED + }; return Ok(vec![json_rpc_error_with_code( &backend_id, - -32603, + super::plugin_function::host_rpc_code(code), "Too many host-service requests are waiting for the host.", - if version >= super::plugin_function::CONTEXT_VERSION { - super::plugin_function::codes::HOST_LIMIT_EXCEEDED - } else { - super::plugin_function::codes::HOST_FAILED - }, + code, )?]); } let host_request_id = format!("host-{}", state.next_host_request_id); @@ -13239,7 +13240,7 @@ public class Main { "a", "plugin.host.contextExpired", "plugin.host.contextExpired", - -32603, + -32011, ), ("b", "plugin.function.unknown", "plugin.host.failed", -32603), ] { diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 7bbdd84c91..9b5626bbee 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -513,7 +513,12 @@ pub(crate) fn host_request( }; if !is_implicit_service(entry.service) && !granted.contains(entry.service) { return Err(HostRequestRefusal { - rpc_code: -32601, + // Version 2 answered a missing grant like a missing method. + rpc_code: if version >= CONTEXT_VERSION { + host_rpc_code(codes::HOST_PERMISSION_DENIED) + } else { + -32601 + }, code: codes::HOST_PERMISSION_DENIED, message: "The plugin does not declare the permission for this host service.", }); @@ -675,25 +680,46 @@ pub(crate) fn attach_context( Ok(()) } -/// The code and JSON-RPC error code the backend receives when the host -/// failed a forwarded request. Only the codes a host may report pass +/// The JSON-RPC error code of a host-service code from version 3 on. Each +/// code has its own number, so a backend can branch on the number alone: +/// codes with a standard meaning keep the JSON-RPC or LSP number, and the +/// rest take numbers from the server-defined range, after -32000, which +/// backends use for their own failures. Versions 1 and 2 keep their +/// earlier numbers, see `host_request`. +pub(crate) fn host_rpc_code(code: &str) -> i64 { + match code { + codes::HOST_UNAVAILABLE => -32601, + codes::HOST_INVALID_PARAMS => -32602, + codes::HOST_CANCELLED => -32800, + codes::HOST_PERMISSION_DENIED => -32010, + codes::HOST_CONTEXT_EXPIRED => -32011, + codes::HOST_AUTHORITY_UNSUPPORTED => -32012, + codes::HOST_VERSION_CONFLICT => -32013, + codes::HOST_LIMIT_EXCEEDED => -32014, + codes::HOST_TIMEOUT => -32015, + _ => -32603, + } +} + +/// The code and JSON-RPC error code a version 3 backend receives when the +/// host failed a forwarded request. Only the codes a host may report pass /// through; anything else, including none, is `plugin.host.failed`, so a /// host cannot impersonate Core's own refusals of other kinds. pub(crate) fn host_failure(code: Option<&str>) -> (&'static str, i64) { - match code { - Some(codes::HOST_UNAVAILABLE) => (codes::HOST_UNAVAILABLE, -32601), - Some(codes::HOST_PERMISSION_DENIED) => (codes::HOST_PERMISSION_DENIED, -32601), - Some(codes::HOST_INVALID_PARAMS) => (codes::HOST_INVALID_PARAMS, -32602), - Some(codes::HOST_CONTEXT_EXPIRED) => (codes::HOST_CONTEXT_EXPIRED, -32603), - Some(codes::HOST_AUTHORITY_UNSUPPORTED) => (codes::HOST_AUTHORITY_UNSUPPORTED, -32603), - Some(codes::HOST_VERSION_CONFLICT) => (codes::HOST_VERSION_CONFLICT, -32603), - Some(codes::HOST_LIMIT_EXCEEDED) => (codes::HOST_LIMIT_EXCEEDED, -32603), - Some(codes::HOST_CANCELLED) => (codes::HOST_CANCELLED, -32800), - Some(codes::HOST_TIMEOUT) => (codes::HOST_TIMEOUT, -32603), + let code = match code { + Some(codes::HOST_UNAVAILABLE) => codes::HOST_UNAVAILABLE, + Some(codes::HOST_PERMISSION_DENIED) => codes::HOST_PERMISSION_DENIED, + Some(codes::HOST_INVALID_PARAMS) => codes::HOST_INVALID_PARAMS, + Some(codes::HOST_CONTEXT_EXPIRED) => codes::HOST_CONTEXT_EXPIRED, + Some(codes::HOST_AUTHORITY_UNSUPPORTED) => codes::HOST_AUTHORITY_UNSUPPORTED, + Some(codes::HOST_VERSION_CONFLICT) => codes::HOST_VERSION_CONFLICT, + Some(codes::HOST_LIMIT_EXCEEDED) => codes::HOST_LIMIT_EXCEEDED, + Some(codes::HOST_CANCELLED) => codes::HOST_CANCELLED, + Some(codes::HOST_TIMEOUT) => codes::HOST_TIMEOUT, // A backend exit is reported to pages, never to the backend itself. - Some(codes::BACKEND_EXITED) => (codes::HOST_FAILED, -32603), - _ => (codes::HOST_FAILED, -32603), - } + _ => codes::HOST_FAILED, + }; + (code, host_rpc_code(code)) } /// Key of a stored secret, also the alphabet of subscription and context diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 57f4df7e0b..355802d489 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1860,9 +1860,10 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's the plugin and are deleted when it is uninstalled. - Core refuses, without involving the host, in this order: a session that negotiated version 1 or a method version 2 does not define (`-32601`, - `plugin.host.unavailable`), a service the plugin is not granted (`-32601`, - `plugin.host.permissionDenied`), and malformed params (`-32602`, - `plugin.host.invalidParams`). The code is in `error.data.code`. + `plugin.host.unavailable`), a service the plugin is not granted (`-32601` + in version 2, `-32010` from version 3, `plugin.host.permissionDenied`), + and malformed params (`-32602`, `plugin.host.invalidParams`). The code is + in `error.data.code`. - An accepted request becomes the runtime event `hostRequest` with `method` and `result: { hostRequestID, service, params }`. The host answers it with `lsp.hostResponse { sessionId, hostRequestID, result? | error: { message } }`; @@ -1935,13 +1936,27 @@ failures, and cancellation of host requests. `plugin.function.invalidParams`; a backend that negotiated version 1 or 2 receives the call without it. The host fills it from the page's window. - A host failure may carry a code: - `lsp.hostResponse.error: { message, code? }`. Core passes - `plugin.host.unavailable`, `.permissionDenied` (`-32601`), - `.invalidParams` (`-32602`), `.contextExpired`, `.authorityUnsupported`, - `.versionConflict`, `.limitExceeded`, `.timeout` (`-32603`), and - `.cancelled` (`-32800`) to a version 3 backend; any other code, no code, - or a version 2 backend gets `plugin.host.failed`. `fixtures/function-protocol.json` + `lsp.hostResponse.error: { message, code? }`. Core passes these codes to + a version 3 backend, each with its own JSON-RPC error code so a backend + can branch on the number alone; any other code, no code, or a version 2 + backend gets `plugin.host.failed`. `fixtures/function-protocol.json` (`hostFailures`) lists the mapping. + + | Code | JSON-RPC | Meaning | + | --- | --- | --- | + | `plugin.host.unavailable` | `-32601` | The host offers no such service or method | + | `plugin.host.invalidParams` | `-32602` | Params malformed or out of range | + | `plugin.host.failed` | `-32603` | Any other failure | + | `plugin.host.cancelled` | `-32800` | The request was cancelled | + | `plugin.host.permissionDenied` | `-32010` | Permission not declared, or the user withheld consent | + | `plugin.host.contextExpired` | `-32011` | The context is closed or its generation replaced | + | `plugin.host.authorityUnsupported` | `-32012` | The service does not support the workspace authority | + | `plugin.host.versionConflict` | `-32013` | A document or resource version differs from the expected one | + | `plugin.host.limitExceeded` | `-32014` | A host limit was exceeded | + | `plugin.host.timeout` | `-32015` | The host's deadline passed | + + Codes from `-32010` are in the JSON-RPC server-defined range and leave + `-32000` to backends' own failures. Versions 1 and 2 keep their numbers. - A backend may cancel its own pending host request with `$/cancelRequest`. Core answers it at once with `-32800` and `plugin.host.cancelled`, frees the slot, and publishes `hostRequestCancelled { hostRequestID }` so the host diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index dfee71a319..08ab990e80 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -411,7 +411,7 @@ "hostServices": ["credentials"], "method": "lithe/host/workspace/listContexts", "params": {}, - "expected": { "rpcCode": -32601, "code": "plugin.host.permissionDenied" } + "expected": { "rpcCode": -32010, "code": "plugin.host.permissionDenied" } }, { "name": "workspace contexts need version 3 even when granted", @@ -535,14 +535,14 @@ ], "hostFailures": [ { "name": "no code is a generic failure", "code": null, "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } }, - { "name": "a closed or replaced context", "code": "plugin.host.contextExpired", "expected": { "code": "plugin.host.contextExpired", "rpcCode": -32603 } }, - { "name": "a remote authority without the operation", "code": "plugin.host.authorityUnsupported", "expected": { "code": "plugin.host.authorityUnsupported", "rpcCode": -32603 } }, - { "name": "a stale versioned edit", "code": "plugin.host.versionConflict", "expected": { "code": "plugin.host.versionConflict", "rpcCode": -32603 } }, - { "name": "a host limit", "code": "plugin.host.limitExceeded", "expected": { "code": "plugin.host.limitExceeded", "rpcCode": -32603 } }, + { "name": "a closed or replaced context", "code": "plugin.host.contextExpired", "expected": { "code": "plugin.host.contextExpired", "rpcCode": -32011 } }, + { "name": "a remote authority without the operation", "code": "plugin.host.authorityUnsupported", "expected": { "code": "plugin.host.authorityUnsupported", "rpcCode": -32012 } }, + { "name": "a stale versioned edit", "code": "plugin.host.versionConflict", "expected": { "code": "plugin.host.versionConflict", "rpcCode": -32013 } }, + { "name": "a host limit", "code": "plugin.host.limitExceeded", "expected": { "code": "plugin.host.limitExceeded", "rpcCode": -32014 } }, { "name": "the host stopped the request", "code": "plugin.host.cancelled", "expected": { "code": "plugin.host.cancelled", "rpcCode": -32800 } }, - { "name": "the host's deadline passed", "code": "plugin.host.timeout", "expected": { "code": "plugin.host.timeout", "rpcCode": -32603 } }, + { "name": "the host's deadline passed", "code": "plugin.host.timeout", "expected": { "code": "plugin.host.timeout", "rpcCode": -32015 } }, { "name": "params the service itself refuses", "code": "plugin.host.invalidParams", "expected": { "code": "plugin.host.invalidParams", "rpcCode": -32602 } }, - { "name": "the user withheld consent", "code": "plugin.host.permissionDenied", "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32601 } }, + { "name": "the user withheld consent", "code": "plugin.host.permissionDenied", "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32010 } }, { "name": "a service the host turned off", "code": "plugin.host.unavailable", "expected": { "code": "plugin.host.unavailable", "rpcCode": -32601 } }, { "name": "a host cannot borrow a function code", "code": "plugin.function.unknown", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } }, { "name": "a host cannot report a backend exit to the backend", "code": "plugin.backend.exited", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } } diff --git a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json new file mode 100644 index 0000000000..50b8f4d646 --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json @@ -0,0 +1,32 @@ +{ + "id": "plugin-backend-workspace-context", + "area": "扩展", + "group": "插件与主题", + "capability": "插件后端 v2 与工作区上下文:requires.backend 2 的后端可由 onWorkspaceOpen 或 onCommand 激活,不依赖网页;函数协议 3 提供 lithe/host/getCapabilities、subscriptions/dispose、workspace/listContexts|getContext|getFolders,调用带 {contextID, generation},远程工作区不暴露路径;宿主错误码各有独立 JSON-RPC 码", + "macos": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/lsp/interface/plugin_function.rs", + "rust/lithe-core/src/lsp/interface/engine.rs", + "rust/lithe-core/src/plugins/backend.rs", + "shared/contracts/plugins/v1/README.md", + "shared/contracts/plugins/v1/fixtures/function-protocol.json", + "shared/contracts/plugins/v1/fixtures/backend-workspace.plugin.json", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift", + "macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift", + "macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Extensions", + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core,核对 function-protocol.json 的 hostRequests/hostFailures、v2 清单拒绝 onStartupFinished 与 *、旧宿主拒绝 backend 2、v3 会话第 17 个挂起请求返回 plugin.host.limitExceeded(-32014)、$/cancelRequest 立即返回 -32800 并发布 hostRequestCancelled。macOS 宿主:scripts/macos/test/test-macos.sh --filter PluginHostServiceDispatchTests。macOS 原生验收:debug 应用安装一个 backend 2、声明 onWorkspaceOpen 与 workspace.metadata 的示例插件,打开本地工作区后确认后端自动启动、listContexts/getFolders 返回该窗口的根;同一窗口换工作区后旧 generation 得到 plugin.host.contextExpired;打开远程工作区时 folders 不含 path;关闭窗口、停用插件后用 pgrep 确认后端不残留。", + "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)。macOS 已有 Core 共享测试与宿主分发单测,尚未做原生验收,所以验证状态为待验证。上下文变化暂不推送给后端,只能比较 listContexts.revision;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。" +} diff --git a/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json b/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json new file mode 100644 index 0000000000..7712b54383 --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json @@ -0,0 +1,38 @@ +{ + "id": "plugin-host-editor-file-window-services", + "area": "扩展", + "group": "插件与主题", + "capability": "插件宿主服务(内部实现,尚未对插件开放):读取已打开文档与选区并按版本整批编辑(一个撤销步骤,IME 组字期间拒绝);工作区文件分页读目录、stat、读文件、监听与文件名/全文搜索(逐段 O_NOFOLLOW,远程拒绝);在指定窗口打开/定位编辑器、打开 HTML 预览、用系统浏览器打开 http/https 或本地文件、显示带按钮的插件消息", + "macos": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Application/Features/EditorDocumentService.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift", + "frontend/editor/src/plugin-edits.ts", + "macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift", + "macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSecurePath.swift", + "macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift", + "macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift", + "macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift", + "macos/Sources/Lithe/Application/Features/PluginHostMessageService.swift", + "macos/Tests/LitheTests/EditorTextEditPlannerTests.swift", + "macos/Tests/LitheTests/EditorDocumentServiceTests.swift", + "macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift", + "macos/Tests/LitheTests/PluginHostEditorServiceTests.swift", + "macos/Tests/LitheTests/PluginHostBrowserServiceTests.swift", + "macos/Tests/LitheTests/PluginHostMessageServiceTests.swift", + "macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "docs/development/macos-plugin-api-plan.md" + ] + }, + "owner": "Extensions", + "verification": "macOS 单测:scripts/macos/test/test-macos.sh --filter \"EditorTextEditPlannerTests|EditorDocumentServiceTests|PluginWorkspaceFileServiceTests|PluginHostEditorServiceTests|PluginHostBrowserServiceTests|PluginHostMessageServiceTests|HTMLPreviewBridgeIsolationTests\";前端:frontend/editor 下运行 plugin-edits.test.ts。服务接入宿主分发、对插件开放后,按计划 §7 用三个验收插件(选区转换器、分页文件浏览器、HTML 预览启动器)在 debug 应用中原生验收:版本冲突与 IME 组字时编辑被拒、撤销一步回退整批编辑;符号链接与 .. 越界被拒、远程工作区返回 authorityUnsupported;后台窗口打开文件不抢焦点、消息限频生效;验收后关闭应用并确认无残留进程。", + "notes": "本条覆盖 #1548 计划的 API-06–API-10、API-15–API-17(三种消息)。这些服务目前只是 Mac 内部实现,没有注册进 hostDispatcher,也没在 Core HOST_METHODS 登记,用户与插件都还观察不到,所以实现状态为部分实现、验证为待验证。HTML 预览不暴露插件 bridge 已有回归测试。QuickPick/InputBox、可替换预览标签和纯预览模式未做。Windows/Tauri 不在本轮范围。" +} From a65723991232ade381ad1b8458a3538868a0d18a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 11:34:23 +0800 Subject: [PATCH 10/16] fix(macos): make the editor flush callback sendable --- .../Lithe/Application/Features/PluginHostEditorFlush.swift | 2 +- macos/Tests/LitheTests/PluginHostMessageServiceTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift index 7a78607497..3e4ef1cea9 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift @@ -18,7 +18,7 @@ enum PluginHostEditorFlush { static func run( timeout: Duration = defaultTimeout, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }, - request: @MainActor (@escaping @MainActor (Bool) -> Void) -> Void + request: (@escaping @MainActor @Sendable (Bool) -> Void) -> Void ) async -> Bool { let waiter = Waiter() return await withCheckedContinuation { (continuation: CheckedContinuation) in diff --git a/macos/Tests/LitheTests/PluginHostMessageServiceTests.swift b/macos/Tests/LitheTests/PluginHostMessageServiceTests.swift index be5d6a6d23..26109b54e1 100644 --- a/macos/Tests/LitheTests/PluginHostMessageServiceTests.swift +++ b/macos/Tests/LitheTests/PluginHostMessageServiceTests.swift @@ -433,7 +433,7 @@ struct PluginHostEditorFlushTests { } @Test func aTimeoutThatFiresFirstGivesFalseAndTheLateAnswerIsIgnored() async { - var late: (@MainActor (Bool) -> Void)? + var late: (@MainActor @Sendable (Bool) -> Void)? let confirmed = await PluginHostEditorFlush.run( timeout: .seconds(1), sleep: { _ in }, request: { late = $0 } From 0ebd03b2c823ea1536bbb500a824b54c4582a9e6 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 12:35:05 +0800 Subject: [PATCH 11/16] feat(core): add host notifications, failure details and finer host error codes --- rust/lithe-core/src/lsp/interface/client.rs | 15 +- rust/lithe-core/src/lsp/interface/engine.rs | 165 +++++++++- .../src/lsp/interface/plugin_function.rs | 296 +++++++++++++++++- rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 15 + .../v1/fixtures/function-protocol.json | 37 ++- 6 files changed, 510 insertions(+), 22 deletions(-) diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 334c3ffde6..5b1d912fa1 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -778,7 +778,7 @@ fn json_rpc_request(id: &str, method: &str, params: Value) -> Result Result { +pub(super) fn json_rpc_notification(method: &str, params: Value) -> Result { encode_json_rpc(json!({ "jsonrpc": "2.0", "method": method, @@ -824,11 +824,22 @@ pub(super) fn json_rpc_error_with_code( code: i64, message: &str, contract_code: &str, +) -> Result { + json_rpc_error_with_data(id, code, message, json!({ "code": contract_code })) +} + +/// A JSON-RPC error with the given `data`, which holds at least the stable +/// plugin-contract `code`. +pub(super) fn json_rpc_error_with_data( + id: &Value, + code: i64, + message: &str, + data: Value, ) -> Result { encode_json_rpc(json!({ "jsonrpc": "2.0", "id": id, - "error": { "code": code, "message": message, "data": { "code": contract_code } } + "error": { "code": code, "message": message, "data": data } })) } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index d11f2db2e0..53975e356b 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2,7 +2,7 @@ use super::client::{ client_apply_server_message_for_plugin, client_initialize_for_plugin, json_rpc_error_with_code, - json_rpc_result, + json_rpc_error_with_data, json_rpc_notification, json_rpc_result, }; use super::process::{LspProcessHandle, LspProcessLauncher, LspProcessSpec, SystemProcessLauncher}; use super::{ @@ -462,6 +462,32 @@ pub struct PluginHostFailure { /// Stable `plugin.host.*` code of the failure, from protocol version 3. #[serde(default)] pub code: Option, + /// Structured facts a backend may act on, such as the current version + /// of a `plugin.host.versionConflict`. From protocol version 3 it + /// reaches the backend as `error.data.details` when it is an object + /// within `plugin_function::MAX_HOST_DETAILS_BYTES`; otherwise it is + /// dropped and the code and message still arrive. + #[serde(default)] + pub details: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One notification from the host to a plugin backend, such as a +/// subscription event or a changed set of workspace contexts. Core admits +/// only the methods of `plugin_function::HOST_NOTIFICATIONS`. +pub struct PluginHostNotification { + pub session_id: String, + pub method: String, + pub params: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Whether a host notification was written to the backend. `false` means +/// the backend negotiated a protocol version without the notification. +pub struct PluginHostNotificationResponse { + pub delivered: bool, } #[derive(Debug, Clone, Deserialize)] @@ -1331,6 +1357,16 @@ pub fn plugin_host_response(request: PluginHostResponse) -> Result<(), CoreError .host_response(request) } +/// Sends one host notification to a plugin backend. +pub fn plugin_host_notify( + request: PluginHostNotification, +) -> Result { + let delivered = engine() + .session(&request.session_id)? + .host_notify(request)?; + Ok(PluginHostNotificationResponse { delivered }) +} + /// Invokes one plugin function on a session of the function's plugin. pub fn plugin_function_request( request: PluginFunctionRequest, @@ -2425,11 +2461,19 @@ impl RuntimeSession { } else { (super::plugin_function::codes::HOST_FAILED, -32603) }; - json_rpc_error_with_code( + let mut data = json!({ "code": code }); + if version >= super::plugin_function::CONTEXT_VERSION { + if let Some(details) = + super::plugin_function::bounded_host_details(failure.details.as_ref()) + { + data["details"] = details; + } + } + json_rpc_error_with_data( &backend_id, rpc_code, &super::plugin_function::bounded_host_message(&failure.message), - code, + data, )? } (result, None) => json_rpc_result(&backend_id, result.unwrap_or(Value::Null))?, @@ -2438,6 +2482,40 @@ impl RuntimeSession { self.send_messages_or_fail(&outbound_order, vec![message], "hostResponse") } + /// Writes one host notification to a ready plugin-owned backend. + /// Returns `false`, writing nothing, when the backend negotiated a + /// protocol version without the notification. + fn host_notify(&self, notification: PluginHostNotification) -> Result { + let outbound_order = self.lock_outbound_order()?; + let message = { + let state = self.lock_state()?; + if self.plugin_id.is_none() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Only a plugin process receives host notifications.", + )); + } + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + let delivered = super::plugin_function::host_notification( + ¬ification.method, + ¬ification.params, + state.client.plugin_function_version.unwrap_or(0), + &state.client.host_services, + )?; + if !delivered { + return Ok(false); + } + json_rpc_notification(¬ification.method, notification.params)? + }; + self.send_messages_or_fail(&outbound_order, vec![message], "hostNotify")?; + Ok(true) + } + fn request_with_kind( &self, request: SemanticRequest, @@ -13270,6 +13348,87 @@ public class Main { } } + /// A host failure's details reach a version 3 backend under + /// `error.data.details`, next to the stable code. + #[test] + fn host_failure_details_reach_a_version_3_backend() { + let mut harness = host_service_backend(3, &["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "folders", + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w1", "generation": 1 } } + })); + let host_request_id = harness + .await_event(|event| event.kind == "hostRequest") + .clone() + .result + .unwrap()["hostRequestID"] + .as_str() + .unwrap() + .to_string(); + let response: PluginHostResponse = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "hostRequestID": host_request_id, + "error": { + "message": "The context changed.", + "code": "plugin.host.contextExpired", + "details": { "generation": 2 } + } + })) + .expect("documented wire shape"); + harness + .session() + .host_response(response) + .expect("the host reports its failure"); + let reply = await_reply(&harness, "folders"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.contextExpired"); + assert_eq!( + reply["error"]["data"]["details"], + json!({ "generation": 2 }) + ); + } + + /// `lsp.hostNotify` writes an admitted notification to the backend and + /// refuses one the session was not granted. + #[test] + fn host_notifications_reach_a_version_3_backend() { + let harness = host_service_backend(3, &["workspace"]); + let notify = |method: &str, params: Value| { + let request: PluginHostNotification = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "method": method, + "params": params, + })) + .expect("documented wire shape"); + harness.session().host_notify(request) + }; + assert!(notify( + "lithe/host/workspace/didChangeContexts", + json!({ "revision": 2 }) + ) + .expect("an admitted notification")); + assert!(harness + .server + .await_notification("lithe/host/workspace/didChangeContexts")); + assert!(notify("lithe/build/run", json!({})).is_err()); + } + + /// A version 2 backend never receives a notification it cannot read. + #[test] + fn host_notifications_skip_a_version_2_backend() { + let harness = host_service_backend(2, &["workspace"]); + let request: PluginHostNotification = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "method": "lithe/host/workspace/didChangeContexts", + "params": { "revision": 2 }, + })) + .expect("documented wire shape"); + assert!(!harness + .session() + .host_notify(request) + .expect("a known notification")); + } + /// A backend that cancels its own host request is answered at once, the /// host learns which request to stop, and its late answer is refused. #[test] diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 9b5626bbee..f810a0e96a 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -88,6 +88,12 @@ const MAX_CREDENTIAL_KEY_BYTES: usize = 128; pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; /// Host requests one session may have awaiting the host's answer. pub(crate) const MAX_PENDING_HOST_REQUESTS: usize = 16; +/// Bound of the serialized `details` object of one host failure; enough +/// for a few numbers and identifiers, not for documents. +pub(crate) const MAX_HOST_DETAILS_BYTES: usize = 4 * 1024; +/// Bound of the serialized params of one host-to-backend notification, +/// the same as one function result. +pub(crate) const MAX_HOST_NOTIFICATION_BYTES: usize = MAX_RESULT_BYTES; /// Stable codes of the plugin-function contract. Synchronous refusals carry /// them in `CoreError::details`; asynchronous outcomes in the runtime error's @@ -134,12 +140,32 @@ pub(crate) mod codes { pub const HOST_AUTHORITY_UNSUPPORTED: &str = "plugin.host.authorityUnsupported"; /// A versioned write found a newer version than the one it names. pub const HOST_VERSION_CONFLICT: &str = "plugin.host.versionConflict"; - /// The request exceeds a host limit: size, count, or rate. + /// The request exceeds a host limit on size or count. Retrying the same + /// request fails again; rate limits are `HOST_RATE_LIMITED`. pub const HOST_LIMIT_EXCEEDED: &str = "plugin.host.limitExceeded"; /// The backend cancelled the request, or the host stopped it. pub const HOST_CANCELLED: &str = "plugin.host.cancelled"; /// The host's own deadline for the operation passed. pub const HOST_TIMEOUT: &str = "plugin.host.timeout"; + /// The resource the request names (a file, a document, a window) does + /// not exist or is no longer open. + pub const HOST_NOT_FOUND: &str = "plugin.host.notFound"; + /// The resource exists but may not be changed. + pub const HOST_READ_ONLY: &str = "plugin.host.readOnly"; + /// The params are well formed, but this host does not implement an + /// option they ask for. + pub const HOST_UNSUPPORTED_OPTION: &str = "plugin.host.unsupportedOption"; + /// The resource is not in the state the operation needs, for example a + /// document with unsaved changes or of another kind; the backend changes + /// the state or the request, not merely retries. + pub const HOST_PRECONDITION_FAILED: &str = "plugin.host.preconditionFailed"; + /// The host cannot serve the request at the moment, for example while + /// the user composes text or an index loads; the same request may + /// succeed later. + pub const HOST_BUSY: &str = "plugin.host.busy"; + /// The plugin made too many requests of this kind recently; the same + /// request may succeed after a while. + pub const HOST_RATE_LIMITED: &str = "plugin.host.rateLimited"; /// A page's call or a host operation ended because the backend process /// exited. Reported by hosts to pages, never sent to a backend. pub const BACKEND_EXITED: &str = "plugin.backend.exited"; @@ -487,7 +513,9 @@ pub(crate) const HOST_METHODS: &[HostMethod] = &[ /// Services every plugin-owned session of their version has without a /// permission: they reveal nothing beyond the session's own grants. fn is_implicit_service(service: &str) -> bool { - service == HOST_SERVICE_HOST || service == HOST_SERVICE_SUBSCRIPTIONS + service == HOST_SERVICE_HOST + || service == HOST_SERVICE_SUBSCRIPTIONS + || service == HOST_SERVICE_COMMANDS } /// Where a backend request for a host service goes, or why it is refused. @@ -533,19 +561,32 @@ pub(crate) fn host_request( /// The answer to `lithe/host/getCapabilities`: the negotiated version, the /// services and methods this session may call, where the backend runs, and /// the bounds Core enforces. It lists only what the session is granted, so -/// it reveals nothing about other plugins. +/// it reveals nothing about other plugins. `granted` is what the host +/// passed to `lsp.startServer.hostServices`; a host passes only services it +/// serves, so a listed method never answers `plugin.host.unavailable` for +/// want of a handler. pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> Value { + let admitted = |service: &str| is_implicit_service(service) || granted.contains(service); let mut services = serde_json::Map::new(); - for entry in HOST_METHODS.iter().filter(|entry| { - version >= entry.since - && (is_implicit_service(entry.service) || granted.contains(entry.service)) - }) { - let service = services - .entry(entry.service.to_string()) - .or_insert_with(|| json!({ "methods": [] })); - if let Some(methods) = service["methods"].as_array_mut() { - methods.push(json!(entry.method)); + let mut add = |service: &str, kind: &str, method: &str| { + let entry = services + .entry(service.to_string()) + .or_insert_with(|| json!({ "methods": [], "notifications": [] })); + if let Some(list) = entry[kind].as_array_mut() { + list.push(json!(method)); } + }; + for entry in HOST_METHODS + .iter() + .filter(|entry| version >= entry.since && admitted(entry.service)) + { + add(entry.service, "methods", entry.method); + } + for entry in HOST_NOTIFICATIONS + .iter() + .filter(|entry| version >= entry.since && admitted(entry.service)) + { + add(entry.service, "notifications", entry.method); } json!({ "protocolVersion": version, @@ -556,11 +597,156 @@ pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> V "limits": { "pendingHostRequests": MAX_PENDING_HOST_REQUESTS, "functionResultBytes": MAX_RESULT_BYTES, - "messageBytes": MAX_MESSAGE_BYTES, + "errorMessageBytes": MAX_MESSAGE_BYTES, + "errorDetailsBytes": MAX_HOST_DETAILS_BYTES, + "notificationBytes": MAX_HOST_NOTIFICATION_BYTES, }, }) } +/// How Core checks the params of one host-to-backend notification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum NotificationParams { + /// Exactly `{ revision }`, a positive integer. + Revision, + /// Exactly `{ subscriptionID, sequence, event }`: the subscription the + /// backend holds, a positive sequence that grows per subscription, and + /// the service's event object. + SubscriptionEvent, + /// `{ commandID, context? }`: one of the plugin's own commands and the + /// workspace context of the window it ran in. + Command, +} + +/// One row of the host-notification table. +pub(crate) struct HostNotification { + pub method: &'static str, + /// Service whose grant admits sending it; implicit services need none. + pub service: &'static str, + pub since: u64, + pub params: NotificationParams, +} + +const WORKSPACE_DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; +const SUBSCRIPTIONS_NOTIFY: &str = "lithe/host/subscriptions/notify"; +const COMMANDS_DID_RUN: &str = "lithe/host/commands/didRun"; +/// The plugin's own UI commands reaching its backend. Every plugin-owned +/// session of version 3 has it; a command names only the plugin's own IDs. +const HOST_SERVICE_COMMANDS: &str = "commands"; + +/// Every host-to-backend notification the host may send through +/// `lsp.hostNotify`. A notification missing here never reaches a backend, +/// so the host cannot use the channel to call into the backend's own +/// protocol (functions, build or VCS methods). +pub(crate) const HOST_NOTIFICATIONS: &[HostNotification] = &[ + HostNotification { + method: WORKSPACE_DID_CHANGE_CONTEXTS, + service: HOST_SERVICE_WORKSPACE, + since: CONTEXT_VERSION, + params: NotificationParams::Revision, + }, + HostNotification { + method: SUBSCRIPTIONS_NOTIFY, + service: HOST_SERVICE_SUBSCRIPTIONS, + since: CONTEXT_VERSION, + params: NotificationParams::SubscriptionEvent, + }, + HostNotification { + method: COMMANDS_DID_RUN, + service: HOST_SERVICE_COMMANDS, + since: CONTEXT_VERSION, + params: NotificationParams::Command, + }, +]; + +/// Whether a host notification reaches this session. `Ok(false)` means the +/// backend negotiated a version without it, so the host may skip it; a +/// method outside the table, one whose service the session was not +/// granted, or malformed params are the host's mistake and refused. +pub(crate) fn host_notification( + method: &str, + params: &Value, + version: u64, + granted: &BTreeSet, +) -> Result { + let invalid = |message: &str| { + refusal( + ErrorCode::InvalidRequest, + message, + codes::HOST_INVALID_PARAMS, + ) + }; + let Some(entry) = HOST_NOTIFICATIONS + .iter() + .find(|entry| entry.method == method) + else { + return Err(refusal( + ErrorCode::InvalidRequest, + "The host may not send this notification to a plugin process.", + codes::HOST_UNAVAILABLE, + )); + }; + if !is_implicit_service(entry.service) && !granted.contains(entry.service) { + return Err(refusal( + ErrorCode::InvalidRequest, + "The session was not granted the service of this notification.", + codes::HOST_PERMISSION_DENIED, + )); + } + let Some(object) = params.as_object() else { + return Err(invalid("Notification params must be an object.")); + }; + if serde_json::to_vec(params).map_or(true, |bytes| bytes.len() > MAX_HOST_NOTIFICATION_BYTES) { + return Err(invalid( + "Notification params exceed the notification bound.", + )); + } + let positive = |key: &str| { + object + .get(key) + .and_then(Value::as_u64) + .is_some_and(|value| value >= 1) + }; + let only = |keys: &[&str]| object.keys().all(|key| keys.contains(&key.as_str())); + let well_formed = match entry.params { + NotificationParams::Revision => only(&["revision"]) && positive("revision"), + NotificationParams::SubscriptionEvent => { + only(&["subscriptionID", "sequence", "event"]) + && object + .get("subscriptionID") + .and_then(Value::as_str) + .is_some_and(is_credential_key) + && positive("sequence") + && object.get("event").is_some_and(Value::is_object) + } + NotificationParams::Command => { + only(&["commandID", "context"]) + && object + .get("commandID") + .and_then(Value::as_str) + .is_some_and(is_command_id) + && object.get("context").map_or(true, is_context_reference) + } + }; + if !well_formed { + return Err(invalid( + "Notification params do not match the notification.", + )); + } + Ok(version >= entry.since) +} + +/// A UI command ID as the manifest schema spells it: `[a-z0-9.-]+`. +fn is_command_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_FUNCTION_ID_BYTES + && value.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '.' | '-') + }) +} + fn check_host_params(rule: HostParams, params: Option<&Value>) -> Result<(), HostRequestRefusal> { let invalid = |message| HostRequestRefusal { rpc_code: -32602, @@ -697,10 +883,30 @@ pub(crate) fn host_rpc_code(code: &str) -> i64 { codes::HOST_VERSION_CONFLICT => -32013, codes::HOST_LIMIT_EXCEEDED => -32014, codes::HOST_TIMEOUT => -32015, + codes::HOST_NOT_FOUND => -32016, + codes::HOST_READ_ONLY => -32017, + codes::HOST_UNSUPPORTED_OPTION => -32018, + codes::HOST_PRECONDITION_FAILED => -32019, + codes::HOST_BUSY => -32020, + codes::HOST_RATE_LIMITED => -32021, _ => -32603, } } +/// The `details` of a host failure a version 3 backend receives in +/// `error.data.details`: an object of at most `MAX_HOST_DETAILS_BYTES` +/// serialized, such as the current version of a `versionConflict` or the +/// index of the edit a range error names. Anything else is dropped, so a +/// host cannot inflate the reply or send a value a backend must not parse. +pub(crate) fn bounded_host_details(details: Option<&Value>) -> Option { + let details = details?; + if !details.is_object() { + return None; + } + let size = serde_json::to_vec(details).map(|bytes| bytes.len()).ok()?; + (size <= MAX_HOST_DETAILS_BYTES).then(|| details.clone()) +} + /// The code and JSON-RPC error code a version 3 backend receives when the /// host failed a forwarded request. Only the codes a host may report pass /// through; anything else, including none, is `plugin.host.failed`, so a @@ -716,6 +922,12 @@ pub(crate) fn host_failure(code: Option<&str>) -> (&'static str, i64) { Some(codes::HOST_LIMIT_EXCEEDED) => codes::HOST_LIMIT_EXCEEDED, Some(codes::HOST_CANCELLED) => codes::HOST_CANCELLED, Some(codes::HOST_TIMEOUT) => codes::HOST_TIMEOUT, + Some(codes::HOST_NOT_FOUND) => codes::HOST_NOT_FOUND, + Some(codes::HOST_READ_ONLY) => codes::HOST_READ_ONLY, + Some(codes::HOST_UNSUPPORTED_OPTION) => codes::HOST_UNSUPPORTED_OPTION, + Some(codes::HOST_PRECONDITION_FAILED) => codes::HOST_PRECONDITION_FAILED, + Some(codes::HOST_BUSY) => codes::HOST_BUSY, + Some(codes::HOST_RATE_LIMITED) => codes::HOST_RATE_LIMITED, // A backend exit is reported to pages, never to the backend itself. _ => codes::HOST_FAILED, }; @@ -1087,6 +1299,64 @@ mod tests { } } + #[test] + fn host_notifications_follow_the_fixture() { + for case in cases("hostNotifications") { + let granted: BTreeSet = case["hostServices"] + .as_array() + .unwrap() + .iter() + .map(|service| service.as_str().unwrap().to_string()) + .collect(); + let outcome = host_notification( + case["method"].as_str().unwrap(), + &case["params"], + case["version"].as_u64().unwrap(), + &granted, + ); + let expected = &case["expected"]; + match expected["code"].as_str() { + Some(code) => assert_eq!( + outcome.expect_err(name(&case)).details.as_deref(), + Some(code), + "{}", + name(&case) + ), + None => assert_eq!( + outcome.unwrap_or_else(|error| panic!("{}: {error:?}", name(&case))), + expected["delivered"].as_bool().unwrap(), + "{}", + name(&case) + ), + } + } + } + + /// Details pass only as a bounded object, so a host cannot hand a + /// backend an unbounded or untyped payload through a failure. + #[test] + fn host_failure_details_are_a_bounded_object() { + let details = json!({ "currentVersion": 7 }); + assert_eq!(bounded_host_details(Some(&details)), Some(details)); + assert_eq!(bounded_host_details(Some(&json!("text"))), None); + let oversized = json!({ "text": "x".repeat(MAX_HOST_DETAILS_BYTES) }); + assert_eq!(bounded_host_details(Some(&oversized)), None); + assert_eq!(bounded_host_details(None), None); + } + + /// An oversized notification is refused before it reaches a backend. + #[test] + fn host_notifications_are_bounded() { + let params = json!({ + "subscriptionID": "s1", + "sequence": 1, + "event": { "text": "x".repeat(MAX_HOST_NOTIFICATION_BYTES) } + }); + let refusal = host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, 3, &BTreeSet::new()) + .expect_err("the notification is too large"); + assert_eq!(refusal.details.as_deref(), Some(codes::HOST_INVALID_PARAMS)); + } + /// Capability discovery lists only what the session may call. #[test] fn capabilities_list_only_granted_services() { diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 97cb4f4d36..9a392c619a 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -340,6 +340,8 @@ pub enum CoreCommand { LspFunctionRequest, /// Answers a plugin backend's host-service request (`lsp.hostResponse`). LspHostResponse, + /// Sends a plugin backend one host notification (`lsp.hostNotify`). + LspHostNotify, /// Queues shared Java gutter marker resolution (`java.navigationMarkers`). JavaNavigationMarkers, /// Queues click-time Java navigation resolution (`java.resolveNavigation`). @@ -683,6 +685,7 @@ impl CoreCommand { "lsp.vcsRequest" => Some(Self::LspVcsRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), "lsp.hostResponse" => Some(Self::LspHostResponse), + "lsp.hostNotify" => Some(Self::LspHostNotify), "java.resolveNavigation" => Some(Self::JavaResolveNavigation), "lsp.cancelOperation" => Some(Self::LspCancelOperation), "lsp.pollEvents" => Some(Self::LspPollEvents), @@ -797,6 +800,7 @@ mod tests { "lsp.vcsRequest", "lsp.functionRequest", "lsp.hostResponse", + "lsp.hostNotify", "lsp.cancelOperation", "lsp.pollEvents", "lsp.waitEvents", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 643c16dbd7..2808fd7b70 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1883,6 +1883,21 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspHostNotify => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host notification") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::plugin_host_notify) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Host notification response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspCancelOperation => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index 08ab990e80..cbe833ad8f 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -376,17 +376,28 @@ "protocolVersion": 3, "backendAuthority": "local", "services": { - "host": { "methods": ["lithe/host/getCapabilities"] }, - "subscriptions": { "methods": ["lithe/host/subscriptions/dispose"] }, + "commands": { "methods": [], "notifications": ["lithe/host/commands/didRun"] }, + "host": { "methods": ["lithe/host/getCapabilities"], "notifications": [] }, + "subscriptions": { + "methods": ["lithe/host/subscriptions/dispose"], + "notifications": ["lithe/host/subscriptions/notify"] + }, "workspace": { "methods": [ "lithe/host/workspace/listContexts", "lithe/host/workspace/getContext", "lithe/host/workspace/getFolders" - ] + ], + "notifications": ["lithe/host/workspace/didChangeContexts"] } }, - "limits": { "pendingHostRequests": 16, "functionResultBytes": 1048576, "messageBytes": 1024 } + "limits": { + "pendingHostRequests": 16, + "functionResultBytes": 1048576, + "errorMessageBytes": 1024, + "errorDetailsBytes": 4096, + "notificationBytes": 1048576 + } } } }, @@ -544,7 +555,25 @@ { "name": "params the service itself refuses", "code": "plugin.host.invalidParams", "expected": { "code": "plugin.host.invalidParams", "rpcCode": -32602 } }, { "name": "the user withheld consent", "code": "plugin.host.permissionDenied", "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32010 } }, { "name": "a service the host turned off", "code": "plugin.host.unavailable", "expected": { "code": "plugin.host.unavailable", "rpcCode": -32601 } }, + { "name": "the target does not exist", "code": "plugin.host.notFound", "expected": { "code": "plugin.host.notFound", "rpcCode": -32016 } }, + { "name": "the target cannot be written", "code": "plugin.host.readOnly", "expected": { "code": "plugin.host.readOnly", "rpcCode": -32017 } }, + { "name": "an option this host does not support", "code": "plugin.host.unsupportedOption", "expected": { "code": "plugin.host.unsupportedOption", "rpcCode": -32018 } }, + { "name": "a state the operation needs is missing", "code": "plugin.host.preconditionFailed", "expected": { "code": "plugin.host.preconditionFailed", "rpcCode": -32019 } }, + { "name": "the host is busy and the request may be retried", "code": "plugin.host.busy", "expected": { "code": "plugin.host.busy", "rpcCode": -32020 } }, + { "name": "the plugin sent requests too often", "code": "plugin.host.rateLimited", "expected": { "code": "plugin.host.rateLimited", "rpcCode": -32021 } }, { "name": "a host cannot borrow a function code", "code": "plugin.function.unknown", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } }, { "name": "a host cannot report a backend exit to the backend", "code": "plugin.backend.exited", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } } + ], + "hostNotifications": [ + { "name": "changed contexts reach a workspace session", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "delivered": true } }, + { "name": "a version 2 backend is skipped", "version": 2, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "delivered": false } }, + { "name": "changed contexts need the workspace grant", "version": 3, "hostServices": [], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "code": "plugin.host.permissionDenied" } }, + { "name": "a revision is positive", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a subscription event", "version": 3, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": { "kind": "changed" } }, "expected": { "delivered": true } }, + { "name": "a subscription event is an object", "version": 3, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": "changed" }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a command run in a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "context": { "contextID": "w1", "generation": 2 } }, "expected": { "delivered": true } }, + { "name": "a command without a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh" }, "expected": { "delivered": true } }, + { "name": "extra command params are refused", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "path": "/etc" }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "the host cannot call the backend's own protocol", "version": 3, "hostServices": [], "method": "lithe/functions/invoke", "params": {}, "expected": { "code": "plugin.host.unavailable" } } ] } From 33f7f6f3489f298651ec9473f8bd5f8aac5d4a1e Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 13:54:34 +0800 Subject: [PATCH 12/16] feat(macos): push host notifications to plugin backends and tighten workspace contexts - Push lithe/host/workspace/didChangeContexts to running backends granted workspace on every context open, change, or close. - Deliver lithe/host/commands/didRun to a backend 2 plugin when its command runs: at once when ready, after the start when starting, and only on its own onCommand: when stopped. - Send subscription events with a per-subscription sequence through lithe/host/subscriptions/notify; notifications to one backend stay in order and are cancelled with the session. - Start onWorkspaceOpen backends on enable or register when a workspace is already open. - Grant a backend only the host services this host has handlers for, so getCapabilities lists nothing that fails for want of one. - Switching a window to another workspace closes its context and opens one with a new contextID; generation counts changes of the same workspace. - Make rootID an HMAC keyed per plugin with a host secret kept in Application Support/Lithe/PluginHost/root-id.key (0600, created once). - listContexts no longer returns or creates stateDirectory; getContext does. - Add notFound, readOnly, unsupportedOption, preconditionFailed, busy and rateLimited host error codes and structured failure details. - Fix the dispose error message, document the protocol 3 notifications, details, error-code range, and context semantics in the contract, the Rust Core API, the Agent Note, and the feature matrix. --- ...-10-30-plugin-backend-workspace-context.md | 49 +++++-- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Features/PluginHostServiceDispatch.swift | 134 +++++++++++++----- .../Features/PluginWorkspaceContexts.swift | 76 +++++++--- .../Lithe/Core/Rust/RustCoreBridge.swift | 30 +++- .../RustLanguageServerRuntimeAdapter.swift | 11 +- .../AppModel/AppModel+UIContributions.swift | 7 +- .../Platform/MacOS/MacServiceContainer.swift | 3 +- .../Plugins/MacPluginBackendSessions.swift | 101 ++++++++++++- .../MacOS/Plugins/MacPluginRootIDSecret.swift | 53 +++++++ .../LanguageServerRuntimeContracts.swift | 27 +++- .../Runtime/LanguageServerSession.swift | 19 +++ .../MacPluginBackendSessionsTests.swift | 98 ++++++++++++- .../PluginHostServiceDispatchTests.swift | 111 +++++++++++++-- shared/contracts/plugins/v1/README.md | 116 +++++++++++---- shared/contracts/rust-core-api.md | 16 ++- shared/i18n/locales/en-US.json | 1 + shared/i18n/locales/zh-CN.json | 1 + shared/i18n/messages.json | 16 +++ .../plugin-backend-workspace-context.json | 10 +- 21 files changed, 751 insertions(+), 132 deletions(-) create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRootIDSecret.swift diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md index 893c152b54..12a3394992 100644 --- a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md @@ -25,7 +25,7 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 - 只认识 `[1]` 的旧宿主会以 `plugin.api.unsupported` 拒绝 v2 清单,因此旧宿主不会误把 v2 当 v1 运行。 - v2 至少要有一种启动方式:网页工具窗口、`ui.commands[]`,或 `onWorkspaceOpen`。三者都没有时清单无效。 - v2 可以是整个插件的全部内容,不需要同时声明语言或 UI。 -- 函数协议在 Core 里协商出 `[1, 2, 3]`。版本 3 加入的内容:能力发现、订阅释放、工作区上下文、调用上下文、带错误码的宿主失败、宿主请求取消。backend 1 的插件也能协商到协议 3,网页调用的行为不变。 +- 函数协议在 Core 里协商出 `[1, 2, 3]`。版本 3 加入的内容:能力发现、订阅释放、工作区上下文、调用上下文、带错误码和 details 的宿主失败、宿主请求取消、宿主到后端的通知。backend 1 的插件也能协商到协议 3,网页调用的行为不变。 ### 共享进程、隔离调用上下文,而不是每个工作区一个会话 @@ -45,13 +45,17 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 | --- | --- | --- | --- | | 插件 ID | 安装记录 | 安装期间 | 卸载即消失 | | 后端会话与授权(hostServices) | Rust 启动计划 + `dispatcher.beginSession` | 一个进程 | 停止、禁用、替换包、崩溃后重启时 `endSession`,未完成的宿主请求不再回答,订阅全部释放 | -| contextID | `PluginWorkspaceContexts`,形如 `w-` | 窗口显示该工作区期间;同一窗口换工作区时 ID 不变 | 窗口关闭工作区、切到单文件或关窗即关闭 | -| generation | 同上,从 1 开始 | 文件夹或 authority 每变一次加 1 | 旧 generation 一律 `plugin.host.contextExpired`,后端需要重新获取 | -| rootID | `"r"` + 位置 SHA-256 的前 16 位十六进制 | 同一位置跨重启保持稳定 | 不含路径,不能当作授权凭证 | +| contextID | `PluginWorkspaceContexts`,形如 `w-` | 窗口显示同一个工作区期间;同一窗口切到另一个工作区(状态 digest 不同)时,旧上下文关闭、发新 ID | 窗口关闭工作区、切到单文件、换工作区或关窗即关闭 | +| generation | 同上,从 1 开始 | 同一工作区的文件夹或 authority 每变一次加 1 | 旧 generation 一律 `plugin.host.contextExpired`,后端需要重新获取 | +| rootID | `"r"` + HMAC-SHA256(宿主密钥, 插件 ID + `\0` + 位置) 的前 16 位十六进制 | 同一插件、同一位置跨重启保持稳定;不同插件看到的 ID 不同 | 不含路径,不能当作授权凭证;删除密钥文件只会让所有 rootID 换新 | | subscriptionID | `PluginHostSubscriptions`,形如 `s-` | 直到被 dispose、上下文关闭或会话结束 | 未知 ID 的 dispose 也返回成功 | | hostRequestID | Core | 一次宿主请求 | 后端用 `$/cancelRequest` 取消后,Core 立即以 `-32800` 回答;宿主停止工作,之后的回答会被 Core 拒收 | -宿主对每个请求再做一次核验:服务是否在该会话的授权里(隐式服务 `host`、`subscriptions` 除外),以及参数里的上下文是否仍是当前 generation。这一步和 Core 做的检查是重复的,属于纵深防御。 +宿主对每个请求再做一次核验:服务是否在该会话的授权里(隐式服务 `host`、`subscriptions`、`commands` 除外),以及参数里的上下文是否仍是当前 generation。这一步和 Core 做的检查是重复的,属于纵深防御。 + +换工作区发新 contextID,而不是沿用 ID 只加 generation,是因为后端常按 contextID 缓存连接、索引和登录态。沿用 ID 会让后端把 A 工程的缓存带进 B 工程;generation 只表达“同一个工作区的形状变了”。 + +rootID 用按插件加密钥的 HMAC,而不是位置的普通哈希:普通哈希可以由猜到的路径直接算出,也能让两个插件通过协议互相对上同一个文件夹。密钥放在 `Application Support/Lithe/PluginHost/root-id.key`(`MacPluginRootIDSecret`),首次使用时以 0600 和 `O_EXCL` 创建、之后不再改写;它在平台可写目录里,不在 app bundle、插件包或任何插件的 `${stateDir}` 中,不影响代码签名和 Sparkle 增量包。目录不可写时只用本次启动的随机密钥,rootID 仍唯一,只是不跨重启稳定。后端以用户身份运行,能读用户文件的进程本来就能读到密钥,这一层只防“从协议推出路径或跨插件关联”。 ### Authority(工作区在哪台机器上) @@ -64,13 +68,14 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 - 全局状态是 `${stateDir}`,即 `Application Support/Lithe/PluginState//backend`。 - 工作区状态是 `${stateDir}/workspaces/`。digest 对本地工作区取路径的 SHA-256,对远程工作区取 `remote:<连接ID>:<远端路径>` 的 SHA-256,目录名里不出现用户路径。 -- 工作区状态目录在第一次 `getContext` 或 `listContexts` 时才创建。两种状态都在平台可写目录里,不在 app bundle 或插件包内,所以不影响代码签名和 Sparkle 增量包的基线。 +- 工作区状态目录只在 `getContext` 时创建,`stateDirectory` 也只出现在 `getContext` 的结果里;`listContexts` 的条目不带它、不建任何目录,避免后端列一次上下文就给每个打开的窗口都留下空目录。两种状态都在平台可写目录里,不在 app bundle 或插件包内,所以不影响代码签名和 Sparkle 增量包的基线。 - 禁用和更新插件时保留这些目录。卸载时随插件目录一起删除,沿用 `MacPluginStateDirectories.pluginDirectory` 的现有清理。 ### 激活事件与启动预算 - `onWorkspaceOpen`:窗口打开工作区后,在下一个主线程任务里启动,不阻塞打开流程。多个插件按 ID 排序后串行启动,一次只启动一个。每个插件沿用 60 秒的 initialize 超时,超时或失败只记日志(`plugins.backend`),不会挡住工作区。 -- `onCommand:`:在 `performToolWindowContribution` 里,命令贡献的 `actionID` 就是命令 ID;只启动所属 UI 模块为 `.ui` 的那个插件。窗口照常打开,不等待后端。 +- `onCommand:`:在 `performToolWindowContribution` 里,命令贡献的 `actionID` 就是命令 ID;只启动所属 UI 模块为 `.ui` 的那个插件。窗口照常打开,不等待后端。命令本身经 `lithe/host/commands/didRun { commandID, context? }` 送到 `backend: 2` 的后端:已就绪的后端立即按通知顺序收到;正在启动的排在启动之后;未运行的只有声明了该命令的 `onCommand:` 才会被启动并在就绪后收到。`backend: 1` 不收命令通知。 +- 热启用:`enable` 和 `register` 时如果已经有工作区打开,声明 `onWorkspaceOpen` 的插件立即启动,等同于工作区刚打开,不必等下一个窗口。新安装带后端的包仍需重启(`canActivateWithoutRestart`),这一点不在本次范围。 - 不接受 `onStartupFinished` 和 `*`。“应用一启动就跑”的后端没有用户可感知的触发点,会拖慢所有用户的启动。 - 激活不以信任状态为前提。只要插件已安装、已启用、经过安装审查,激活事件就会生效。 @@ -81,8 +86,20 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 ### 宿主服务分发扩展点 - 每个切片实现 `@MainActor protocol PluginHostServiceHandling`(属性 `service`,方法 `answer(_ call: PluginHostCall) async -> Result`),在组合根 `MacServiceContainer` 里用 `hostDispatcher.register(_:)` 注册。 -- `PluginHostServiceError.code` 用 `PluginHostErrorCode` 表示:未支持、未授权、上下文失效、authority 不支持、版本冲突、参数错误、超限、取消、超时、后端退出、其他失败。 -- Core 只把白名单里的错误码透传给协议 3 的后端,其余一律变成 `plugin.host.failed`。 +- `PluginHostServiceError.code` 用 `PluginHostErrorCode` 表示:未支持、未授权、上下文失效、authority 不支持、版本冲突、参数错误、超限、取消、超时、不存在(notFound)、只读(readOnly)、不支持的选项(unsupportedOption)、前置条件不满足(preconditionFailed)、忙(busy)、限流(rateLimited)、后端退出、其他失败。 +- Core 只把白名单里的错误码透传给协议 3 的后端,每个码对应自己的 JSON-RPC 数值(`-32010` 起逐个加 1,一码一义),其余一律变成 `plugin.host.failed`。`-32000`~`-32009` 留给后端自己的失败,因为很多 JSON-RPC 库从 `-32000` 开始用。 +- `PluginHostServiceError.details` 是可选的结构化对象(如 `currentVersion`、`retryAfterMilliseconds`),经 `lsp.hostResponse.error.details` 到 Core,序列化后不超过 4 KiB、且必须是对象才会以 `error.data.details` 送到协议 3 的后端,否则丢弃。 +- 宿主只授予自己真正注册了 handler 的服务:启动时把清单的 `hostServices` 与 `hostDispatcher.registeredServices` 取交集,再交给 Core。这样 `getCapabilities` 列出的方法都有人回答,不会出现“列出来但调用失败”。 + +### 宿主到后端的通知 + +Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ delivered }`。只允许三种方法,Core 按白名单、授权和参数格式检查,协议低于 3 的会话返回 `delivered: false` 不发送: + +- `lithe/host/workspace/didChangeContexts { revision }`:需要 `workspace` 授权。`PluginWorkspaceContexts.observeRevision` 在每次打开、变化、关闭时触发一次,`MacPluginBackendSessions` 推给所有已授权的运行中后端;后端自己再调 `listContexts`。 +- `lithe/host/subscriptions/notify { subscriptionID, sequence, event }`:`PluginHostSubscriptions.notify` 为每个订阅单独递增 `sequence`(从 1 起),已释放的订阅不再发送。服务通过 dispatcher 的 `subscriptions` 发事件,不直接找会话。 +- `lithe/host/commands/didRun { commandID, context? }`:见上面的 `onCommand`。 + +宿主按插件串行发送通知(每个运行中会话一个 Task 链),停止会话时取消未发出的通知,保证同一后端收到的顺序与宿主发出的顺序一致。参数序列化后不超过 1 MiB(`limits.notificationBytes`)。 ### 工作区信任 @@ -102,9 +119,9 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 - 凭据服务的规则不变。 - 进程分离不等于沙盒:原生后端本来就拥有用户的 OS 权限,宿主能做的只是拒绝代理越权的请求。 - **4.3 异步与预算。** - - 宿主请求最多同时挂起 16 个(第 17 个返回 `limitExceeded`),单个结果不超过 1 MiB,错误消息不超过 1024 字节。这些上限通过 `getCapabilities.limits` 公布。 + - 宿主请求最多同时挂起 16 个(第 17 个返回 `limitExceeded`),单个结果不超过 1 MiB,错误消息不超过 1024 字节,错误 details 不超过 4 KiB,通知参数不超过 1 MiB。这些上限通过 `getCapabilities.limits`(`pendingHostRequests`、`functionResultBytes`、`errorMessageBytes`、`errorDetailsBytes`、`notificationBytes`)公布;`messageBytes` 改名为 `errorMessageBytes`,避免被误解为整条消息的上限。 - 后端可以取消自己的宿主请求。会话结束时,所有进行中的宿主任务都会被取消。 - - 订阅的创建方是服务,释放方是 dispatcher(dispose、上下文关闭、会话结束三条路径)。`listContexts.revision` 让后端判断工作区列表是否变了,需要重新获取快照。 + - 订阅的创建方是服务,释放方是 dispatcher(dispose、上下文关闭、会话结束三条路径)。`listContexts.revision` 让后端判断工作区列表是否变了,需要重新获取快照;`didChangeContexts` 在变化时主动通知,后端不必轮询。 - **4.4 编辑器一致性。** M0 不涉及文档内容。编辑器读取和带版本编辑由 API-09/10 切片通过同一扩展点接入,版本冲突用 `plugin.host.versionConflict`。 ### 威胁模型 @@ -116,7 +133,8 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 | 插件 A 读取插件 B 的状态或订阅 | 状态目录按插件 ID 分开,订阅按插件 ID 登记;凭据按插件 ID 隔离 | | 远程工作区被当作本地读写 | 远程上下文不带路径;文件和进程服务必须返回 `authorityUnsupported` | | 慢后端或恶意后端堆积宿主请求 | 最多 16 个挂起,可取消,会话结束时全部清理 | -| 路径从状态目录名或 rootID 泄露 | 两者都只用 SHA-256 摘要 | +| 路径从状态目录名或 rootID 泄露 | 状态目录名只用 SHA-256 摘要;rootID 用按插件加宿主密钥的 HMAC,既推不出路径,也不能跨插件对上同一文件夹 | +| 换工作区后后端把旧工作区的缓存用到新工作区 | 换工作区即换 contextID,旧 ID 立即 `contextExpired` | | 后端退出后,宿主仍把结果送给新进程 | 新进程启动前先 `endSession`;旧任务被取消,不再回答 | | 原生后端绕过宿主直接读盘或联网 | 不在本设计防护范围内;由安装审查和用户的 OS 权限承担 | @@ -141,7 +159,8 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 - 后端必须自己处理 `contextExpired`,重新获取上下文后再试。 - 在 v2 里 `${workspaceStateDir}` 不可用,插件作者需要从上下文对象里取状态目录。 -- 每加一个新服务,都要同时更新 Core 的 `HOST_METHODS` 和契约 README。 +- 每加一个新服务,都要同时更新 Core 的 `HOST_METHODS`(以及有通知时的 `HOST_NOTIFICATIONS`)和契约 README。 +- 后端拿到 `listContexts` 后,要用 `getContext` 才能得到某个工作区的 `stateDirectory`。 - `onWorkspaceOpen` 后端串行启动。插件多的时候,最后一个插件就绪得较晚,但这不影响工作区本身。 ## 验证 @@ -156,8 +175,10 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 - `capabilities_list_only_granted_services` - `grantable_services_have_methods` - `workspace_backend_is_planned_with_its_version_and_workspace_service` + - `host_notifications_follow_the_fixture`、`host_notifications_are_bounded`、`host_notifications_reach_a_version_3_backend`、`host_notifications_skip_a_version_2_backend` + - `host_failure_details_are_a_bounded_object`、`host_failure_details_reach_a_version_3_backend` - 以及 `fixtures/invalid-manifests.json` 新增的 6 个拒绝用例 -- `./scripts/macos/test/test-macos.sh --filter "PluginHostServiceDispatchTests|MacPluginBackendSessionsTests|PluginWebBridgeTests"`:上下文 generation、rootID、授权核验、本地与远程描述、过期与格式错误、订阅的三条释放路径、错误码透传。M0 提交时尚未在本机编译运行,合入前必须跑通。 +- `./scripts/macos/test/test-macos.sh --filter "PluginHostServiceDispatchTests|MacPluginBackendSessionsTests|PluginWebBridgeTests"`:上下文 generation 与换工作区发新 ID、按插件的 rootID 与密钥文件、`listContexts` 不建目录、授权核验、本地与远程描述、过期与格式错误、订阅的三条释放路径与 sequence、错误码与 details 透传、命令和订阅通知按顺序送达运行中的后端、`backend: 1` 与未运行的后端不收命令。 - `zsh scripts/architecture/verify-shared-contracts.sh`:契约 schema、fixture 与 README。 - `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`:运行时不向发行目录写入。 - 合入前,在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本仓库路径> --ref <分支>`,确认 Node 和 Java SDK 的示例后端在协议 3 下仍能跑通。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 8483e54750..0e0d8efffd 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3914,6 +3914,7 @@ "The official plugin package signature is missing." = "The official plugin package signature is missing."; "The official plugin signing key is unavailable." = "The official plugin signing key is unavailable."; "The params of %@ must be an object." = "The params of %@ must be an object."; +"The params of %@ must name a subscriptionID." = "The params of %@ must name a subscriptionID."; "The patch changes the base commit's tree into the target commit's tree." = "The patch changes the base commit's tree into the target commit's tree."; "The plugin archive expands beyond the allowed size: %@" = "The plugin archive expands beyond the allowed size: %@"; "The plugin archive is larger than %@ MB." = "The plugin archive is larger than %@ MB."; @@ -8548,6 +8549,7 @@ "native.theOfficialPluginPackageSignatureIsMissing" = "The official plugin package signature is missing."; "native.theOfficialPluginSigningKeyIsUnavailable" = "The official plugin signing key is unavailable."; "native.theParamsOfMustBeAnObject" = "The params of %1$@ must be an object."; +"native.theParamsOfMustNameASubscriptionID" = "The params of %1$@ must name a subscriptionID."; "native.thePatchChangesTheBaseCommitSTreeIntoTheTargetCommitSTree" = "The patch changes the base commit's tree into the target commit's tree."; "native.thePluginArchiveExpandsBeyondTheAllowedSize" = "The plugin archive expands beyond the allowed size: %1$@"; "native.thePluginArchiveIsLargerThanLldMb" = "The plugin archive is larger than %1$@ MB."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index da9df01c4f..4254bd47b4 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3914,6 +3914,7 @@ "The official plugin package signature is missing." = "缺少官方插件包签名。"; "The official plugin signing key is unavailable." = "官方插件签名密钥不可用。"; "The params of %@ must be an object." = "%@ 的参数必须是对象。"; +"The params of %@ must name a subscriptionID." = "%@ 的参数必须指明 subscriptionID。"; "The patch changes the base commit's tree into the target commit's tree." = "补丁将基准提交的文件内容变为目标提交的文件内容。"; "The plugin archive expands beyond the allowed size: %@" = "插件压缩包解压后超出允许的大小:%@"; "The plugin archive is larger than %@ MB." = "插件压缩包超过 %@ MB。"; @@ -8548,6 +8549,7 @@ "native.theOfficialPluginPackageSignatureIsMissing" = "缺少官方插件包签名。"; "native.theOfficialPluginSigningKeyIsUnavailable" = "官方插件签名密钥不可用。"; "native.theParamsOfMustBeAnObject" = "%1$@ 的参数必须是对象。"; +"native.theParamsOfMustNameASubscriptionID" = "%1$@ 的参数必须指明 subscriptionID。"; "native.thePatchChangesTheBaseCommitSTreeIntoTheTargetCommitSTree" = "补丁将基准提交的文件内容变为目标提交的文件内容。"; "native.thePluginArchiveExpandsBeyondTheAllowedSize" = "插件压缩包解压后超出允许的大小:%1$@"; "native.thePluginArchiveIsLargerThanLldMb" = "插件压缩包超过 %1$@ MB。"; diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift index 4b9e0a42a4..198c7e583c 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift @@ -10,6 +10,10 @@ protocol PluginBackendActivating: AnyObject { /// Returns at once; the backends start in the background. `owner` /// limits `onCommand:` to the plugin whose UI module declares it. func activate(event: String, owner: ModuleID?) + /// The user ran one of the owner plugin's commands: its `backend` 2 + /// starts when it declares `onCommand:`, and the running backend + /// receives `lithe/host/commands/didRun` with the window's context. + func runCommand(_ commandID: String, owner: ModuleID, context: PluginWorkspaceContextReference?) } /// How a host service failed, as a function protocol 3 backend sees it. @@ -27,8 +31,22 @@ enum PluginHostErrorCode: String, Sendable, CaseIterable { /// The resource changed since the version the request named. case versionConflict = "plugin.host.versionConflict" case invalidParams = "plugin.host.invalidParams" - /// Too large, too many, or too often. + /// Too large or too many; too often is `.rateLimited`. case limitExceeded = "plugin.host.limitExceeded" + /// The resource the request names does not exist. + case notFound = "plugin.host.notFound" + /// The resource exists but may not be written. + case readOnly = "plugin.host.readOnly" + /// The method exists but this host does not support an option it got. + case unsupportedOption = "plugin.host.unsupportedOption" + /// A state the operation needs does not hold, such as a missing parent + /// directory or a resource that already exists. + case preconditionFailed = "plugin.host.preconditionFailed" + /// The host cannot serve the request right now; the same request may + /// succeed when retried later. + case busy = "plugin.host.busy" + /// The plugin sent requests of this kind too often. + case rateLimited = "plugin.host.rateLimited" case cancelled = "plugin.host.cancelled" case timeout = "plugin.host.timeout" /// The backend went away while the host worked on its request. @@ -41,14 +59,18 @@ enum PluginHostErrorCode: String, Sendable, CaseIterable { struct PluginHostServiceError: Error, Equatable, Sendable { let code: PluginHostErrorCode let message: String + /// Facts the backend may act on, such as `{ currentVersion }` of a + /// version conflict. The same rule as the message applies. + let details: [String: ToolingJSONValue]? - init(_ code: PluginHostErrorCode, _ message: String) { + init(_ code: PluginHostErrorCode, _ message: String, details: [String: ToolingJSONValue]? = nil) { self.code = code self.message = message + self.details = details } var failure: PluginHostRequestFailure { - PluginHostRequestFailure(message: message, code: code.rawValue) + PluginHostRequestFailure(message: message, code: code.rawValue, details: details.map { .object($0) }) } } @@ -92,7 +114,24 @@ final class PluginHostSubscriptions { } private var entries: [String: [String: Entry]] = [:] + private var sequences: [String: Int] = [:] private var nextNumber = 1 + /// Sends `lithe/host/subscriptions/notify` to the plugin's backend. + var deliver: (@MainActor (_ pluginID: String, _ params: ToolingJSONValue) -> Void)? + + /// Sends one event of a live subscription to its backend, with the next + /// sequence number of that subscription. An event of a disposed + /// subscription is dropped. + func notify(pluginID: String, subscriptionID: String, event: [String: ToolingJSONValue]) { + guard entries[pluginID]?[subscriptionID] != nil else { return } + let sequence = (sequences[subscriptionID] ?? 0) + 1 + sequences[subscriptionID] = sequence + deliver?(pluginID, .object([ + "subscriptionID": .string(subscriptionID), + "sequence": .integer(sequence), + "event": .object(event), + ])) + } /// Records a subscription and returns its ID for the backend. func add(pluginID: String, contextID: String?, dispose: @escaping @MainActor () -> Void) -> String { @@ -108,12 +147,17 @@ final class PluginHostSubscriptions { /// Unknown and already disposed IDs succeed, as the contract says. func dispose(pluginID: String, subscriptionID: String) { - entries[pluginID]?.removeValue(forKey: subscriptionID)?.dispose() + guard let entry = entries[pluginID]?.removeValue(forKey: subscriptionID) else { return } + sequences[subscriptionID] = nil + entry.dispose() } func disposeAll(pluginID: String) { guard let removed = entries.removeValue(forKey: pluginID) else { return } - for entry in removed.values { entry.dispose() } + for (id, entry) in removed { + sequences[id] = nil + entry.dispose() + } } func disposeAll(contextID: String) { @@ -121,6 +165,7 @@ final class PluginHostSubscriptions { guard let owned = entries[pluginID] else { continue } for (id, entry) in owned where entry.contextID == contextID { entries[pluginID]?[id] = nil + sequences[id] = nil entry.dispose() } } @@ -134,7 +179,9 @@ final class PluginHostSubscriptions { @MainActor final class PluginHostServiceDispatcher { /// Services every plugin-owned session may call without a grant. - static let implicitServices: Set = ["host", "subscriptions"] + /// `commands` has no methods: its notification brings the plugin's own + /// commands to its backend. + static let implicitServices: Set = ["host", "subscriptions", "commands"] let contexts: PluginWorkspaceContexts let subscriptions = PluginHostSubscriptions() @@ -218,7 +265,7 @@ final class PluginHostServiceDispatcher { if request.method == "lithe/host/subscriptions/dispose" { guard case .string(let id)? = params["subscriptionID"] else { return .failure(PluginHostServiceError( - .invalidParams, L10n.string("The params of \(request.method) must be an object.") + .invalidParams, L10n.string("The params of \(request.method) must name a subscriptionID.") ).failure) } subscriptions.dispose(pluginID: pluginID, subscriptionID: id) @@ -280,27 +327,39 @@ final class PluginCredentialHostService: PluginHostServiceHandling { } /// `workspace`: the contexts a backend works in (`workspace.metadata`). -/// Paths appear only for a local authority; a context's state directory is -/// private to the plugin and created on first use, outside the bundle. +/// Paths appear only for a local authority. A context's state directory is +/// private to the plugin, outside the bundle, and created by `getContext` +/// only: listing the contexts creates nothing, so a backend that never +/// works in a workspace leaves no directory for it. @MainActor final class PluginWorkspaceHostService: PluginHostServiceHandling { let service = "workspace" private let contexts: PluginWorkspaceContexts private let backendStateDirectory: (String) -> URL + private let rootIDSecret: () -> Data + private lazy var rootIDs = PluginWorkspaceRootIDs(secret: rootIDSecret()) private let fileManager: FileManager /// `backendStateDirectory` gives a plugin's `${stateDir}`, which must be /// a platform-writable directory; context state lives below it. + /// `rootIDSecret` is read once, for the first described folder; it must + /// stay the same across launches for root IDs to stay stable. init( contexts: PluginWorkspaceContexts, backendStateDirectory: @escaping (String) -> URL, + rootIDSecret: @escaping () -> Data, fileManager: FileManager = .default ) { self.contexts = contexts self.backendStateDirectory = backendStateDirectory + self.rootIDSecret = rootIDSecret self.fileManager = fileManager } + func rootID(of folder: PluginWorkspaceContext.Folder, pluginID: String) -> String { + rootIDs.rootID(of: folder.location, pluginID: pluginID) + } + func stateDirectory(of context: PluginWorkspaceContext, pluginID: String) -> URL { backendStateDirectory(pluginID) .appendingPathComponent("workspaces", isDirectory: true) @@ -310,20 +369,29 @@ final class PluginWorkspaceHostService: PluginHostServiceHandling { func answer(_ call: PluginHostCall) async -> Result { switch call.request.method { case "lithe/host/workspace/listContexts": - var described: [ToolingJSONValue] = [] - for context in contexts.contexts { - switch describe(context, pluginID: call.pluginID) { - case .success(let value): described.append(value) - case .failure(let error): return .failure(error) - } - } - return .success(.object(["revision": .integer(contexts.revision), "contexts": .array(described)])) + let described = contexts.contexts.map { describe($0, pluginID: call.pluginID) } + return .success(.object([ + "revision": .integer(contexts.revision), + "contexts": .array(described.map { .object($0) }), + ])) case "lithe/host/workspace/getContext": guard let context = call.context else { return .failure(Self.missingContext) } - return describe(context, pluginID: call.pluginID) + let directory = stateDirectory(of: context, pluginID: call.pluginID) + do { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return .failure(PluginHostServiceError( + .failed, L10n.string("The host could not create the plugin's workspace state directory.") + )) + } + var object = describe(context, pluginID: call.pluginID) + object["stateDirectory"] = .string(directory.path) + return .success(.object(object)) case "lithe/host/workspace/getFolders": guard let context = call.context else { return .failure(Self.missingContext) } - return .success(.object(["folders": .array(context.folders.map { Self.describe($0, authority: context.authority) })])) + return .success(.object(["folders": .array(context.folders.map { + describe($0, authority: context.authority, pluginID: call.pluginID) + })])) default: return .failure(PluginHostServiceError( .unavailable, L10n.string("The host does not serve \(call.request.method).") @@ -335,35 +403,31 @@ final class PluginWorkspaceHostService: PluginHostServiceHandling { PluginHostServiceError(.invalidParams, L10n.string("The workspace context reference is malformed.")) } - private func describe(_ context: PluginWorkspaceContext, pluginID: String) -> Result { - let directory = stateDirectory(of: context, pluginID: pluginID) - do { - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - } catch { - return .failure(PluginHostServiceError( - .failed, L10n.string("The host could not create the plugin's workspace state directory.") - )) - } + /// A context without its state directory, which only `getContext` gives. + private func describe(_ context: PluginWorkspaceContext, pluginID: String) -> [String: ToolingJSONValue] { let authority: [String: ToolingJSONValue] switch context.authority { case .local: authority = ["kind": .string("local")] case .remote(let id): authority = ["kind": .string("remote"), "id": .string(id)] } - return .success(.object([ + return [ "contextID": .string(context.contextID), "generation": .integer(context.generation), "name": .string(context.name), "authority": .object(authority), - "folders": .array(context.folders.map { Self.describe($0, authority: context.authority) }), - "stateDirectory": .string(directory.path), - ])) + "folders": .array(context.folders.map { describe($0, authority: context.authority, pluginID: pluginID) }), + ] } - private static func describe( + private func describe( _ folder: PluginWorkspaceContext.Folder, - authority: PluginWorkspaceContext.Authority + authority: PluginWorkspaceContext.Authority, + pluginID: String ) -> ToolingJSONValue { - var object: [String: ToolingJSONValue] = ["rootID": .string(folder.rootID), "name": .string(folder.name)] + var object: [String: ToolingJSONValue] = [ + "rootID": .string(rootID(of: folder, pluginID: pluginID)), + "name": .string(folder.name), + ] // The local directory of a remote workspace is a placeholder. if authority == .local { object["path"] = .string(folder.localURL.path) } return .object(object) diff --git a/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift b/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift index b899126734..82e8afd3a9 100644 --- a/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift +++ b/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift @@ -6,9 +6,11 @@ import LitheModuleAPI // Note: 插件后端的工作区上下文、身份与存储范围的决策见 .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md /// One open workspace in one window, as plugin backends see it (function -/// protocol 3). `generation` grows whenever the window's folders or -/// authority change, so a backend holding an older one learns that its -/// view is stale (`plugin.host.contextExpired`) instead of acting on it. +/// protocol 3). The context ID lives as long as the window shows that +/// workspace; another workspace in the window is another context. +/// `generation` grows when the same workspace's folders or authority +/// change, so a backend holding an older one learns that its view is stale +/// (`plugin.host.contextExpired`) instead of acting on it. struct PluginWorkspaceContext: Equatable, Sendable { /// Where the workspace's files and processes are. Only `local` is /// served by file and process services today; they refuse `remote` @@ -20,8 +22,10 @@ struct PluginWorkspaceContext: Equatable, Sendable { } struct Folder: Equatable, Sendable { - /// Stable for the same location across restarts, without the path. - let rootID: String + /// The folder's location, `stateKey`-style: the local path, or the + /// connection and remote path. Backends see it only as a per-plugin + /// `rootID` (`PluginWorkspaceRootIDs`). + let location: String let name: String /// The folder on this Mac. For a remote workspace it is the empty /// placeholder, never the workspace: services must not read it. @@ -56,6 +60,7 @@ final class PluginWorkspaceContexts { private var contextsByOwner: [ObjectIdentifier: PluginWorkspaceContext] = [:] private var closeObservers: [UUID: @MainActor (String) -> Void] = [:] private var openObservers: [UUID: @MainActor (PluginWorkspaceContext) -> Void] = [:] + private var revisionObservers: [UUID: @MainActor (Int) -> Void] = [:] private var nextContextNumber = 1 init() {} @@ -83,8 +88,9 @@ final class PluginWorkspaceContexts { } /// Opens or replaces the window's context. The same workspace again - /// keeps the context unchanged; another one keeps the context ID with - /// the next generation, and closes what the old one owned. + /// keeps the context unchanged, or moves it to the next generation when + /// its folders or authority changed. Another workspace closes the old + /// context, and what it owned, and opens a new context ID. @discardableResult func open(owner: AnyObject, rootURL: URL, remote: (connectionID: String, remoteRoot: String)?) -> PluginWorkspaceContext { let root = rootURL.standardizedFileURL @@ -101,23 +107,25 @@ final class PluginWorkspaceContexts { name = root.lastPathComponent } let folder = PluginWorkspaceContext.Folder( - rootID: Self.rootID(stateKey), name: name, localURL: root, remotePath: remote?.remoteRoot + location: stateKey, name: name, localURL: root, remotePath: remote?.remoteRoot ) let key = ObjectIdentifier(owner) let previous = contextsByOwner[key] if let previous, previous.authority == authority, previous.folders == [folder] { return previous } + // The same workspace keeps its ID; another one is a new context. + let continued = previous.flatMap { $0.stateKey == stateKey ? $0 : nil } let contextID: String - if let previous { - contextID = previous.contextID + if let continued { + contextID = continued.contextID } else { contextID = "w-\(nextContextNumber)" nextContextNumber += 1 } let context = PluginWorkspaceContext( contextID: contextID, - generation: (previous?.generation ?? 0) + 1, + generation: (continued?.generation ?? 0) + 1, name: name, authority: authority, folders: [folder], @@ -125,10 +133,11 @@ final class PluginWorkspaceContexts { ) contextsByOwner[key] = context revision += 1 - if previous != nil { - for observer in closeObservers.values { observer(contextID) } + if let previous { + for observer in closeObservers.values { observer(previous.contextID) } } for observer in openObservers.values { observer(context) } + notifyRevision() return context } @@ -137,6 +146,11 @@ final class PluginWorkspaceContexts { guard let context = contextsByOwner.removeValue(forKey: ObjectIdentifier(owner)) else { return } revision += 1 for observer in closeObservers.values { observer(context.contextID) } + notifyRevision() + } + + private func notifyRevision() { + for observer in revisionObservers.values { observer(revision) } } /// Called with the context ID whenever a context closes or moves to a @@ -156,14 +170,19 @@ final class PluginWorkspaceContexts { return id } + /// Called once with the new `revision` after every open, change, or + /// close, after the close and open observers. + @discardableResult + func observeRevision(_ observer: @escaping @MainActor (Int) -> Void) -> UUID { + let id = UUID() + revisionObservers[id] = observer + return id + } + func removeObserver(_ id: UUID) { closeObservers[id] = nil openObservers[id] = nil - } - - /// `r` and 16 hex digits of the location's SHA-256. - static func rootID(_ location: String) -> String { - "r" + SHA256.hash(data: Data(location.utf8)).prefix(8).map { String(format: "%02x", $0) }.joined() + revisionObservers[id] = nil } /// Names the state directory of a context without the user's path. @@ -171,3 +190,24 @@ final class PluginWorkspaceContexts { SHA256.hash(data: Data(stateKey.utf8)).map { String(format: "%02x", $0) }.joined() } } + +/// The `rootID`s backends see. Each is `r` and 16 hex digits of an +/// HMAC-SHA256 of the plugin ID and the folder's location under a secret +/// the host keeps, so it is stable for the same plugin and location across +/// restarts, differs between plugins, and cannot be computed from a guessed +/// path without the secret. +struct PluginWorkspaceRootIDs: Sendable { + private let key: SymmetricKey + + init(secret: Data) { + key = SymmetricKey(data: secret) + } + + func rootID(of location: String, pluginID: String) -> String { + var message = Data(pluginID.utf8) + message.append(0) + message.append(Data(location.utf8)) + let code = HMAC.authenticationCode(for: message, using: key) + return "r" + Data(code).prefix(8).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 30393ea837..3884ca236b 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2078,6 +2078,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let message: String /// Omitted when nil. let code: String? + /// Omitted when nil. + let details: ToolingJSONValue? } let sessionId: String @@ -4046,7 +4048,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { hostRequestID: String, result: ToolingJSONValue?, failureMessage: String?, - failureCode: String? = nil + failureCode: String? = nil, + failureDetails: ToolingJSONValue? = nil ) -> Result { executeVoid( command: "lsp.hostResponse", @@ -4054,11 +4057,34 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { sessionId: sessionID, hostRequestID: hostRequestID, result: failureMessage == nil ? (result ?? .null) : nil, - error: failureMessage.map { .init(message: $0, code: failureCode) } + error: failureMessage.map { .init(message: $0, code: failureCode, details: failureDetails) } ) ) } + private struct LspHostNotifyRequest: Encodable { + let sessionId: String + let method: String + let params: ToolingJSONValue + } + + private struct LspHostNotifyResponse: Decodable { + let delivered: Bool + } + + /// Sends a plugin backend one host notification. `false` when the + /// backend negotiated a function protocol without the notification. + func lspHostNotify( + sessionID: String, + method: String, + params: ToolingJSONValue + ) -> Result { + executeResult( + command: "lsp.hostNotify", + payload: LspHostNotifyRequest(sessionId: sessionID, method: method, params: params) + ).map { (response: LspHostNotifyResponse) in response.delivered } + } + /// Cancels a pending operation. Its completion event still arrives, carrying /// a cancellation error, so callers never leak a continuation. func lspCancelOperation(sessionID: String, operationID: String) { diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 6a81e4d5f4..9e27af450e 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -349,10 +349,19 @@ extension RustCoreBridge: LanguageServerRuntimeCore { } return lspHostResponse( sessionID: sessionID, hostRequestID: hostRequestID, result: result, - failureMessage: failure?.message, failureCode: failure?.code + failureMessage: failure?.message, failureCode: failure?.code, failureDetails: failure?.details ).mapError(Self.runtimeFailure) } + func notifyPluginBackend( + sessionID: String, + method: String, + params: ToolingJSONValue + ) -> Result { + lspHostNotify(sessionID: sessionID, method: method, params: params) + .mapError(Self.runtimeFailure) + } + func updateWorkspaceConfiguration( sessionID: String, configuration: ToolingJSONValue diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift index ce3ebfd8e0..3a0bc6f089 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift @@ -19,10 +19,11 @@ extension AppModel { let target = contribution.kind == .toolWindow ? contribution.id : contribution.visibility["toolWindowID"] guard let target, workspaceURL != nil, services.moduleRuntime.availableContributions()[owner]?.contains(contribution) == true else { return } - // A plugin command also starts its `backend` 2 when the plugin - // declares `onCommand:`; the window opens without waiting. + // A plugin command reaches its `backend` 2, which starts first when + // the plugin declares `onCommand:`; the window opens without + // waiting. if contribution.kind == .command, let commandID = contribution.actionID { - services.pluginBackendActivation?.activate(event: "onCommand:\(commandID)", owner: owner) + services.pluginBackendActivation?.runCommand(commandID, owner: owner, context: pluginWebWorkspaceContext) } let window = WorkbenchFeatureModel.ToolWindow(rawValue: target) if !openOnly && contribution.kind == .toolWindow && workbenchFeature.isVisible(window) { diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index ec243530fe..07b51e5bd4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -422,7 +422,8 @@ final class MacServiceContainer { let backendDirectories = MacPluginStateDirectories(fileStorage: fileStorage) hostDispatcher.register(PluginWorkspaceHostService( contexts: .shared, - backendStateDirectory: { backendDirectories.backendStateDirectory(pluginID: $0) } + backendStateDirectory: { backendDirectories.backendStateDirectory(pluginID: $0) }, + rootIDSecret: { MacPluginRootIDSecret.load(from: MacPluginRootIDSecret.fileURL(fileStorage: fileStorage)) } )) let sessions = MacPluginBackendSessions( launchResolver: pluginLaunchResolver, diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 5ae65789b8..ec4fb39727 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -80,6 +80,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi /// A replaced or removed package never starts again in this launch. private var replacedPluginIDs: Set = [] private var running: [String: Running] = [:] + private var contextObserver: UUID? /// One process, from its start until it is dropped. `session` is set as /// soon as the process is launched, so a stop during initialize ends it. @@ -87,6 +88,10 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi let settings: [String: ToolingJSONValue] var task: Task? var session: LanguageServerRuntimeSession? + /// What Core granted the session, set when the process launches. + var hostServices: Set = [] + /// Notifications reach the backend in the order the host sent them. + var notifications: Task? init(settings: [String: ToolingJSONValue]) { self.settings = settings @@ -128,12 +133,22 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi dispatcher.register(PluginCredentialHostService(credentials: credentials)) self.hostDispatcher = dispatcher } + // Running backends learn of every opened, changed, or closed + // context; one that starts later asks `listContexts` itself. + contextObserver = self.hostDispatcher.contexts.observeRevision { [weak self] revision in + self?.contextsDidChange(revision: revision) + } + self.hostDispatcher.subscriptions.deliver = { [weak self] pluginID, params in + self?.notify(pluginID: pluginID, method: "lithe/host/subscriptions/notify", params: params) + } } /// Records the backends of the plugins loaded at launch. Every project /// window registers the same startup snapshot; a package installed later - /// waits for the next launch like its pages. + /// waits for the next launch like its pages. A plugin registered while + /// a workspace is already open gets its `onWorkspaceOpen` at once. func register(_ plugins: [InstalledPluginPackage]) { + var added: Set = [] for plugin in plugins where plugin.descriptor.declaresBackend { let id = plugin.descriptor.id.rawValue guard !replacedPluginIDs.contains(id), packages[id] == nil else { continue } @@ -144,7 +159,9 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi activationEvents: plugin.descriptor.activationEvents, backendVersion: Self.backendVersion(ofManifest: plugin.descriptor.normalizedManifest) ) + added.insert(id) } + activateForOpenWorkspaces(added) } func declaresBackend(_ pluginID: String) -> Bool { @@ -200,15 +217,24 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi /// backend never holds up the workspace or the command. A backend that /// is already running stays as it is. func activate(event: String, owner: ModuleID? = nil) { - let starting = pluginIDs(activatedBy: event, owner: owner).filter { running[$0] == nil } - guard !starting.isEmpty else { return } + start(pluginIDs(activatedBy: event, owner: owner).filter { running[$0] == nil }) + } + + /// Queues the start of each plugin's backend; `then` runs for each one + /// that is ready, in the queue's order. + private func start( + _ pluginIDs: [String], + then: (@MainActor (_ pluginID: String) -> Void)? = nil + ) { + guard !pluginIDs.isEmpty else { return } let previous = activationQueue activationQueue = Task { @MainActor [weak self] in await previous?.value - for pluginID in starting { + for pluginID in pluginIDs { guard let self, !Task.isCancelled else { return } do { _ = try await self.session(for: pluginID) + then?(pluginID) } catch { if !(error is CancellationError) { self.onActivationFailure?(pluginID, error) } } @@ -216,14 +242,70 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi } } + /// The plugins among `pluginIDs` that start on `onWorkspaceOpen` start + /// now when a workspace is already open, as if it had just opened. + private func activateForOpenWorkspaces(_ pluginIDs: Set) { + guard !pluginIDs.isEmpty, !hostDispatcher.contexts.contexts.isEmpty else { return } + start(self.pluginIDs(activatedBy: "onWorkspaceOpen").filter { + pluginIDs.contains($0) && running[$0] == nil + }) + } + + func runCommand(_ commandID: String, owner: ModuleID, context: PluginWorkspaceContextReference?) { + let pluginID = String(owner.rawValue.dropLast(".ui".count)) + guard owner.rawValue.hasSuffix(".ui"), let package = packages[pluginID], package.backendVersion >= 2, + !disabledPluginIDs.contains(pluginID) else { return } + var params: [String: ToolingJSONValue] = ["commandID": .string(commandID)] + if let context { + params["context"] = .object([ + "contextID": .string(context.contextID), "generation": .integer(context.generation), + ]) + } + let deliver: @MainActor (String) -> Void = { [weak self] pluginID in + self?.notify(pluginID: pluginID, method: "lithe/host/commands/didRun", params: .object(params)) + } + // A ready backend gets it in order with the notifications sent from + // now; a starting one after its start. A stopped one starts only on + // its own `onCommand:`. + if running[pluginID]?.session != nil { + deliver(pluginID) + } else if running[pluginID] != nil || package.activationEvents.contains("onCommand:\(commandID)") { + start([pluginID], then: deliver) + } + } + + /// Tells every running backend granted `workspace` that the contexts + /// changed; it asks `listContexts` for them. + private func contextsDidChange(revision: Int) { + for (pluginID, entry) in running where entry.hostServices.contains("workspace") { + notify(pluginID: pluginID, method: "lithe/host/workspace/didChangeContexts", + params: .object(["revision": .integer(revision)])) + } + } + + /// Sends a host notification to the plugin's running backend, after the + /// ones sent before it. A backend that is not running, or negotiated a + /// protocol without the notification, does not receive it. + private func notify(pluginID: String, method: String, params: ToolingJSONValue) { + guard let entry = running[pluginID], let session = entry.session else { return } + let previous = entry.notifications + entry.notifications = Task { @MainActor in + await previous?.value + _ = await session.notify(method: method, params: params) + } + } + /// Stops the backend until `enable` is called. func disable(_ pluginID: String) { disabledPluginIDs.insert(pluginID) stop(pluginID) } + /// The backend may start again; with a workspace open, one that starts + /// on `onWorkspaceOpen` starts now instead of with the next workspace. func enable(_ pluginID: String) { disabledPluginIDs.remove(pluginID) + activateForOpenWorkspaces([pluginID]) } /// Stops the backend before its package is replaced, rolled back, or @@ -238,6 +320,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi hostDispatcher.endSession(pluginID: pluginID) guard let entry = running.removeValue(forKey: pluginID) else { return } entry.task?.cancel() + entry.notifications?.cancel() entry.session?.stop() } @@ -310,6 +393,11 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi // leaves behind can be ended when the backend is gone. let backendToken = UUID().uuidString environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + // Core lists in `getCapabilities` what it grants, so it is granted + // only what this host serves: a listed method never fails for want + // of a handler. + let served = Set(hostDispatcher.registeredServices) + let hostServices = backend.hostServices.filter { served.contains($0) } let session = LanguageServerRuntimeSession( providerID: "pluginBackend:\(pluginID)", executableURL: executableURL, @@ -323,7 +411,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi // are the backend's only callers. moduleID: ModuleID("\(pluginID).ui"), pluginID: pluginID, - hostServices: backend.hostServices + hostServices: hostServices ) let endBackendProcesses = endBackendProcesses let hostDispatcher = hostDispatcher @@ -332,7 +420,8 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi session.onReleased = { endBackendProcesses(backendToken, false) } // A new process starts with no subscriptions and nothing in flight. hostDispatcher.endSession(pluginID: pluginID) - hostDispatcher.beginSession(pluginID: pluginID, hostServices: backend.hostServices) + hostDispatcher.beginSession(pluginID: pluginID, hostServices: hostServices) + entry.hostServices = Set(hostServices) session.onHostRequest = { [weak session] request in hostDispatcher.handle(request, pluginID: pluginID) { outcome in session?.respond(to: request.id, with: outcome) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRootIDSecret.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRootIDSecret.swift new file mode 100644 index 0000000000..133f34ba4f --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRootIDSecret.swift @@ -0,0 +1,53 @@ +import Foundation +import Security + +// Note: rootID 的密钥与生命周期见 .agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md + +/// The host secret behind the `rootID`s plugin backends see +/// (`PluginWorkspaceRootIDs`). +/// +/// It lives in `Application Support/Lithe/PluginHost/root-id.key`. That is a +/// platform-writable directory, outside the app bundle (so it never touches +/// code signing or Sparkle deltas) and outside every plugin's `${stateDir}`. +/// It is created on first use with mode 0600 and never rewritten; deleting +/// it only gives every folder new root IDs. Backends run as the user, so +/// the secret keeps root IDs from being computed from a guessed path or +/// matched between plugins through the protocol, not from a process that +/// reads the user's files. +enum MacPluginRootIDSecret { + static let byteCount = 32 + + static func fileURL(fileStorage: any FileStorage) -> URL { + fileStorage.applicationSupportDirectory() + .appendingPathComponent("Lithe/PluginHost", isDirectory: true) + .appendingPathComponent("root-id.key", isDirectory: false) + } + + /// The stored secret, created when missing. Another window creating it + /// at the same moment wins and both read the same bytes. When the + /// directory cannot be written, a secret for this launch is used: root + /// IDs stay unique, only not stable across launches. + static func load(from url: URL, fileManager: FileManager = .default) -> Data { + if let stored = read(url) { return stored } + var bytes = Data(count: byteCount) + let status = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + if status != errSecSuccess { + bytes = Data((0..= 0 else { return read(url) ?? bytes } + let written = bytes.withUnsafeBytes { write(descriptor, $0.baseAddress, byteCount) } + close(descriptor) + guard written == byteCount else { + try? fileManager.removeItem(at: url) + return bytes + } + return bytes + } + + private static func read(_ url: URL) -> Data? { + guard let data = try? Data(contentsOf: url), data.count == byteCount else { return nil } + return data + } +} diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index e1cffe3e5e..87cdc9a9bd 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -538,6 +538,14 @@ package protocol LanguageServerRuntimeCore: Sendable { hostRequestID: String, outcome: Result ) -> Result + /// Sends a plugin backend one host notification (`lsp.hostNotify`), such + /// as a subscription event. `true` when Core wrote it; `false` when the + /// backend negotiated a function protocol without it. + func notifyPluginBackend( + sessionID: String, + method: String, + params: ToolingJSONValue + ) -> Result /// Replaces the configuration a running plugin server reads and tells it /// through `workspace/didChangeConfiguration`. func updateWorkspaceConfiguration( @@ -804,6 +812,17 @@ package extension LanguageServerRuntimeCore { )) } + func notifyPluginBackend( + sessionID _: String, + method _: String, + params _: ToolingJSONValue + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "not_supported", + message: "Plugin host notifications are not supported by this runtime." + )) + } + func updateWorkspaceConfiguration( sessionID _: String, configuration _: ToolingJSONValue @@ -862,14 +881,18 @@ package struct PluginHostRequest: Equatable, Sendable { /// The host could not complete a host-service request. The message reaches /// the backend; never put a secret in it. `code` is a `plugin.host.*` code /// Core passes to a function protocol 3 backend; nil, or a code Core does -/// not pass, reaches it as `plugin.host.failed`. +/// not pass, reaches it as `plugin.host.failed`. `details` is an object of +/// facts the backend may act on, such as a document's current version; Core +/// passes it within 4 KiB to a protocol 3 backend and drops it otherwise. package struct PluginHostRequestFailure: Error, Equatable, Sendable { package let message: String package let code: String? + package let details: ToolingJSONValue? - package init(message: String, code: String? = nil) { + package init(message: String, code: String? = nil, details: ToolingJSONValue? = nil) { self.message = message self.code = code + self.details = details } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index f308656578..ef55f687c6 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -923,6 +923,25 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Sends the backend one host notification. Core writes it to the + /// backend's input like an answer, so it is sent off the main actor. + /// `false` when the session is not running, the backend negotiated a + /// protocol without the notification, or Core refused it. + package func notify(method: String, params: ToolingJSONValue) async -> Bool { + guard let sessionID, isRunning else { return false } + let core = core + let sending = Task.detached(priority: .userInitiated) { + core.notifyPluginBackend(sessionID: sessionID, method: method, params: params) + } + switch await sending.value { + case .success(let delivered): + return delivered + case .failure(let failure): + onLog?(.warning, "Plugin host notification was not delivered", failure.message, nil) + return false + } + } + /// Codes Core puts in `details` when it refuses a call before it reaches /// the backend. Any other `details`, such as an OS error text from a /// failed write, is a diagnostic and never becomes the page's code. diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift index c79253d730..4922e24b8a 100644 --- a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -111,6 +111,54 @@ struct MacPluginBackendSessionsTests { } } + /// A command run from the plugin's UI and the events of its + /// subscriptions reach the running backend as host notifications, in + /// the order the host sent them. + @Test + func commandsAndSubscriptionEventsReachTheRunningBackendInOrder() async throws { + let fixture = try BackendFixture(backendVersion: 2) + defer { fixture.remove() } + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + let owner = ModuleID("\(BackendFixture.pluginID).ui") + let subscriptions = fixture.sessions.hostDispatcher.subscriptions + let subscriptionID = subscriptions.add(pluginID: BackendFixture.pluginID, contextID: nil) {} + + fixture.sessions.runCommand( + "query.run", owner: owner, + context: PluginWorkspaceContextReference(contextID: "w-1", generation: 2) + ) + subscriptions.notify(pluginID: BackendFixture.pluginID, subscriptionID: subscriptionID, event: ["kind": .string("changed")]) + fixture.sessions.runCommand("query.stop", owner: owner, context: nil) + + #expect(await fixture.core.waitForNotifications(3)) + #expect(fixture.core.notifications.map(\.method) == [ + "lithe/host/commands/didRun", "lithe/host/subscriptions/notify", "lithe/host/commands/didRun", + ]) + #expect(fixture.core.notifications.first?.params == .object([ + "commandID": .string("query.run"), + "context": .object(["contextID": .string("w-1"), "generation": .integer(2)]), + ])) + #expect(fixture.core.notifications.last?.params == .object(["commandID": .string("query.stop")])) + } + + /// A version 1 backend has no command notification, and a command that + /// is not an activation event does not start a stopped backend. + @Test + func aCommandReachesNoBackendThatCannotReceiveIt() async throws { + let first = try BackendFixture() + defer { first.remove() } + _ = try await first.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + first.sessions.runCommand("query.run", owner: ModuleID("\(BackendFixture.pluginID).ui"), context: nil) + #expect(first.core.notifications.isEmpty) + + let stopped = try BackendFixture(backendVersion: 2) + defer { stopped.remove() } + stopped.sessions.runCommand("query.run", owner: ModuleID("\(BackendFixture.pluginID).ui"), context: nil) + stopped.sessions.runCommand("query.run", owner: ModuleID("dev.example.other.ui"), context: nil) + #expect(stopped.core.starts.isEmpty) + #expect(stopped.core.notifications.isEmpty) + } + /// The answer never echoes a secret, and a request outside the /// credentials service is refused even if Core forwarded it. @Test @@ -174,16 +222,23 @@ private struct BackendFixture { let core = ScriptedBackendCore() let sessions: MacPluginBackendSessions - init(prepareState: @escaping @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void = { _, _ in }) throws { + init( + backendVersion: Int = 1, + prepareState: @escaping @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void = { _, _ in } + ) throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-plugin-backend-\(UUID().uuidString)", isDirectory: true) packageURL = root.appendingPathComponent("package", isDirectory: true) stateRoot = root.appendingPathComponent("state", isDirectory: true) let fixtureURL = DeclarativePluginTestSupport.repositoryRoot .appendingPathComponent("shared/contracts/plugins/v1/fixtures/backend.plugin.json") - let object = try #require( + var object = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] ) + if var requires = object["requires"] as? [String: Any] { + requires["backend"] = backendVersion + object["requires"] = requires + } _ = try DeclarativePluginTestSupport.writePackage(at: packageURL, manifest: object) let executable = packageURL.appendingPathComponent("bin/backend") try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) @@ -238,17 +293,26 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S let outcome: Result } + struct Notification: Equatable { + let sessionID: String + let method: String + let params: ToolingJSONValue + } + private let lock = NSLock() private var nextSession = 0 private var events: [String: [LanguageServerRuntimeEvent]] = [:] private var _starts: [Start] = [] private var _stops: [String] = [] private var _responses: [Response] = [] + private var _notifications: [Notification] = [] private var waiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] + private var notificationWaiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] var starts: [Start] { lock.withLock { _starts } } var stops: [String] { lock.withLock { _stops } } var responses: [Response] { lock.withLock { _responses } } + var notifications: [Notification] { lock.withLock { _notifications } } func startPluginLanguageServer( pluginID: String, providerID _: String, executableURL _: URL, arguments: [String], @@ -312,6 +376,36 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S } } + func notifyPluginBackend( + sessionID: String, method: String, params: ToolingJSONValue + ) -> Result { + let satisfied = lock.withLock { + _notifications.append(Notification(sessionID: sessionID, method: method, params: params)) + let ready = notificationWaiters.filter { $0.value.count <= _notifications.count } + ready.keys.forEach { notificationWaiters.removeValue(forKey: $0) } + return ready.values.map(\.continuation) + } + satisfied.forEach { $0.resume(returning: true) } + return .success(true) + } + + /// Notifications are sent off the main actor; like `waitForResponses`, + /// a watchdog fails the wait after five seconds instead of hanging. + func waitForNotifications(_ count: Int) async -> Bool { + await withCheckedContinuation { continuation in + let id = UUID() + let ready: Bool = lock.withLock { + guard _notifications.count < count else { return true } + notificationWaiters[id] = (count, continuation) + return false + } + if ready { return continuation.resume(returning: true) } + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { [self] in + lock.withLock { notificationWaiters.removeValue(forKey: id) }?.continuation.resume(returning: false) + } + } + } + func startLanguageServer( providerID _: String, executableURL _: URL, arguments _: [String], environment _: [String: String], rootURL _: URL, workingDirectoryURL _: URL, initializationOptions _: ToolingJSONValue?, diff --git a/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift index 4a07bfcae5..462a0ff35e 100644 --- a/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift +++ b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift @@ -12,39 +12,67 @@ import Testing struct PluginHostServiceDispatchTests { private static let pluginID = "dev.lithe.fixture.workspacebackend" - /// A window keeps its context ID while its workspace changes; the - /// generation grows, so an older reference expires. + /// Another workspace in the same window is another context: the old + /// one closes and its references expire. The same workspace again + /// changes nothing. @Test - func reopeningAnotherWorkspaceKeepsTheIDWithTheNextGeneration() throws { + func anotherWorkspaceInTheWindowIsANewContext() throws { let contexts = PluginWorkspaceContexts() let window = NSObject() + var closed: [String] = [] + var revisions: [Int] = [] + contexts.observeClose { closed.append($0) } + contexts.observeRevision { revisions.append($0) } let first = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) let same = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/orders"), remote: nil) let second = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/tmp/billing"), remote: nil) #expect(first == same) #expect(first.generation == 1) - #expect(second.contextID == first.contextID) - #expect(second.generation == 2) + #expect(second.contextID != first.contextID) + #expect(second.generation == 1) + #expect(closed == [first.contextID]) + #expect(revisions == [2, 3]) #expect(throws: PluginHostServiceError.self) { try contexts.resolve(first.reference).get() } #expect(try contexts.resolve(second.reference).get() == second) contexts.close(owner: window) #expect(throws: PluginHostServiceError.self) { try contexts.resolve(second.reference).get() } #expect(contexts.contexts.isEmpty) + #expect(revisions == [2, 3, 4]) } - /// A root ID is stable for a location and reveals no path. + /// A root ID is stable for a plugin and location, differs between + /// plugins and secrets, and reveals no path. @Test - func rootIDsAreStableAndOpaque() { - let id = PluginWorkspaceContexts.rootID("/Users/me/orders") - #expect(id == PluginWorkspaceContexts.rootID("/Users/me/orders")) - #expect(id != PluginWorkspaceContexts.rootID("/Users/me/billing")) + func rootIDsAreStablePerPluginAndOpaque() { + let rootIDs = PluginWorkspaceRootIDs(secret: Fixture.rootIDSecret) + let id = rootIDs.rootID(of: "/Users/me/orders", pluginID: Self.pluginID) + #expect(id == PluginWorkspaceRootIDs(secret: Fixture.rootIDSecret).rootID(of: "/Users/me/orders", pluginID: Self.pluginID)) + #expect(id != rootIDs.rootID(of: "/Users/me/billing", pluginID: Self.pluginID)) + #expect(id != rootIDs.rootID(of: "/Users/me/orders", pluginID: "dev.example.other")) + #expect(id != PluginWorkspaceRootIDs(secret: Data(repeating: 9, count: 32)).rootID(of: "/Users/me/orders", pluginID: Self.pluginID)) #expect(id.count == 17) #expect(id.hasPrefix("r")) #expect(!id.contains("orders")) } + /// The secret is created once with owner-only access and read back the + /// same on the next load. + @Test + func theRootIDSecretIsCreatedOnceAndKept() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-root-id-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("PluginHost/root-id.key") + let first = MacPluginRootIDSecret.load(from: url) + let second = MacPluginRootIDSecret.load(from: url) + #expect(first.count == MacPluginRootIDSecret.byteCount) + #expect(first == second) + let permissions = try FileManager.default.attributesOfItem(atPath: url.path)[.posixPermissions] as? Int + #expect(permissions == 0o600) + } + /// A session serves only its granted services, plus the implicit ones. @Test func aServiceTheSessionWasNotGrantedIsRefused() async { @@ -99,6 +127,11 @@ struct PluginHostServiceDispatchTests { } #expect(list["revision"] == .integer(fixture.contexts.revision)) #expect(entries.count == 1) + // Listing creates no state directory. + let workspaces = fixture.stateRoot + .appendingPathComponent(Self.pluginID, isDirectory: true) + .appendingPathComponent("backend/workspaces", isDirectory: true) + #expect(!FileManager.default.fileExists(atPath: workspaces.path)) let described = try await fixture.dispatcher.dispatch( PluginHostRequest( @@ -114,10 +147,12 @@ struct PluginHostServiceDispatchTests { Issue.record("getContext returned \(described)") return } - #expect(entries.first == described) + var withoutDirectory = object + withoutDirectory["stateDirectory"] = nil + #expect(entries.first == .object(withoutDirectory)) #expect(object["authority"] == .object(["kind": .string("local")])) #expect(folder["path"] == .string("/tmp/orders")) - #expect(folder["rootID"] == .string(PluginWorkspaceContexts.rootID("/tmp/orders"))) + #expect(folder["rootID"] == .string(Fixture.rootID("/tmp/orders"))) let expectedRoot = fixture.stateRoot .appendingPathComponent(Self.pluginID, isDirectory: true) .appendingPathComponent("backend/workspaces", isDirectory: true) @@ -151,7 +186,7 @@ struct PluginHostServiceDispatchTests { #expect(context.authority == .remote(id: "conn-1")) #expect(context.name == "orders") #expect(folders == .object(["folders": .array([.object([ - "rootID": .string(PluginWorkspaceContexts.rootID("remote:conn-1:/srv/orders")), + "rootID": .string(Fixture.rootID("remote:conn-1:/srv/orders")), "name": .string("orders"), ])])])) } @@ -221,11 +256,46 @@ struct PluginHostServiceDispatchTests { #expect(subscriptions.count(pluginID: Self.pluginID) == 0) } - /// Typed failures keep their code on the way to Core. + /// Typed failures keep their code and details on the way to Core. @Test func aServiceErrorBecomesAFailureWithItsCode() { let failure = PluginHostServiceError(.versionConflict, "changed").failure #expect(failure == PluginHostRequestFailure(message: "changed", code: "plugin.host.versionConflict")) + let detailed = PluginHostServiceError(.versionConflict, "changed", details: ["currentVersion": .integer(4)]).failure + #expect(detailed.details == .object(["currentVersion": .integer(4)])) + } + + /// Every error code a host reports is one Core passes on, each once. + @Test + func everyHostErrorCodeIsDistinct() { + let codes = PluginHostErrorCode.allCases.map(\.rawValue) + #expect(Set(codes).count == codes.count) + #expect(codes.contains("plugin.host.rateLimited")) + #expect(codes.contains("plugin.host.busy")) + } + + /// Events of a live subscription reach the backend with a sequence that + /// grows per subscription; a disposed one sends nothing more. + @Test + func subscriptionEventsCarryAGrowingSequence() { + let subscriptions = PluginHostSubscriptions() + let log = DeliveryLog() + subscriptions.deliver = { pluginID, params in log.items.append((pluginID, params)) } + let id = subscriptions.add(pluginID: Self.pluginID, contextID: nil) {} + subscriptions.notify(pluginID: Self.pluginID, subscriptionID: id, event: ["kind": .string("changed")]) + subscriptions.notify(pluginID: Self.pluginID, subscriptionID: id, event: ["kind": .string("changed")]) + subscriptions.notify(pluginID: "dev.example.other", subscriptionID: id, event: [:]) + subscriptions.dispose(pluginID: Self.pluginID, subscriptionID: id) + subscriptions.notify(pluginID: Self.pluginID, subscriptionID: id, event: [:]) + + #expect(log.items.map(\.0) == [Self.pluginID, Self.pluginID]) + let fields = log.items.map { item -> [String: ToolingJSONValue] in + guard case .object(let object) = item.1 else { return [:] } + return object + } + #expect(fields.map { $0["sequence"] } == [.integer(1), .integer(2)]) + #expect(fields.first?["subscriptionID"] == .string(id)) + #expect(fields.first?["event"] == .object(["kind": .string("changed")])) } /// Only a `backend` 2 starts on activation events; a manifest whose @@ -248,8 +318,14 @@ struct PluginHostServiceDispatchTests { var items: [String] = [] } + @MainActor + private final class DeliveryLog { + var items: [(String, ToolingJSONValue)] = [] + } + @MainActor private struct Fixture { + static let rootIDSecret = Data(repeating: 7, count: 32) let contexts = PluginWorkspaceContexts() let dispatcher: PluginHostServiceDispatcher let stateRoot: URL @@ -261,10 +337,15 @@ struct PluginHostServiceDispatchTests { let directories = MacPluginStateDirectories(rootURL: stateRoot) dispatcher.register(PluginWorkspaceHostService( contexts: contexts, - backendStateDirectory: { directories.backendStateDirectory(pluginID: $0) } + backendStateDirectory: { directories.backendStateDirectory(pluginID: $0) }, + rootIDSecret: { Self.rootIDSecret } )) } + static func rootID(_ location: String) -> String { + PluginWorkspaceRootIDs(secret: rootIDSecret).rootID(of: location, pluginID: PluginHostServiceDispatchTests.pluginID) + } + func remove() { try? FileManager.default.removeItem(at: stateRoot) } diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 355802d489..df987e4912 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1880,7 +1880,8 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's Version 3 keeps every rule of version 2 and adds capability discovery, subscription disposal, workspace contexts, a context on each call, typed host -failures, and cancellation of host requests. +failures with details, cancellation of host requests, and host +notifications. | Request | Params | Result | Granted by | | --- | --- | --- | --- | @@ -1891,12 +1892,16 @@ failures, and cancellation of host requests. | `lithe/host/workspace/getFolders` | `{ context: { contextID, generation } }` | `{ folders: [] }` | `workspace.metadata` | - Core answers `getCapabilities` itself. `services` maps each service the - session may call (the implicit `host` and `subscriptions`, and the granted - ones) to `{ methods }`; it lists nothing the session may not call. - `backendAuthority` is `"local"`: the backend runs on the machine that runs - Lithe. `limits` gives `pendingHostRequests`, `functionResultBytes`, and - `messageBytes`. A backend must check `services` instead of assuming a - method from the version. + session may use (the implicit `host`, `subscriptions`, and `commands`, and + the granted ones) to `{ methods, notifications }`: the requests it may + send and the [host notifications](#host-notifications-protocol-version-3) + it may receive. It lists nothing the session may not use, and a host + grants only the services it has handlers for, so a listed method never + fails for want of one. `backendAuthority` is `"local"`: the backend runs on + the machine that runs Lithe. `limits` gives `pendingHostRequests`, + `functionResultBytes`, `errorMessageBytes` (the cut of a host failure's + message), `errorDetailsBytes`, and `notificationBytes`. A backend must + check `services` instead of assuming a method from the version. - A context is one open workspace in one window: ```json @@ -1911,13 +1916,17 @@ failures, and cancellation of host requests. ``` `contextID` uses the key alphabet and lives as long as the window shows - that workspace. `generation` is a positive integer that grows every time - the context's folders or authority change; a request naming an older - generation, or a closed context, is `plugin.host.contextExpired`, and the - backend asks again. `revision` of `listContexts` grows whenever a context - opens, closes, or changes. -- `rootID` identifies one root folder stably for the same path, across - restarts, without revealing the path. `path` is present only for a + that workspace: a window that switches to another workspace closes the + context and opens one with a new `contextID`, so state a backend keyed by + the ID never carries over to a different workspace. `generation` starts at + 1 and grows every time the same workspace's folders or authority change; a + request naming an older generation, or a closed context, is + `plugin.host.contextExpired`, and the backend asks again. `revision` of + `listContexts` grows whenever a context opens, closes, or changes. +- `rootID` identifies one root folder stably for the same location, across + restarts, without revealing the path. It is keyed per plugin with a host + secret, so two plugins see different IDs for the same folder and an ID + cannot be computed from a guessed path. `path` is present only for a `local` authority. - `authority.kind` is `local` or `remote` (a remote workspace, which also has `authority.id`, an opaque connection ID). For a `remote` context the @@ -1927,7 +1936,9 @@ failures, and cancellation of host requests. not implement with `plugin.host.authorityUnsupported`; they never read the placeholder or run the operation locally instead. - `stateDirectory` is private to the plugin and the workspace, outside the - plugin package and the application bundle, and created on first use. + plugin package and the application bundle. Only `getContext` returns it, + and creates it then; `listContexts` entries carry every other field and + create nothing, so listing many windows leaves no directories behind. `${stateDir}` is the plugin-wide state. Both survive disabling and updating the plugin and are deleted when it is uninstalled. - A page call may name its context: `lsp.functionRequest.context` @@ -1935,12 +1946,17 @@ failures, and cancellation of host requests. `params.context` of `lithe/function/invoke`. A malformed reference is `plugin.function.invalidParams`; a backend that negotiated version 1 or 2 receives the call without it. The host fills it from the page's window. -- A host failure may carry a code: - `lsp.hostResponse.error: { message, code? }`. Core passes these codes to - a version 3 backend, each with its own JSON-RPC error code so a backend - can branch on the number alone; any other code, no code, or a version 2 - backend gets `plugin.host.failed`. `fixtures/function-protocol.json` - (`hostFailures`) lists the mapping. +- A host failure may carry a code and details: + `lsp.hostResponse.error: { message, code?, details? }`. Core passes these + codes to a version 3 backend, each with its own JSON-RPC error code so a + backend can branch on the number alone; any other code, no code, or a + version 2 backend gets `plugin.host.failed`. `details` is an object of at + most 4 KiB serialized (for example `{ currentVersion }` with + `versionConflict`, or `{ retryAfterMilliseconds }` with `rateLimited`); + it reaches a version 3 backend as `error.data.details` and is dropped + otherwise. Details that are not an object, or larger, are dropped too; + the failure itself still reaches the backend. `fixtures/function-protocol.json` (`hostFailures`) + lists the mapping. | Code | JSON-RPC | Meaning | | --- | --- | --- | @@ -1954,9 +1970,19 @@ failures, and cancellation of host requests. | `plugin.host.versionConflict` | `-32013` | A document or resource version differs from the expected one | | `plugin.host.limitExceeded` | `-32014` | A host limit was exceeded | | `plugin.host.timeout` | `-32015` | The host's deadline passed | - - Codes from `-32010` are in the JSON-RPC server-defined range and leave - `-32000` to backends' own failures. Versions 1 and 2 keep their numbers. + | `plugin.host.notFound` | `-32016` | The named resource does not exist | + | `plugin.host.readOnly` | `-32017` | The resource or workspace cannot be written | + | `plugin.host.unsupportedOption` | `-32018` | The host does not implement a requested option | + | `plugin.host.preconditionFailed` | `-32019` | A state the request requires does not hold, such as a missing user confirmation | + | `plugin.host.busy` | `-32020` | The resource is in use; retry later | + | `plugin.host.rateLimited` | `-32021` | Too many requests in a period; retry after `details.retryAfterMilliseconds` when given | + + JSON-RPC reserves `-32768` to `-32000` for the protocol and lets + implementations define `-32099` to `-32000` as server errors. `-32000` + to `-32009` stay free for backends' own failures, which many JSON-RPC + libraries already use from `-32000`, so host codes start at `-32010` and + grow by one; a code is never reused for another meaning. Versions 1 and + 2 keep their numbers. - A backend may cancel its own pending host request with `$/cancelRequest`. Core answers it at once with `-32800` and `plugin.host.cancelled`, frees the slot, and publishes `hostRequestCancelled { hostRequestID }` so the host @@ -1970,6 +1996,39 @@ failures, and cancellation of host requests. operation (`plugin.function.recursiveCall`), so a page call never waits on itself through the host. +### Host notifications (protocol version 3) + +The host tells a version 3 backend about changes with JSON-RPC +notifications; the backend answers none of them. The host sends them through +Core with `lsp.hostNotify { sessionId, method, params }`, which returns +`{ delivered }`: `false` when the session negotiated an older version, which +then never sees them. + +| Notification | Params | Granted by | +| --- | --- | --- | +| `lithe/host/workspace/didChangeContexts` | `{ revision }` | `workspace.metadata` | +| `lithe/host/subscriptions/notify` | `{ subscriptionID, sequence, event }` | every plugin-owned session | +| `lithe/host/commands/didRun` | `{ commandID, context? }` | every plugin-owned session | + +- `didChangeContexts` follows every change `revision` of `listContexts` + counts; the backend calls `listContexts` for the contexts. A backend that + starts later reads them itself. +- `subscriptions/notify` carries one `event` object of the named + subscription. `sequence` starts at 1 and grows by one per subscription, so + a backend can detect a gap; nothing follows a disposal. +- `commands/didRun` reaches the plugin's backend when the user runs one of + the plugin's commands. `commandID` is the command's ID; `context` + (`{ contextID, generation }`) is the window's context when it has one. A + running backend receives it in order with the other notifications; a + stopped one is started for it only when it declares + `onCommand:`, and receives it once ready. +- `revision` and `sequence` are positive integers, `event` is an object, + and serialized params are at most `limits.notificationBytes`. Core refuses + any other method, a service the session is not granted, malformed params, + a session that is not plugin-owned, or one that is not ready, with + `invalid_request`. `fixtures/function-protocol.json` + (`hostNotifications`) holds the cases. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. @@ -1980,13 +2039,14 @@ failures, and cancellation of host requests. new protocol version, offered next to the old one. - A backend that answers `version: 1` keeps working unchanged: version 2 only adds host services. A backend that answers `version: 2` keeps - working unchanged: version 3 only adds methods, the call context, and - failure codes, all of which it never sees. + working unchanged: version 3 only adds methods, notifications, the call + context, and failure codes and details, all of which it never sees. - `requires.backend: 1` packages keep their meaning; a new behavior of the backend capability is `backend` 2, never a silent change of version 1. - `fixtures/function-protocol.json` holds the golden cases, including the - host requests (`hostRequests`), call contexts (`invocationContexts`), and - host failure codes (`hostFailures`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, + host requests (`hostRequests`), call contexts (`invocationContexts`), host + failure codes (`hostFailures`), and host notifications + (`hostNotifications`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, deadline, late reply, revocation, and compatibility with a build-only backend. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2cd048b917..92886db119 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1046,6 +1046,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.buildRequest` | Forward one planned `lithe/build/*` request to a session that negotiated the build-provider protocol | | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | | `lsp.hostResponse` | Answer one host-service request a plugin backend sent | +| `lsp.hostNotify` | Send one host notification to a plugin backend that negotiated it | | `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | | `java.resolveNavigation` | Resolve one Java gutter marker to normalized parent or implementation locations | @@ -2868,7 +2869,20 @@ event. `lsp.hostResponse` accepts `{ sessionId, hostRequestID, result? }` or `result` (default `null`) or `-32603` with `data.code: "plugin.host.failed"` to the backend. An unknown or already answered `hostRequestID` is `invalid_request`. At most 16 host requests are unanswered per session; more -are refused with `plugin.host.failed`. +are refused with `plugin.host.failed`. From version 3 the error may also be +`{ message, code?, details? }`: a host code reaches the backend with its own +JSON-RPC number, and `details`, an object of at most 4 KiB, as +`error.data.details` +([failure codes](plugins/v1/README.md#workspace-contexts-and-capability-discovery-protocol-version-3)). + +`lsp.hostNotify` accepts `{ sessionId, method, params }` for a ready +plugin-owned session and returns `{ delivered }`. Core sends the +[host notification](plugins/v1/README.md#host-notifications-protocol-version-3) +when the session negotiated version 3 and is granted its service, and returns +`delivered: false` without sending for an older version. Another method, an +ungranted service, malformed or oversized params, a session that is not +plugin-owned, or one that is not ready is `invalid_request` with the +`plugin.host.*` code in `details`. `lsp.request` accepts a semantic `operation` plus the operation-specific URI, position, range, diagnostics, item, action, or diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index b06479366d..c57446e18d 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9520,6 +9520,7 @@ "native.thePluginBackendIsNoLongerRunning": "The plugin backend is no longer running.", "native.thePluginIsNotAllowedToUseTheHostService": "The plugin is not allowed to use the host service {arg1}.", "native.theParamsOfMustBeAnObject": "The params of {arg1} must be an object.", + "native.theParamsOfMustNameASubscriptionID": "The params of {arg1} must name a subscriptionID.", "native.theWorkspaceContextReferenceIsMalformed": "The workspace context reference is malformed.", "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "The host could not create the plugin's workspace state directory.", "native.theResourcePathMustBeRelativeUseSeparatorsAndNotContainOr": "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\".", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 58130758b2..b75d1ac3b3 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9520,6 +9520,7 @@ "native.thePluginBackendIsNoLongerRunning": "插件后端已不在运行。", "native.thePluginIsNotAllowedToUseTheHostService": "插件无权使用宿主服务 {arg1}。", "native.theParamsOfMustBeAnObject": "{arg1} 的参数必须是对象。", + "native.theParamsOfMustNameASubscriptionID": "{arg1} 的参数必须指明 subscriptionID。", "native.theWorkspaceContextReferenceIsMalformed": "工作区上下文引用格式不正确。", "native.theHostCouldNotCreateThePluginsWorkspaceStateDirectory": "宿主无法创建插件的工作区状态目录。", "native.theResourcePathMustBeRelativeUseSeparatorsAndNotContainOr": "资源路径必须是相对路径,使用“/”分隔,且不能包含“.”或“..”。", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index d44f2fc480..ca00d026c7 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -90847,6 +90847,22 @@ "arg1": "string" } }, + { + "id": "native.theParamsOfMustNameASubscriptionID", + "description": "Native UI: The params of %@ must name a subscriptionID.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The params of %@ must name a subscriptionID.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.theWorkspaceContextReferenceIsMalformed", "description": "Native UI: The workspace context reference is malformed.", diff --git a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json index 50b8f4d646..1b56760c69 100644 --- a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json +++ b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json @@ -2,7 +2,7 @@ "id": "plugin-backend-workspace-context", "area": "扩展", "group": "插件与主题", - "capability": "插件后端 v2 与工作区上下文:requires.backend 2 的后端可由 onWorkspaceOpen 或 onCommand 激活,不依赖网页;函数协议 3 提供 lithe/host/getCapabilities、subscriptions/dispose、workspace/listContexts|getContext|getFolders,调用带 {contextID, generation},远程工作区不暴露路径;宿主错误码各有独立 JSON-RPC 码", + "capability": "插件后端 v2 与工作区上下文:requires.backend 2 的后端可由 onWorkspaceOpen 或 onCommand 激活,不依赖网页,启用插件时已有工作区打开即启动;函数协议 3 提供 lithe/host/getCapabilities、subscriptions/dispose、workspace/listContexts|getContext|getFolders,调用带 {contextID, generation},换工作区发新 contextID,rootID 按插件加密钥,远程工作区不暴露路径;宿主错误码各有独立 JSON-RPC 码并可带 details;宿主经 lsp.hostNotify 推送 didChangeContexts、订阅事件和 commands/didRun", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", @@ -16,7 +16,9 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", "macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift", "macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift", - "macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift" + "macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRootIDSecret.swift", + "macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift" ] }, "windows": { @@ -27,6 +29,6 @@ ] }, "owner": "Extensions", - "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core,核对 function-protocol.json 的 hostRequests/hostFailures、v2 清单拒绝 onStartupFinished 与 *、旧宿主拒绝 backend 2、v3 会话第 17 个挂起请求返回 plugin.host.limitExceeded(-32014)、$/cancelRequest 立即返回 -32800 并发布 hostRequestCancelled。macOS 宿主:scripts/macos/test/test-macos.sh --filter PluginHostServiceDispatchTests。macOS 原生验收:debug 应用安装一个 backend 2、声明 onWorkspaceOpen 与 workspace.metadata 的示例插件,打开本地工作区后确认后端自动启动、listContexts/getFolders 返回该窗口的根;同一窗口换工作区后旧 generation 得到 plugin.host.contextExpired;打开远程工作区时 folders 不含 path;关闭窗口、停用插件后用 pgrep 确认后端不残留。", - "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)。macOS 已有 Core 共享测试与宿主分发单测,尚未做原生验收,所以验证状态为待验证。上下文变化暂不推送给后端,只能比较 listContexts.revision;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。" + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core,核对 function-protocol.json 的 hostRequests/hostFailures/hostNotifications、v2 清单拒绝 onStartupFinished 与 *、旧宿主拒绝 backend 2、v3 会话第 17 个挂起请求返回 plugin.host.limitExceeded(-32014)、$/cancelRequest 立即返回 -32800 并发布 hostRequestCancelled、details 只以不超过 4 KiB 的对象送到 v3 后端、v2 会话的 lsp.hostNotify 返回 delivered:false。macOS 宿主:scripts/macos/test/test-macos.sh --filter \"PluginHostServiceDispatchTests|MacPluginBackendSessionsTests\"。macOS 原生验收:debug 应用安装一个 backend 2、声明 onWorkspaceOpen 与 workspace.metadata 的示例插件,打开本地工作区后确认后端自动启动、listContexts/getFolders 返回该窗口的根;同一窗口换工作区后收到 didChangeContexts,旧 contextID 得到 plugin.host.contextExpired;执行插件命令后后端收到 commands/didRun;打开远程工作区时 folders 不含 path;关闭窗口、停用插件后用 pgrep 确认后端不残留。", + "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)以及 PR #1595 评审补充(宿主通知、错误码细分与 details、换工作区发新 contextID、按插件的 rootID、listContexts 不建目录、能力表只列有 handler 的服务)。macOS 已有 Core 共享测试与宿主单测,尚未做原生验收,所以验证状态为待验证。新安装带后端的包仍需重启才能激活;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。" } From f20601ee6194f32d597031556ea628de586ac2e2 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 16:09:45 +0800 Subject: [PATCH 13/16] feat(plugins): extend protocol 3 additively instead of new versions - From protocol 3, new host services, methods, notifications, options, result fields and failure codes are added within the version and found with getCapabilities; only a change an existing backend would misread needs a new protocol version. - Core answers a field a host method does not define with plugin.host.unsupportedOption (-32018) and details { option } for a version 3 session, before the host sees the request; version 2 keeps plugin.host.invalidParams. - Document the extension rule and the backend's duties (call only listed methods, ignore unknown fields and notifications, treat unknown numbers as host failures) in the contract README, the outcomes table, the Agent Notes, and the feature matrix; add fixture and engine tests. --- ...6-10-20-host-plugin-function-invocation.md | 2 +- .../2026-10-20-plugin-function-contract.md | 4 +- ...-10-30-plugin-backend-workspace-context.md | 19 +++++- rust/lithe-core/src/lsp/interface/client.rs | 18 ++++-- rust/lithe-core/src/lsp/interface/engine.rs | 31 +++++++++ .../src/lsp/interface/plugin_function.rs | 43 ++++++++++--- shared/contracts/plugins/v1/README.md | 63 +++++++++++++++++-- .../v1/fixtures/function-protocol.json | 22 ++++++- .../plugin-backend-workspace-context.json | 2 +- 9 files changed, 177 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md index fc4f2f2e16..d25960b1a3 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md +++ b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md @@ -85,7 +85,7 @@ Core 的 `lsp.functionRequest` 和 `lithe/build/*` 请求是同步把消息写 - **没有真实需求**:Maven、Gradle 的第一批函数只需要读 Provider 已经算好的状态。真正需要“请宿主跑进程”的场景,要等 PR 4 把 Maven 插件迁过来才能确认参数形状。 - **权限还没定**:进程能力意味着插件后端能在用户机器上执行任意命令。这需要和安装审查、工作区信任、`executesProjectCode` 一起设计,不能只加一个方法名。 -- **兼容成本低**:契约已经规定宿主能力要随新的协议版本逐个开放。现在不开放,以后加也不会破坏旧插件。 +- **兼容成本低**:契约已经规定宿主能力要随新的协议版本逐个开放。现在不开放,以后加也不会破坏旧插件。(协议 3 起改为版本内新增、靠 `getCapabilities` 发现,见[插件后端的工作区上下文](2026-10-30-plugin-backend-workspace-context.md)。) 所以版本 1 继续对所有 `lithe/host/*` 请求返回 `plugin.host.unavailable`。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md b/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md index bcc8e8024f..f9e20ad737 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md @@ -37,7 +37,7 @@ Issue #1351 要把 Maven、Gradle 这类插件从“只给声明”推进到“ ### 2. 禁止回调自己 -后端向宿主请求 `lithe/function/invoke` 或任何 `lithe/build/*`,Core 直接回 JSON-RPC `-32601`,`data.code` 是 `plugin.function.recursiveCall`。宿主能力只开放进程运行、输出、存储这类原语,不开放“再进入某个领域注册表”的入口:Maven 处理器需要跑 Maven,应当请求宿主的进程能力,而不是请求宿主“执行 Maven 任务”。版本 1 一个宿主能力都没有开放,`lithe/host/*` 一律是 `plugin.host.unavailable`;每个宿主能力加入时要和参数、权限、所属操作一起进入新的协议版本。 +后端向宿主请求 `lithe/function/invoke` 或任何 `lithe/build/*`,Core 直接回 JSON-RPC `-32601`,`data.code` 是 `plugin.function.recursiveCall`。宿主能力只开放进程运行、输出、存储这类原语,不开放“再进入某个领域注册表”的入口:Maven 处理器需要跑 Maven,应当请求宿主的进程能力,而不是请求宿主“执行 Maven 任务”。版本 1 一个宿主能力都没有开放,`lithe/host/*` 一律是 `plugin.host.unavailable`;协议 2 和 3 的宿主能力都随新的协议版本开放,每个都带着参数、权限和所属操作;从协议 3 起,新的宿主能力改为在协议 3 内新增,后端用 `getCapabilities` 发现,见[插件后端的工作区上下文](2026-10-30-plugin-backend-workspace-context.md)的“协议 3 内只增不改”。 ### 3. 引用、注册与撤回 @@ -69,7 +69,7 @@ Issue #1351 要把 Maven、Gradle 这类插件从“只给声明”推进到“ - 所有 API 级别的旧插件包不变:函数没有新增清单字段。 - 不认识 `litheFunctions` 的后端照常服务语言和 `litheBuild`,不会收到函数调用;它发来的函数请求得到 `-32601`。 -- 对方不能忽略的新字段或新宿主能力,只能放进新的协议版本,与旧版本一起提供。 +- 对方不能忽略的新字段,只能放进新的协议版本,与旧版本一起提供。宿主能力从协议 3 起在版本内新增,靠 `getCapabilities` 发现,不再单独升版本。 ### 正确做法 diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md index 12a3394992..ec26944934 100644 --- a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md @@ -6,6 +6,8 @@ 插件后端新增能力版本 `backend: 2`,同时函数协议升到 3。`backend: 1` 的含义一个字都没改:仍然是“网页第一次调用时才启动、整个应用一个进程”。`backend: 2` 可以在没有网页的情况下启动,触发条件是打开工作区(`onWorkspaceOpen`)或执行插件命令(`onCommand:`)。它仍是每个插件一个进程,被所有窗口共用;每次调用都要带上“工作区上下文”(contextID 加 generation),后端靠它知道这次操作的是哪个工作区。 +从协议 3 起,宿主再开放新能力(新服务、方法、通知、可选参数、结果字段、错误码)都在协议 3 内新增,后端用 `getCapabilities` 查到才使用,不再为它们升协议版本;只有会让已有后端读错的改动才出协议 4。 + 开发时记住三条。第一,宿主服务一律注册到 `PluginHostServiceDispatcher`,不要在 `MacPluginBackendSessions` 里按服务名加分支。第二,凡是涉及文件或进程的服务,遇到不支持的远程 authority 都要返回 `plugin.host.authorityUnsupported`,不能去读本机的空占位目录。第三,工作区信任暂不引入,后续单独立项;现在仍沿用按操作确认。 ## 问题 @@ -101,6 +103,18 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del 宿主按插件串行发送通知(每个运行中会话一个 Task 链),停止会话时取消未发出的通知,保证同一后端收到的顺序与宿主发出的顺序一致。参数序列化后不超过 1 MiB(`limits.notificationBytes`)。 +### 协议 3 内只增不改 + +原来的规则是“每个宿主能力加入时都进入新的协议版本”(见[插件函数契约](2026-10-20-plugin-function-contract.md))。协议 3 有了 `getCapabilities` 之后,这条规则改为: + +- 宿主能力在协议 3 内新增,不升版本。可以新增的有:服务及其方法和通知(配一个新的清单权限)、已有服务的方法或通知、可选参数(叫作“选项”,不传时行为与以前相同)、结果/通知/`details`/`limits` 里的字段、错误码(从 `-32022` 起按顺序取号,号码永不改义)。 +- 只有已有后端会读错的改动才需要协议 4:删除或改名方法、通知、字段,改字段含义或类型,把选项变成必填,改错误码的号码,削弱已写明的顺序或生命周期保证。 +- 后端的义务:只调用 `getCapabilities.services` 列出的方法;忽略不认识的字段和 `lithe/host/*` 通知;遇到不认识的 JSON-RPC 号码按宿主失败处理,认识 `error.data.code` 时按它分支。 +- Core 的义务:协议 3 会话的请求里出现方法没有定义的字段时,回 `-32018` `plugin.host.unsupportedOption`,`details.option` 写明是哪个字段,后端可以去掉它重试。协议 2 仍回 `invalidParams`,行为不变。参数由服务自己校验的方法(`HostParams::Object`)由服务按同样方式回答。 +- 新服务的权限仍要在清单里声明。不认识该权限的旧宿主在安装时以 `plugin.permission.unknown` 拒绝整个包,不会悄悄授予或忽略权限。 + +这样改的原因:插件后端调用宿主属于运行时能力,本来就靠协商和发现判断能不能用([按能力分版本](2026-10-17-plugin-capability-versions.md):“进程启动后还能做什么,走它自己的协议协商”)。如果每加一批服务都升一次版本,SDK 和插件要同时维护很多个版本分支,而版本号能表达的信息 `getCapabilities` 已经全部给出。用户在 PR #1595 评审时确认了这个取舍。 + ### 工作区信任 工作区信任暂不引入,后续单独立项。现阶段沿用按操作确认,例如网页启动进程前的 `pluginWebConfirmProcessStart`。契约 README 里原来写着“workspace trust”的地方,已经改成如实描述:按操作确认,目前没有工作区信任状态。 @@ -143,6 +157,8 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del - **每个工作区一个后端会话。** 隔离最直观。被否的原因:连接和缓存都要按窗口重复建立;`backend:1` 已经是应用级进程,两套模型并存会让插件作者困惑;而且远程工作区也只能在本机跑后端,按工作区拆进程得不到任何隔离收益。 - **把 `backend:1` 直接扩展为带工作区。** 被否的原因:已经发布的插件会在不知情的情况下收到新的启动时机和参数,违背“未知能力明确拒绝、不靠升版本宣称全量可用”的原则。 - **用“当前活动窗口”推断工作区。** 被否的原因:后台调用和多窗口场景下会落到错误的工作区,计划 4.1 也明确禁止这样做。 +- **继续“每个宿主能力升一个协议版本”。** 版本号能直接说明有哪些能力,但文件、编辑器、窗口等服务会分多次开放,协议号会一直往上涨,SDK 每次都要跟着加版本分支。`getCapabilities` 已经按会话列出实际可用的方法,版本号成了重复信息。被否,改为协议 3 内只增不改。 +- **把未知参数静默忽略。** 宿主不升版本时最省事,但后端会以为某个选项生效了,结果宿主没照做,例如以为排除了隐藏文件实际没有。被否,改为明确回 `unsupportedOption` 并写明字段。 - **在 M0 引入工作区信任状态,并以它作为激活前提。** 用户决定暂不引入;先沿用按操作确认,信任模型以后单独立项。 - **把 `${workspaceStateDir}` 开放给 v2 的启动参数。** 被否的原因:进程不属于某个工作区,启动时没有正确的值可填。 @@ -159,7 +175,8 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del - 后端必须自己处理 `contextExpired`,重新获取上下文后再试。 - 在 v2 里 `${workspaceStateDir}` 不可用,插件作者需要从上下文对象里取状态目录。 -- 每加一个新服务,都要同时更新 Core 的 `HOST_METHODS`(以及有通知时的 `HOST_NOTIFICATIONS`)和契约 README。 +- 每加一个新服务,都要同时更新 Core 的 `HOST_METHODS`(以及有通知时的 `HOST_NOTIFICATIONS`)和契约 README。新增的服务、选项和错误码都留在协议 3 内,不能顺手改已有方法或字段的含义,否则就要出协议 4。 +- 同为协议 3,不同宿主能用的方法可能不同,后端和 SDK 必须按 `getCapabilities` 判断,不能只看版本号。 - 后端拿到 `listContexts` 后,要用 `getContext` 才能得到某个工作区的 `stateDirectory`。 - `onWorkspaceOpen` 后端串行启动。插件多的时候,最后一个插件就绪得较晚,但这不影响工作区本身。 diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 5b1d912fa1..4599f63723 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -584,12 +584,18 @@ pub(super) fn client_apply_server_message_for_plugin( error: None, }) } - Err(refusal) => responses.push(json_rpc_error_with_code( - id, - refusal.rpc_code, - refusal.message, - refusal.code, - )?), + Err(refusal) => { + let mut data = json!({ "code": refusal.code }); + if let Some(details) = refusal.details { + data["details"] = details; + } + responses.push(json_rpc_error_with_data( + id, + refusal.rpc_code, + refusal.message, + data, + )?) + } } } _ => { diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 53975e356b..bec9afd982 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -13308,6 +13308,37 @@ public class Main { .all(|event| event.kind != "hostRequest")); } + /// From version 3 a field a method does not define is answered as an + /// unsupported option naming it, so a backend can retry without it; the + /// host never sees the request. + #[test] + fn an_unknown_option_is_named_to_a_version_3_backend() { + let mut harness = host_service_backend(3, &["workspace"]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "folders", + "method": "lithe/host/workspace/getFolders", + "params": { + "context": { "contextID": "w-1", "generation": 1 }, + "includeHidden": true + } + })); + let reply = await_reply(&harness, "folders"); + assert_eq!(reply["error"]["code"], -32018); + assert_eq!( + reply["error"]["data"]["code"], + "plugin.host.unsupportedOption" + ); + assert_eq!( + reply["error"]["data"]["details"], + json!({ "option": "includeHidden" }) + ); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); + } + /// From version 3 a host failure's code reaches the backend when it is /// one a host may report; an unknown code is `plugin.host.failed`. #[test] diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index f810a0e96a..150fa0a40b 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -413,6 +413,9 @@ pub(crate) struct HostRequestRefusal { /// Stable contract code, sent as `error.data.code`. pub code: &'static str, pub message: &'static str, + /// Sent as `error.data.details`, such as the `option` a host does not + /// support. + pub details: Option, } /// Where an accepted `lithe/host/*` request goes. @@ -437,8 +440,9 @@ pub(crate) enum HostParams { Context, /// Any object whose `context`, when present, is a well-formed /// reference. The service checks the rest and answers - /// `plugin.host.invalidParams` itself; for methods whose params Core - /// cannot type without owning their domain. + /// `plugin.host.invalidParams` or, for a field it does not know, + /// `plugin.host.unsupportedOption` itself; for methods whose params + /// Core cannot type without owning their domain. // No row of version 3 uses it yet; the file, editor, and window services // that follow add their rows with it. #[allow(dead_code)] @@ -522,7 +526,10 @@ fn is_implicit_service(service: &str) -> bool { /// `version` is the negotiated protocol version (0 when not negotiated) and /// `granted` the services the plugin's permissions grant the session. The /// params are checked here, so the host only ever sees a well-formed -/// request, and unknown params are refused so a later version can add them. +/// request. From version 3 a field the method does not define is +/// `plugin.host.unsupportedOption` naming it, so a later host can add it as +/// an option without a new version and a backend can tell an option this +/// host lacks from a malformed request. pub(crate) fn host_request( method: &str, params: Option<&Value>, @@ -537,6 +544,7 @@ pub(crate) fn host_request( rpc_code: -32601, code: codes::HOST_UNAVAILABLE, message: "The host offers no such service to this plugin process.", + details: None, }); }; if !is_implicit_service(entry.service) && !granted.contains(entry.service) { @@ -549,9 +557,10 @@ pub(crate) fn host_request( }, code: codes::HOST_PERMISSION_DENIED, message: "The plugin does not declare the permission for this host service.", + details: None, }); } - check_host_params(entry.params, params)?; + check_host_params(entry.params, params, version)?; if entry.method == GET_CAPABILITIES { return Ok(HostRoute::Answer(capabilities_answer(version, granted))); } @@ -747,11 +756,16 @@ fn is_command_id(value: &str) -> bool { }) } -fn check_host_params(rule: HostParams, params: Option<&Value>) -> Result<(), HostRequestRefusal> { +fn check_host_params( + rule: HostParams, + params: Option<&Value>, + version: u64, +) -> Result<(), HostRequestRefusal> { let invalid = |message| HostRequestRefusal { rpc_code: -32602, code: codes::HOST_INVALID_PARAMS, message, + details: None, }; let empty = serde_json::Map::new(); let params = match params { @@ -774,8 +788,17 @@ fn check_host_params(rule: HostParams, params: Option<&Value>) -> Result<(), Hos }; } }; - if params.keys().any(|key| !allowed.contains(&key.as_str())) { - return Err(invalid("Params name a field this service does not define.")); + if let Some(option) = params.keys().find(|key| !allowed.contains(&key.as_str())) { + // Version 2 answered every unknown field as malformed params. + if version < CONTEXT_VERSION { + return Err(invalid("Params name a field this service does not define.")); + } + return Err(HostRequestRefusal { + rpc_code: host_rpc_code(codes::HOST_UNSUPPORTED_OPTION), + code: codes::HOST_UNSUPPORTED_OPTION, + message: "Params name an option this host does not support.", + details: Some(json!({ "option": option })), + }); } match rule { HostParams::Credential { with_secret } => { @@ -1260,6 +1283,12 @@ mod tests { let refusal = outcome.expect_err(name(&case)); assert_eq!(refusal.code, expected["code"], "{}", name(&case)); assert_eq!(refusal.rpc_code, expected["rpcCode"], "{}", name(&case)); + assert_eq!( + refusal.details.as_ref(), + expected.get("details"), + "{}", + name(&case) + ); } } } diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index df987e4912..cd3d02e6a8 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1862,8 +1862,11 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's negotiated version 1 or a method version 2 does not define (`-32601`, `plugin.host.unavailable`), a service the plugin is not granted (`-32601` in version 2, `-32010` from version 3, `plugin.host.permissionDenied`), - and malformed params (`-32602`, `plugin.host.invalidParams`). The code is - in `error.data.code`. + and malformed params (`-32602`, `plugin.host.invalidParams`; from + version 3 a field the method does not define is `-32018`, + `plugin.host.unsupportedOption`, see + [Extending version 3](#extending-version-3)). The code is in + `error.data.code`. - An accepted request becomes the runtime event `hostRequest` with `method` and `result: { hostRequestID, service, params }`. The host answers it with `lsp.hostResponse { sessionId, hostRequestID, result? | error: { message } }`; @@ -1972,7 +1975,7 @@ notifications. | `plugin.host.timeout` | `-32015` | The host's deadline passed | | `plugin.host.notFound` | `-32016` | The named resource does not exist | | `plugin.host.readOnly` | `-32017` | The resource or workspace cannot be written | - | `plugin.host.unsupportedOption` | `-32018` | The host does not implement a requested option | + | `plugin.host.unsupportedOption` | `-32018` | The host does not implement a requested option; `details.option` names the field | | `plugin.host.preconditionFailed` | `-32019` | A state the request requires does not hold, such as a missing user confirmation | | `plugin.host.busy` | `-32020` | The resource is in use; retry later | | `plugin.host.rateLimited` | `-32021` | Too many requests in a period; retry after `details.retryAfterMilliseconds` when given | @@ -2029,14 +2032,56 @@ then never sees them. `invalid_request`. `fixtures/function-protocol.json` (`hostNotifications`) holds the cases. +### Extending version 3 + +From version 3, host capabilities are added within the version, and a +backend discovers them with `getCapabilities` instead of a version number. A later host adds, without a +new version: + +- a service with its methods and notifications, granted by a new manifest + permission, or a method or notification of an existing service; +- an optional field of request params (an *option*) whose absence keeps the + earlier behavior, or a field of a result, a notification, `details`, or + `limits`; +- a failure code, which takes the next unused JSON-RPC number after + `-32021`; a number never changes meaning. + +A new protocol version is needed only for a change an existing backend +would misread: removing or renaming a method, notification, or field, +changing a field's meaning or type, making an option required, changing a +code's number, or weakening an ordering or lifecycle guarantee above. + +So that a version 3 backend works with every host of version 3: + +- It calls a method only when `getCapabilities.services` lists it; another + is `-32601`, `plugin.host.unavailable`. +- It ignores result, notification, `details`, and `limits` fields it does + not know, and any `lithe/host/*` notification it does not know. +- Core refuses a field the method does not define with `-32018`, + `plugin.host.unsupportedOption`, and `details: { option }` naming it, before + the host sees the request; the backend may retry without that option. A + service whose params Core does not type answers the same way itself. +- It treats a JSON-RPC number it does not know as a host failure, and + branches on `error.data.code` when it knows that code. +- A package whose backend needs a new service declares the new permission. + A host that does not know the permission refuses the package at install + (`plugin.permission.unknown`), so a permission is never granted or + ignored silently. + +`fixtures/function-protocol.json` (`hostRequests`) holds the +unsupported-option cases. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. - A backend written before this protocol does not answer `litheFunctions`. It keeps serving its language and its `litheBuild` requests, receives no function calls, and its function requests are answered with `-32601`. -- A new host capability or wire field the other side must not ignore needs a - new protocol version, offered next to the old one. +- Up to version 3, every protocol version added host capabilities. From + version 3 a new host capability or optional field is added within the + version ([Extending version 3](#extending-version-3)); only a change an + existing backend would misread needs a new protocol version, offered next + to the old one. - A backend that answers `version: 1` keeps working unchanged: version 2 only adds host services. A backend that answers `version: 2` keeps working unchanged: version 3 only adds methods, notifications, the call @@ -2149,7 +2194,8 @@ command payload is a Core `invalid_request` failure instead. | `plugin.function.recursiveCall` | Core | A backend asked the host to dispatch a domain operation or function | | `plugin.host.unavailable` | Core | A backend asked for a host capability the negotiated protocol does not offer | | `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services-protocol-version-2) its plugin's permissions do not grant | -| `plugin.host.invalidParams` | Core | A host-service request has malformed or unknown params | +| `plugin.host.invalidParams` | Core | A host-service request has malformed params, or (before version 3) unknown ones | +| `plugin.host.unsupportedOption` | Core, Host | From version 3, a host-service request names a field the host does not implement; `details.option` names it | | `plugin.host.failed` | Core | The host could not complete a host-service request, or (before version 3) too many were unanswered | | `plugin.host.contextExpired` | Host | The request names a workspace context that closed or has a newer generation | | `plugin.host.authorityUnsupported` | Host | The context's authority (for example a remote workspace) does not implement the operation | @@ -2157,6 +2203,11 @@ command payload is a Core `invalid_request` failure instead. | `plugin.host.limitExceeded` | Core, Host | A host limit on size, count, or rate was exceeded | | `plugin.host.cancelled` | Core, Host | The backend cancelled the host request, or the host stopped it | | `plugin.host.timeout` | Host | The host's own deadline for the operation passed | +| `plugin.host.notFound` | Host | The named resource does not exist | +| `plugin.host.readOnly` | Host | The resource or workspace cannot be written | +| `plugin.host.preconditionFailed` | Host | A state the request requires does not hold | +| `plugin.host.busy` | Host | The resource is in use; retry later | +| `plugin.host.rateLimited` | Host | Too many requests in a period | | `plugin.backend.exited` | Host | A page's call, or a host operation for the backend, ended because the backend process exited | Host-facing plugin states are `notInstalled`, `installed` (enabled or diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index cbe833ad8f..5ae2cc5303 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -351,13 +351,29 @@ "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } }, { - "name": "unknown params are invalid so a later version can add them", + "name": "version 2 answers an unknown field as invalid params", "version": 2, "hostServices": ["credentials"], "method": "lithe/host/credentials/get", "params": { "key": "connection.prod", "scope": "global" }, "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } }, + { + "name": "from version 3 an unknown field is an option this host does not support", + "version": 3, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod", "scope": "global" }, + "expected": { "rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": { "option": "scope" } } + }, + { + "name": "a context method names the option it does not support", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w-3f2a", "generation": 1 }, "includeHidden": true }, + "expected": { "rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": { "option": "includeHidden" } } + }, { "name": "capability discovery needs version 3", "version": 2, @@ -402,12 +418,12 @@ } }, { - "name": "capability discovery takes no params", + "name": "capability discovery takes no options yet", "version": 3, "hostServices": [], "method": "lithe/host/getCapabilities", "params": { "services": ["credentials"] }, - "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + "expected": { "rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": { "option": "services" } } }, { "name": "listing workspace contexts is forwarded when workspace.metadata is granted", diff --git a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json index 1b56760c69..a9d9e202df 100644 --- a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json +++ b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json @@ -30,5 +30,5 @@ }, "owner": "Extensions", "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core,核对 function-protocol.json 的 hostRequests/hostFailures/hostNotifications、v2 清单拒绝 onStartupFinished 与 *、旧宿主拒绝 backend 2、v3 会话第 17 个挂起请求返回 plugin.host.limitExceeded(-32014)、$/cancelRequest 立即返回 -32800 并发布 hostRequestCancelled、details 只以不超过 4 KiB 的对象送到 v3 后端、v2 会话的 lsp.hostNotify 返回 delivered:false。macOS 宿主:scripts/macos/test/test-macos.sh --filter \"PluginHostServiceDispatchTests|MacPluginBackendSessionsTests\"。macOS 原生验收:debug 应用安装一个 backend 2、声明 onWorkspaceOpen 与 workspace.metadata 的示例插件,打开本地工作区后确认后端自动启动、listContexts/getFolders 返回该窗口的根;同一窗口换工作区后收到 didChangeContexts,旧 contextID 得到 plugin.host.contextExpired;执行插件命令后后端收到 commands/didRun;打开远程工作区时 folders 不含 path;关闭窗口、停用插件后用 pgrep 确认后端不残留。", - "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)以及 PR #1595 评审补充(宿主通知、错误码细分与 details、换工作区发新 contextID、按插件的 rootID、listContexts 不建目录、能力表只列有 handler 的服务)。macOS 已有 Core 共享测试与宿主单测,尚未做原生验收,所以验证状态为待验证。新安装带后端的包仍需重启才能激活;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。" + "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)以及 PR #1595 评审补充(宿主通知、错误码细分与 details、换工作区发新 contextID、按插件的 rootID、listContexts 不建目录、能力表只列有 handler 的服务)。macOS 已有 Core 共享测试与宿主单测,尚未做原生验收,所以验证状态为待验证。新安装带后端的包仍需重启才能激活;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。协议 3 内只增不改:新的宿主服务、选项和错误码靠 getCapabilities 发现,不再升协议版本;v3 请求里不认识的字段回 plugin.host.unsupportedOption(-32018)并在 details.option 写明。" } From 258b3d1d8df00df6a4af72af2bb07add30b8b1fe Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 17:58:31 +0800 Subject: [PATCH 14/16] feat(plugins): connect public host APIs and harden backend lifecycle --- ...-10-30-plugin-backend-workspace-context.md | 38 ++- ...10-30-plugin-host-open-preview-services.md | 14 +- docs/ci-builds.md | 6 + docs/development/macos-plugin-api-plan.md | 11 + .../Monaco/workbench.integration.ts | 29 +++ macos/Resources/en.lproj/Localizable.strings | 4 + .../zh-Hans.lproj/Localizable.strings | 4 + .../Features/EditorDocumentService.swift | 1 + .../Features/EditorTabOrderFeatureModel.swift | 26 +++ .../Features/PluginDocumentHostService.swift | 85 +++++++ .../Features/PluginFileHostService.swift | 165 +++++++++++++ .../Features/PluginHostAPIValues.swift | 175 ++++++++++++++ .../Features/PluginHostBrowserService.swift | 2 + .../Features/PluginHostEditorFlush.swift | 2 +- .../Features/PluginHostEditorService.swift | 3 + .../Features/PluginHostPreviewService.swift | 13 +- .../Features/PluginHostScope.swift | 26 +++ .../Features/PluginHostServiceDispatch.swift | 125 ++++++++-- .../Features/PluginHostWindowServices.swift | 2 +- .../Features/PluginWindowHostService.swift | 99 ++++++++ .../Features/PluginWorkspaceContexts.swift | 12 + .../AppModel+EditorDocumentService.swift | 5 +- .../AppModel/AppModel+PluginHostWindow.swift | 11 +- .../AppModel/AppModel+UIContributions.swift | 3 +- .../Platform/MacOS/MacServiceContainer.swift | 14 +- .../Plugins/MacPluginBackendSessions.swift | 156 +++++++++---- .../Lithe/Views/Editor/EditorAreaView.swift | 29 +-- .../Views/Search/SearchEverywhereView.swift | 4 +- .../Plugins/DeclarativePluginContracts.swift | 4 +- .../Runtime/LanguageServerSession.swift | 24 +- .../LitheModuleAPI/Plugins/PluginTypes.swift | 2 +- .../Fixtures/PluginHostAPI/backend.mjs | 67 ++++++ .../MacPluginBackendSessionsTests.swift | 88 ++++++- .../PluginHostEditorServiceTests.swift | 7 +- .../PluginHostProcessIntegrationTests.swift | 101 ++++++++ .../PluginHostServiceDispatchTests.swift | 2 +- .../LitheTests/PluginHostTestSupport.swift | 7 +- .../LitheTests/PluginPublicHostAPITests.swift | 153 ++++++++++++ .../PluginWorkspaceFileServiceTests.swift | 40 ++++ rust/lithe-core/src/lsp/interface/engine.rs | 24 +- .../src/lsp/interface/plugin_function.rs | 220 +++++++++++++++++- rust/lithe-core/src/plugins/capabilities.rs | 2 +- rust/lithe-core/src/plugins/declarative.rs | 57 ++++- .../src/tests/declarative_plugins.rs | 23 ++ .../resources/reuse-worktree-resources.mjs | 2 + .../test-reuse-worktree-resources.mjs | 2 +- scripts/resources/worktree-resources.json | 10 + shared/contracts/plugins/v1/README.md | 99 +++++++- .../v1/fixtures/command-backend.plugin.json | 50 ++++ .../v1/fixtures/function-protocol.json | 5 + .../plugins/v1/plugin-manifest.schema.json | 20 +- shared/contracts/rust-core-api.md | 11 +- shared/i18n/locales/en-US.json | 4 +- shared/i18n/locales/zh-CN.json | 4 +- shared/i18n/messages.json | 22 ++ .../plugin-backend-workspace-context.json | 5 +- ...ugin-host-editor-file-window-services.json | 14 +- 57 files changed, 1961 insertions(+), 172 deletions(-) create mode 100644 macos/Sources/Lithe/Application/Features/PluginDocumentHostService.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginFileHostService.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginHostAPIValues.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginWindowHostService.swift create mode 100644 macos/Tests/LitheTests/Fixtures/PluginHostAPI/backend.mjs create mode 100644 macos/Tests/LitheTests/PluginHostProcessIntegrationTests.swift create mode 100644 macos/Tests/LitheTests/PluginPublicHostAPITests.swift create mode 100644 shared/contracts/plugins/v1/fixtures/command-backend.plugin.json diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md index ec26944934..cd75f7d65c 100644 --- a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md @@ -35,8 +35,8 @@ M0 计划(`docs/development/macos-plugin-api-plan.md` 第 4 节)要求插件 理由如下: -- 后端持有的东西(数据库连接、索引缓存、登录态)属于用户,不属于某个工程。按工作区拆进程,就要为每个窗口重复启动、重复认证,内存也会成倍增长。 -- 同一个进程里,靠上下文参数隔离已经足够。宿主对每个请求都会核验上下文是否仍然有效,后端无法自己拼出一个上下文来越权。 +- 数据库连接和登录态可以属于用户;索引、项目缓存和订阅仍属于显式工作区上下文。共享进程减少重复启动与认证,但不是隔离或授权边界。 +- 宿主对每个请求核验上下文是否有效以及该方法是否获授权。原生后端有用户级 OS 权限,且 workspace.metadata 允许枚举全部上下文;不能把上下文检查宣传成 OS 沙盒或逐工程授权。 - `${workspaceStateDir}` 在 v2 里继续拒绝,因为进程启动时没有“当前工作区”可言。每个工作区的状态目录改由上下文对象里的 `stateDirectory` 给出。 正确的做法是:网页调用时,宿主从该网页所在窗口取上下文(`PluginWebBridgeHost.pluginWebWorkspaceContext`),再经 `lsp.functionRequest.context` 送到后端。不要这样做:后端在收不到上下文时,去取“最近打开的工作区”。 @@ -75,7 +75,7 @@ rootID 用按插件加密钥的 HMAC,而不是位置的普通哈希:普通 ### 激活事件与启动预算 -- `onWorkspaceOpen`:窗口打开工作区后,在下一个主线程任务里启动,不阻塞打开流程。多个插件按 ID 排序后串行启动,一次只启动一个。每个插件沿用 60 秒的 initialize 超时,超时或失败只记日志(`plugins.backend`),不会挡住工作区。 +- `onWorkspaceOpen`:窗口打开工作区后,在下一个主线程任务里启动,不阻塞打开流程。后台激活按 ID 入队,最多同时启动两个插件并去重。用户命令独立等待自己的启动,不排在其他插件的后台队列后面。每个插件沿用 60 秒的 initialize 超时,超时或失败只记日志(`plugins.backend`),不会挡住工作区。 - `onCommand:`:在 `performToolWindowContribution` 里,命令贡献的 `actionID` 就是命令 ID;只启动所属 UI 模块为 `.ui` 的那个插件。窗口照常打开,不等待后端。命令本身经 `lithe/host/commands/didRun { commandID, context? }` 送到 `backend: 2` 的后端:已就绪的后端立即按通知顺序收到;正在启动的排在启动之后;未运行的只有声明了该命令的 `onCommand:` 才会被启动并在就绪后收到。`backend: 1` 不收命令通知。 - 热启用:`enable` 和 `register` 时如果已经有工作区打开,声明 `onWorkspaceOpen` 的插件立即启动,等同于工作区刚打开,不必等下一个窗口。新安装带后端的包仍需重启(`canActivateWithoutRestart`),这一点不在本次范围。 - 不接受 `onStartupFinished` 和 `*`。“应用一启动就跑”的后端没有用户可感知的触发点,会拖慢所有用户的启动。 @@ -101,7 +101,7 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del - `lithe/host/subscriptions/notify { subscriptionID, sequence, event }`:`PluginHostSubscriptions.notify` 为每个订阅单独递增 `sequence`(从 1 起),已释放的订阅不再发送。服务通过 dispatcher 的 `subscriptions` 发事件,不直接找会话。 - `lithe/host/commands/didRun { commandID, context? }`:见上面的 `onCommand`。 -宿主按插件串行发送通知(每个运行中会话一个 Task 链),停止会话时取消未发出的通知,保证同一后端收到的顺序与宿主发出的顺序一致。参数序列化后不超过 1 MiB(`limits.notificationBytes`)。 +宿主按插件串行发送通知(每个运行中会话一个有界队列和发送任务),停止会话时取消未发出的通知,除可合并的工作区 revision 外保持队列顺序;已释放订阅和过期 context 的命令不再发送。参数序列化后不超过 1 MiB(`limits.notificationBytes`)。 ### 协议 3 内只增不改 @@ -115,6 +115,32 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del 这样改的原因:插件后端调用宿主属于运行时能力,本来就靠协商和发现判断能不能用([按能力分版本](2026-10-17-plugin-capability-versions.md):“进程启动后还能做什么,走它自己的协议协商”)。如果每加一批服务都升一次版本,SDK 和插件要同时维护很多个版本分支,而版本号能表达的信息 `getCapabilities` 已经全部给出。用户在 PR #1595 评审时确认了这个取舍。 +### 公共服务接线与生命周期补全 + +编辑器、文件和窗口三组内部服务已通过 `PluginDocumentHostService`、 +`PluginFileHostService` 和 `PluginWindowHostService` 接入公共分发。插件只提供 +`PluginWorkspaceContext.reference` 与资源 ID;统一 resolver 找到拥有该 context +的弱引用窗口,再把内部 scope 值交给既有 owner。内部类型不是第二份可伪造的授权真源。 + +- 新权限展开为具体方法的 grant,不以 `workspace` 或 `editor` 整个命名空间放行。 + Core 白名单、宿主已注册方法、会话 grant 三者取交集;metadata 不会因新增文件方法获得读盘权。 +- 每次 beginSession 生成 session token。旧进程回调、取消和结束都必须核验身份,不能碰新进程同名 hostRequestID。 + 进程退出立即清理 dispatcher 和 running entry;initialize 中的命令等 ready,崩溃后的命令可按激活事件重启。 +- 稳定 rootID 只标识资源。文件服务内部 lease 使用 contextID 加 rootID;关闭一个窗口只关闭它的 lease。 + 同一路径的新 context 从 generation 1 开始也能读取,不会被历史 closedGeneration 拒绝。 +- context 关闭取消在途请求并返回 contextExpired;普通机器操作有 30 秒宿主截止时间。 + 等待用户按钮的消息不设总时限,仍随会话、context 和取消回收。 +- 通知队列每会话最多 128 项,工作区 revision 合并为最新值。溢出报告失败并结束会话; + 不静默丢命令。发送前复核订阅/context。通知和响应与函数请求共用发送 watchdog,卡住的 stdin 不阻塞主线程停用。 +- `ui:4` 允许没有工具窗口的后端命令,旧 ui 版本保持不变。命令事件没有业务成功确认, + 不把送出通知当成编辑已完成;有结果的执行将沿用函数请求生命周期。 + +正确做法:同一工程两窗口有相同 rootID,却有不同 context lease;关 A 不影响 B。 +不要这样做:拿 rootID 的全局 closedGeneration 判断 B 是否过期,或者把非空 session 当作 ready。 + +取消/超时不表示回滚,副作用可能已经提交。版本冲突必须重读并重新计算; +contextExpired 必须重新解析原目标,不能换成活动窗口。新协议不允许 SDK 自动重放修改。 + ### 工作区信任 工作区信任暂不引入,后续单独立项。现阶段沿用按操作确认,例如网页启动进程前的 `pluginWebConfirmProcessStart`。契约 README 里原来写着“workspace trust”的地方,已经改成如实描述:按操作确认,目前没有工作区信任状态。 @@ -167,7 +193,7 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del 收益: - 没有网页的插件也能运行后端。 -- 三个切片只需要实现一个协议并注册一次,不改契约,也不改 Core 的能力表。 +- 服务实现注册到按方法分发的宿主入口;正式方法、权限和数据形状仍须与 Core 和契约同步。 - 后端能明确知道每次调用属于哪个工作区,并在工作区过期时得到提示。 - 远程工作区不会被误读。 @@ -178,7 +204,7 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del - 每加一个新服务,都要同时更新 Core 的 `HOST_METHODS`(以及有通知时的 `HOST_NOTIFICATIONS`)和契约 README。新增的服务、选项和错误码都留在协议 3 内,不能顺手改已有方法或字段的含义,否则就要出协议 4。 - 同为协议 3,不同宿主能用的方法可能不同,后端和 SDK 必须按 `getCapabilities` 判断,不能只看版本号。 - 后端拿到 `listContexts` 后,要用 `getContext` 才能得到某个工作区的 `stateDirectory`。 -- `onWorkspaceOpen` 后端串行启动。插件多的时候,最后一个插件就绪得较晚,但这不影响工作区本身。 +- 后台启动并发有界,但进程仍可能很慢;用户命令不等待无关后台插件,失败通过激活错误回调记录。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md index d1a9a22f00..fd5199bb07 100644 --- a/.agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md @@ -4,11 +4,11 @@ ## 先说结论 -macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`preview.openHTML`、`browser.openExternal`)和 API-17 的最小部分(`window.showInformation/Warning/Error`)先在宿主里做成**内部服务**:一组 Swift 协议加实现,不对插件公开,也不碰插件契约。每次调用都必须显式带上“哪个插件、哪个窗口、哪个工作区(含代际)”,服务不会回退到“当前活动窗口”;资源用“根 ID + 工作区相对路径”表示,不收绝对路径。 +macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`preview.openHTML`、`browser.openExternal`)和 API-17 的三种消息(`window.showInformation/Warning/Error`)由内部 Swift 服务复用现有窗口 owner,并通过 `PluginWindowHostService` 对协议 3 插件开放。每次调用都必须显式带上“哪个插件、哪个窗口、哪个工作区(含代际)”,服务不会回退到“当前活动窗口”;资源用“根 ID + 工作区相对路径”表示,不收绝对路径。 三条最容易被改坏的规则:已经打开的文档,不管有没有未保存修改,都不会被磁盘内容替换;后台窗口的调用只在它自己的窗口里干活,不抢焦点;项目 HTML 和外部导航永远拿不到插件 bridge(页面与宿主通信的消息通道)。`editor.open` 用 `.select` 激活时,会像点击标签一样把文档切成前台标签,这是插件读选区、改文本前必须先做的一步。 -后续 M0(激活与作用域协议)把这些服务接到插件调用分发和权限授予上;本篇不涉及授权,也不做错误码映射。 +公共分发按具体方法授权,统一解析 M0 的 context;`PluginHostAPIValues` 将内部失败映射到稳定错误分类和结构化 details。 ## 问题 @@ -22,7 +22,7 @@ macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`previ ### 1. 窗口端口加作用域闸门,服务只依赖协议 -`PluginHostWindowPort`(一个窗口对宿主服务暴露的最小能力)由 `AppModel` 在 `AppModel+PluginHostWindow.swift` 里实现;`PluginHostWindowDirectory`(按窗口 ID 找窗口的目录)由 `ProjectSessionManager` 实现。服务只持有目录,每次调用先过 `PluginHostScopeGate`: +`PluginHostWindowPort`(一个窗口对宿主服务暴露的最小能力)由 `AppModel` 在 `AppModel+PluginHostWindow.swift` 里实现;`PluginHostWindowDirectory`(按窗口 ID 找窗口的目录)公共路径由 `PluginContextWindowDirectory` 按活跃 context 的弱引用 owner 实现;`ProjectSessionManager` 仍保留内部目录适配。服务只持有目录,每次调用先过 `PluginHostScopeGate`: - 窗口不存在或没有工作区:`windowUnavailable`。 - 工作区是远程的:`remoteWorkspaceUnsupported`,在检查路径之前就拒绝,插件不会从错误信息里得知远端有哪些文件。 @@ -44,7 +44,7 @@ macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`previ `PluginHostEditorService.open` 的顺序:闸门、选项检查(`tab == .preview` 当前不支持,明确拒绝,不降级成普通标签)、范围校验(行列从 1 开始,结束不早于开始)、解析资源、读打开状态、再过闸门、交给窗口打开、再过闸门、放置光标。 - 已打开的文档只会被激活,不会重新读盘。未保存的缓冲区保持原样,这是 API-15 的硬要求,对应测试 `anOpenDirtyBufferIsKeptAndNeverReloadedFromDisk`。 -- `activation == .select`:加载(如需要)后调用 `selectEditorTab(.document(id))`,效果等同用户点击标签:跨编辑器分组、盖在 diff / 终端 / Agent 标签之上也会切到前台。结果里的 `document.isActive` 就是这个标签是否在前台。 +- `activation == .select`:加载(如需要)后调用 `selectEditorTab(.document(id))`,效果等同用户点击标签:跨编辑器分组、盖在 diff / 终端 / Agent 标签之上也会切到前台。结果里的 `document.isActive` 就是这个标签是否在前台。公共返回还包含 documentID 和 contentVersion,后续读选区/编辑不必按 URL 猜文档。 - `activation == .background`:只保证文档已打开,不改前台标签,也不放光标。 - `reveal`:文件没打开时返回 `documentNotOpen`,不替插件加载文件。 - 范围只落到起点:编辑器的导航目标(`EditorNavigationTarget`)没有“结束位置”,所以插件传了范围也只放光标,结果用 `appliedSelection == .caret(起点)` 明说,不假装选中了。 @@ -53,7 +53,7 @@ macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`previ ### 4. `preview.openHTML` -内嵌预览是文件自己的编辑器标签里的一个面板,吃的是编辑器的当前缓冲区,所以未保存的修改会显示在预览里(结果的 `usesLiveBuffer` 永远是 `true`,用来和系统浏览器对照)。服务只做两件事:检查扩展名是 `.html` / `.htm`(不区分大小写,否则 `notHTMLDocument`),再走 `editor.open` 的 `.select` 路径。“只显示预览”(`.previewOnly`)当前是每个标签视图里的 `@State`,服务没法设置,所以明确拒绝为 `unsupportedOption`。 +内嵌预览是文件自己的编辑器标签里的一个面板,吃的是编辑器的当前缓冲区,所以未保存的修改会显示在预览里(结果的 `usesLiveBuffer` 永远是 `true`,用来和系统浏览器对照)。服务先做两件事:检查扩展名是 `.html` / `.htm`(不区分大小写,否则 `notHTMLDocument`),再走 `editor.open` 的 `.select` 路径。呈现模式已移到 `EditorTabOrderFeatureModel`,保留原来的文档级选择;openHTML 显式设置 split 或 preview,所以用户先前选过仅编辑器也不会假成功。 ### 5. `browser.openExternal` 与未保存状态 @@ -105,7 +105,7 @@ macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`previ ### 备选方案三:`.preview` 标签和 `.previewOnly` 先做近似实现 -可以把 `.preview` 当普通标签、`.previewOnly` 当并排预览,插件调用不会失败。没有采用,因为近似实现会让插件以为得到了可替换标签或纯预览,之后一旦真实支持就是行为变更。现在明确返回 `unsupportedOption`,等编辑器有可替换标签和可设置的预览模式后再放开。 +可以把 `.preview` 当普通标签、`.previewOnly` 当并排预览,插件调用不会失败。没有采用,因为近似实现会让插件以为得到了可替换标签或纯预览,之后一旦真实支持就是行为变更。可替换标签仍明确返回 `unsupportedOption`;纯预览已通过功能模型的真实呈现状态实现,不再近似。 ### 备选方案四:外部浏览器打开前强制保存 @@ -114,7 +114,7 @@ macOS 插件 API 计划里的 API-15(`editor.open/reveal`)、API-16(`previ ## 后果 - 收益:窗口和工作区在每个等待点都被复核;已打开文档的未保存修改不会丢;外部浏览器的可启动目标有明确的白名单;预览与插件 bridge 隔离有回归测试守着。 -- 代价:多了一层协议和假对象;范围只落到光标;没有可替换的预览标签;消息堆叠在左下角,可能盖住左侧工具栏的一部分。 +- 代价:多了一层协议和假对象;范围只落到光标;没有可替换的预览标签;纯预览模式已接入;消息堆叠在左下角,可能盖住左侧工具栏的一部分。 - 已知限制:路径检查和打开之间存在竞态(先检查再打开,之间文件可被替换成指向外部的链接);大小写或 Unicode 规范化不同的路径别名可能产生第二个缓冲区;媒体和二进制文件返回 `openFailed`;`loadFile` 失败时可能在目标窗口弹出“无法打开”的通知。 - 需要重新评估的触发条件:编辑器支持范围选择或可替换预览标签;预览配置新增脚本处理器;窗口之外的文件根(多根工作区)进入 `PluginHostRoot` 列表。 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index ca607cc962..b6b4001d6b 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -803,6 +803,12 @@ Java Support(`dev.lithe.plugin.java-support`)是过渡期唯一随 Lithe.app 任何复制阶段都不得复用或共享。`excludedResources.plugin-web-state` 通过复用脚本的 排除路由直接拒绝,由 `scripts/resources/test-reuse-worktree-resources.mjs` 验证,不能给它增加缓存校验器。 +插件宿主的 `/Lithe/PluginHost/root-id.key` 是安装级密钥, +`/Lithe/PluginState//backend/workspaces//` +是按插件、工作区归属的可变状态。二者都没有版本/平台/架构/工具链复用身份, +任何复制阶段都不得共享;`excludedResources.plugin-host-runtime-state` 走拒绝路由, +由资源复用测试验证。运行期仅写平台 Application Support,不改签名包或 Sparkle 源基线。 + 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 生成资源只有在构建流程写入可验证的源码、配置、平台、架构和工具链 identity stamp 后才能加入注册表,不能仅凭目录存在就跨 worktree 复制。 diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index 4408038edc..5b3ddf90b8 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -2,6 +2,17 @@ 状态:待实施计划。盘点日期:2026-10-28;2026-10-30 按 preview `e8c4e9fc9` 刷新(#1517、#1515 已合并)。 +## 当前实现进度(PR #1595) + +本轮已将原有三组宿主内部服务注册为协议 3 的公共方法;`ui:4` 提供不打开 +工具窗口的命令。逐方法授权、上下文 lease、编辑器返回 documentID/version、 +真实 HTML 呈现模式、后端初始化/退出/通知背压也已接入。正式参数和限制以 +`shared/contracts/plugins/v1/README.md` 为准,历史盘点表不是当前能力表。 + +尚未交付的部分包括:官方 SDK 新 API 的类型封装与发布、通用原生 Tree 后端 +数据源、精确全文搜索匹配范围、文件修改/保存事务、可替换预览标签和 Windows +适配。三个完整安装插件的 UI 验收尚未全部完成,不把分层集成测试等同于整机验收。 + ## 1. 目标与范围 让第三方开发者不修改 Lithe 源码,就能完成“读取选区并转换文本”“显示工程文件树”“打开 HTML 预览”等完整工作流。优先把 Mac 已有功能接成有权限、版本、生命周期和文档的公共 API,而不是重写 IDE 或先换插件运行时。 diff --git a/macos/Experiments/Monaco/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index 9a8333717d..13e1933afc 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -295,6 +295,35 @@ async function verify() { await send({ type: "open", text: source }); await window.lithe.activate({ id: "A", text: source, revision: 0, language: "java", readonly: false, focus: false }); const editor = monacoEditor.getEditors()[0]; + await check("plugin edit bridge preserves dirty Unicode, CRLF and a single real undo", async () => { + const id = "plugin-edit", text = "dirty 😀\r\n中文"; + await send({ type: "open", id, text }); + await window.lithe.activate({ id, text, revision: 0, language: "plaintext", readonly: false }); + const target = editor.getModel()!; + try { + editor.setSelections([new Selection(1, 1, 1, 6), new Selection(2, 1, 2, 3)]); + const selected = await withDeadline(window.lithe.pluginSelections({ id }), "plugin selections timed out") as any; + assert(selected.status === "ok" && selected.selections.length === 2, "plugin lost multiple selections"); + const edits = [ + { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 6, text: "DONE\n!" }, + { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 3, text: "文字" }, + ]; + const applied = await withDeadline(window.lithe.applyPluginEdits({ id, revision: selected.revision, + token: "plugin-batch", eol: "\r\n", edits }), "plugin edits timed out") as any; + assert(applied.status === "applied", `plugin edits rejected: ${applied.status}`); + const native = await send({ type: "fixtureSnapshot", id }); + assert(native.text === "DONE\r\n! 😀\r\n文字", "plugin edits diverged from exact native source"); + const stale = await window.lithe.applyPluginEdits({ id, revision: selected.revision, token: "stale", eol: "\r\n", edits }); + assert(stale.status === "versionConflict", "stale plugin edit overwrote current input"); + await target.undo(); + const undone = await withDeadline(window.lithe.freeze(id), "plugin undo timed out") as any; + assert(undone.text === text, "one real Monaco undo did not restore the whole plugin batch"); + } finally { + window.lithe.unlock(id); + await window.lithe.retain(["A"]); + await window.lithe.activate({ id: "A", text: source, revision: 0, language: "java", readonly: false }); + } + }); await check("editor context menu routes existing Monaco actions to the native host", async () => { const service = StandaloneServices.get(IContextMenuService) as any; let listener: { dispose(): void } | undefined; diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 51a06917fe..025d3fbe35 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3971,6 +3971,8 @@ "The plugin backend stopped before it was ready." = "The plugin backend stopped before it was ready."; "The plugin does not provide the debug adapter %@." = "The plugin does not provide the debug adapter %@."; "The plugin download address is invalid." = "The plugin download address is invalid."; +"The plugin host request could not be completed." = "The plugin host request could not be completed."; +"The plugin host request parameters are invalid." = "The plugin host request parameters are invalid."; "The plugin is not allowed to use the host service %@." = "The plugin is not allowed to use the host service %@."; "The plugin launch refers to %@, which is not a file inside its package." = "The plugin launch refers to %@, which is not a file inside its package."; "The plugin package changed while it was being copied. Choose it again." = "The plugin package changed while it was being copied. Choose it again."; @@ -9254,6 +9256,8 @@ "npm was not found. It is normally installed with Node.js." = "npm was not found. It is normally installed with Node.js."; "or" = "or"; "outline.expandAll" = "Expand All"; +"plugin.host.invalidParameters" = "The plugin host request parameters are invalid."; +"plugin.host.requestFailed" = "The plugin host request could not be completed."; "plugins.java.installNotice" = "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."; "plugins.java.installTemplates" = "Install Java Support for Java, Maven and Spring templates…"; "plugins.title" = "Plugins"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 2734462746..23a799d48f 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3971,6 +3971,8 @@ "The plugin backend stopped before it was ready." = "插件后端在就绪前已停止。"; "The plugin does not provide the debug adapter %@." = "插件没有提供调试适配器 %@。"; "The plugin download address is invalid." = "插件下载地址无效。"; +"The plugin host request could not be completed." = "未能完成插件宿主请求。"; +"The plugin host request parameters are invalid." = "插件宿主请求的参数无效。"; "The plugin is not allowed to use the host service %@." = "插件无权使用宿主服务 %@。"; "The plugin launch refers to %@, which is not a file inside its package." = "插件启动项引用了 %@,它不是插件包内的文件。"; "The plugin package changed while it was being copied. Choose it again." = "插件包在复制过程中发生了变化。请重新选择。"; @@ -9254,6 +9256,8 @@ "npm was not found. It is normally installed with Node.js." = "未找到 npm。它通常随 Node.js 一起安装。"; "or" = "或"; "outline.expandAll" = "全部展开"; +"plugin.host.invalidParameters" = "插件宿主请求的参数无效。"; +"plugin.host.requestFailed" = "未能完成插件宿主请求。"; "plugins.java.installNotice" = "Java Support 现已独立安装。请从插件市场安装,或使用“从磁盘安装”导入离线包。原有 Java 设置会保留。"; "plugins.java.installTemplates" = "安装 Java Support,获取 Java、Maven 和 Spring 项目模板…"; "plugins.title" = "插件"; diff --git a/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift b/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift index b4bd97a564..f81aa5af05 100644 --- a/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift +++ b/macos/Sources/Lithe/Application/Features/EditorDocumentService.swift @@ -119,6 +119,7 @@ final class EditorDocumentService: EditorDocumentServicing { func applyEdits(scope: EditorDocumentScope, documentID: UUID, expectedVersion: UInt64, edits: [EditorTextEdit]) async throws -> EditorDocumentEditResult { + try Task.checkCancellation() let document = try resolve(scope, documentID) guard !document.isReadOnly else { throw EditorDocumentServiceError.readOnly } guard document.contentVersion == expectedVersion else { diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index 512774b33a..4f5826fc75 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -1,6 +1,31 @@ import Combine import Foundation +enum DocumentPreviewMode: String, CaseIterable, Identifiable, Equatable { + case editor + case split + case preview + + var id: String { rawValue } + + var title: String { + switch self { + case .editor: "Editor" + case .split: "Editor and Preview" + case .preview: "Preview" + } + } + + var symbolName: String { + switch self { + case .editor: "pencil.line" + case .split: "rectangle.split.2x1" + case .preview: "doc.richtext" + } + } +} + + struct AgentEditorConversation: Equatable { let agentID: String let sessionID: String? @@ -11,6 +36,7 @@ struct AgentEditorConversation: Equatable { /// selection remain with their existing features. @MainActor final class EditorTabOrderFeatureModel: ObservableObject { + @Published var documentPreviewModes: [UUID: DocumentPreviewMode] = [:] @Published private(set) var items: [EditorTabItem] = [] { didSet { reconcileGroups() diff --git a/macos/Sources/Lithe/Application/Features/PluginDocumentHostService.swift b/macos/Sources/Lithe/Application/Features/PluginDocumentHostService.swift new file mode 100644 index 0000000000..f69a16a5e2 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginDocumentHostService.swift @@ -0,0 +1,85 @@ +import Foundation +import LitheCoreContracts + +/// Public document/editor methods reuse the existing live-buffer owner and +/// Monaco undo boundary. No disk fallback is allowed for a missing editor. +@MainActor +final class PluginDocumentHostService: PluginHostServiceHandling { + let service = "editor" + let methods: Set = Set([ + "documents/list", "documents/getSnapshot", "editor/getActive", "editor/getSelections", + "editor/applyEdits", "editor/replaceSelections", "editor/insertText", + ].map { "lithe/host/" + $0 }) + private let contexts: PluginWorkspaceContexts + + init(contexts: PluginWorkspaceContexts) { self.contexts = contexts } + + func answer(_ call: PluginHostCall) async -> Result { + do { + let scope = try PluginHostAPIContext(call, contexts: contexts) + guard let editor = scope.window.hostDocumentService else { throw EditorDocumentServiceError.editorUnavailable } + let method = String(call.request.method.split(separator: "/").last ?? "") + let allowed: Set + switch method { + case "list", "getActive": allowed = ["context"] + case "getSnapshot", "getSelections": allowed = ["context", "documentID"] + case "applyEdits": allowed = ["context", "documentID", "expectedVersion", "edits"] + case "insertText": allowed = ["context", "documentID", "expectedVersion", "at", "text"] + case "replaceSelections": allowed = ["context", "documentID", "expectedVersion", "selections", "replacements"] + default: throw PluginHostArguments.invalid + } + let args = try PluginHostArguments(call.params, allowed: allowed) + if method == "list" { return .success(.object(["documents": .array(try editor.listDocuments(scope: scope.editorScope).map(PluginHostAPIValues.summary))])) } + if method == "getActive" { return .success(try editor.activeDocument(scope: scope.editorScope).map(PluginHostAPIValues.summary) ?? .null) } + guard let id = UUID(uuidString: try args.string("documentID")) else { throw PluginHostArguments.invalid } + if method == "getSnapshot" { + let result = try editor.snapshot(scope: scope.editorScope, documentID: id) + guard result.text.utf8.count <= 128 * 1024 else { throw PluginWorkspaceFileError.limitExceeded(.directoryEntries) } + return .success(.object(["document": PluginHostAPIValues.summary(result.summary), "text": .string(result.text)])) + } + if method == "getSelections" { + let result = try await editor.selections(scope: scope.editorScope, documentID: id) + _ = try PluginHostAPIContext(call, contexts: contexts) + return .success(.object([ + "documentID": .string(id.uuidString), "version": .integer(Int(clamping: result.version)), + "primaryIndex": result.primaryIndex.map(ToolingJSONValue.integer) ?? .null, + "selections": .array(result.selections.map { .object([ + "anchor": PluginHostAPIValues.position($0.anchor), "active": PluginHostAPIValues.position($0.active), + ]) }), + ])) + } + let version = UInt64(try args.integer("expectedVersion")) + let result: EditorDocumentEditResult + switch method { + case "applyEdits": + let values = try args.array("edits") + guard values.count <= 1_000 else { throw PluginWorkspaceFileError.limitExceeded(.directoryEntries) } + let edits = try values.map { value -> EditorTextEdit in + guard case .object(let object) = value else { throw PluginHostArguments.invalid } + let edit = try PluginHostArguments(object, allowed: ["range", "text"]) + return try EditorTextEdit(range: PluginHostArguments.range(edit.values["range"] ?? .null), text: edit.string("text")) + } + result = try await editor.applyEdits(scope: scope.editorScope, documentID: id, expectedVersion: version, edits: edits) + case "insertText": + result = try await editor.insertText(scope: scope.editorScope, documentID: id, expectedVersion: version, + at: PluginHostArguments.position(args.values["at"] ?? .null), text: args.string("text")) + default: + let values = try args.array("selections") + guard values.count <= 1_000 else { throw PluginWorkspaceFileError.limitExceeded(.directoryEntries) } + let selections = try values.map { value -> EditorSelection in + guard case .object(let object) = value else { throw PluginHostArguments.invalid } + let item = try PluginHostArguments(object, allowed: ["anchor", "active"]) + return try EditorSelection(anchor: PluginHostArguments.position(item.values["anchor"] ?? .null), + active: PluginHostArguments.position(item.values["active"] ?? .null)) + } + let replacements = try args.array("replacements").map { value -> String in + guard case .string(let text) = value else { throw PluginHostArguments.invalid }; return text + } + result = try await editor.replaceSelections(scope: scope.editorScope, + selections: EditorSelectionsSnapshot(documentID: id, version: version, selections: selections, primaryIndex: nil), + replacements: replacements) + } + return .success(.object(["documentID": .string(id.uuidString), "version": .integer(Int(clamping: result.version)), "applied": .bool(result.applied)])) + } catch { return .failure(PluginHostAPIValues.failure(error)) } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginFileHostService.swift b/macos/Sources/Lithe/Application/Features/PluginFileHostService.swift new file mode 100644 index 0000000000..491ef8621b --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginFileHostService.swift @@ -0,0 +1,165 @@ +import Foundation +import LitheCoreContracts + +/// Adapts context-owned file operations to the public resource format. Stable +/// root IDs are never used as lifetime keys: reopening a folder gets a new lease. +@MainActor +final class PluginFileHostService: PluginHostServiceHandling { + let service = "workspace" + let methods = Set(["readDirectory", "stat", "readFile", "watch", "findFiles", "findText"].map { "lithe/host/workspace/" + $0 }) + private let contexts: PluginWorkspaceContexts + private let metadata: PluginWorkspaceHostService + private let files: any PluginWorkspaceFileAccess + private var leases: [String: [String: UInt64]] = [:] + + init(contexts: PluginWorkspaceContexts, metadata: PluginWorkspaceHostService, files: any PluginWorkspaceFileAccess) { + self.contexts = contexts + self.metadata = metadata + self.files = files + } + + func endSession(pluginID: String, sessionID: String) { + files.releaseResources(of: PluginWorkspaceCaller(pluginID: pluginID, sessionID: sessionID)) + } + + func closeContext(_ contextID: String) { + for (root, generation) in leases.removeValue(forKey: contextID) ?? [:] { + files.closeWorkspace(rootID: root, generation: generation) + } + } + + func answer(_ call: PluginHostCall) async -> Result { + do { + let scope = try PluginHostAPIContext(call, contexts: contexts) + let method = String(call.request.method.split(separator: "/").last ?? "") + var allowed: Set = ["context", "resource"] + switch method { + case "readDirectory": allowed.formUnion(["cursor", "limit"]) + case "readFile": allowed.insert("maximumBytes") + case "watch": allowed.insert("recursive") + case "findFiles": allowed.formUnion(["query", "fileMask", "cursor", "limit"]) + case "findText": allowed.formUnion(["pattern", "fileMask", "caseSensitive", "wholeWords", "regularExpression", "cursor", "limit"]) + case "stat": break + default: throw PluginHostArguments.invalid + } + let args = try PluginHostArguments(call.params, allowed: allowed) + let resource = try PluginHostArguments(args.object("resource"), allowed: ["rootID", "path"]) + let publicRoot = try resource.string("rootID"), path = try resource.string("path") + guard let folder = scope.workspace.folders.first(where: { metadata.rootID(of: $0, pluginID: call.pluginID) == publicRoot }) else { + throw PluginHostError.unknownRoot + } + let lease = scope.workspace.contextID + ":" + publicRoot + let generation = UInt64(scope.workspace.generation) + leases[scope.workspace.contextID, default: [:]][lease] = generation + let root = PluginWorkspaceRoot(id: lease, url: folder.localURL, authority: .local, + generation: generation, visibilityRules: scope.window.hostVisibilityRules) + let caller = PluginWorkspaceCaller(pluginID: call.pluginID, sessionID: call.sessionID) + let cursor = try args.optionalString("cursor") + let result: ToolingJSONValue + switch method { + case "readDirectory": + let page = try await files.readDirectory(path, in: root, caller: caller, cursor: cursor, + limit: args.integer("limit", default: 200)) + result = .object(["entries": .array(page.entries.map { .object([ + "name": .string($0.name), "resource": Self.resource(publicRoot, $0.resource.path), "kind": .string($0.kind.rawValue), + ]) }), "nextCursor": page.nextCursor.map(ToolingJSONValue.string) ?? .null]) + case "stat": + let stat = try await files.stat(path, in: root, caller: caller) + result = .object(["resource": Self.resource(publicRoot, stat.resource.path), "kind": .string(stat.kind.rawValue), + "size": .integer(Int(clamping: stat.size)), "modifiedMilliseconds": .number(stat.modificationDate.timeIntervalSince1970 * 1_000)]) + case "readFile": + // Worst-case JSON escaping is six bytes per source byte; retain + // envelope headroom under the shared 1 MiB response bound. + let maximum = try args.integer("maximumBytes", default: 128 * 1024) + guard maximum > 0, maximum <= 128 * 1024 else { throw PluginWorkspaceFileError.limitExceeded(.directoryEntries) } + let contents = try await files.readFile(path, in: root, caller: caller, maximumBytes: maximum) + var object: [String: ToolingJSONValue] = [ + "resource": Self.resource(publicRoot, contents.resource.path), "source": .string("disk"), + "byteCount": .integer(contents.byteCount), "isTruncated": .bool(contents.isTruncated), + "identity": contents.identity.map(ToolingJSONValue.string) ?? .null, + ] + switch contents.content { + case .text(let text, let encoding): object["text"] = .string(text); object["encoding"] = .string(encoding) + case .binary(let data): object["base64"] = .string(data.base64EncodedString()) + } + result = .object(object) + case "watch": + let relay = WatchRelay(subscriptions: call.subscriptions, pluginID: call.pluginID, rootID: publicRoot) + let watch = try files.watch(path, recursive: args.bool("recursive"), in: root, caller: caller) { event in + relay.enqueue(event) + } + let id = call.subscriptions.add(pluginID: call.pluginID, contextID: scope.workspace.contextID) { watch.dispose() } + relay.subscriptionID = id + result = .object(["subscriptionID": .string(id)]) + case "findFiles": + guard path.isEmpty else { throw PluginHostError.unsupportedOption } + let page = try await files.findFiles(PluginWorkspaceFindFilesQuery(query: args.string("query"), fileMask: args.string("fileMask", default: "")), + in: root, caller: caller, cursor: cursor, limit: args.integer("limit", default: 100)) + result = Self.page(page.matches.map { Self.resource(publicRoot, $0.resource.path) }, cursor: page.nextCursor, truncated: page.isTruncated) + default: + guard path.isEmpty else { throw PluginHostError.unsupportedOption } + let query = try PluginWorkspaceFindTextQuery(pattern: args.string("pattern"), caseSensitive: args.bool("caseSensitive"), + wholeWords: args.bool("wholeWords"), regularExpression: args.bool("regularExpression"), fileMask: args.string("fileMask", default: "")) + let page = try await files.findText(query, in: root, caller: caller, cursor: cursor, limit: args.integer("limit", default: 100)) + // The search owner currently reports line/preview, not exact match + // spans. Do not invent a range by re-running a different matcher. + result = Self.page(page.matches.map { .object(["resource": Self.resource(publicRoot, $0.resource.path), + "line": .integer($0.line), "preview": .string($0.preview), "range": .null]) }, cursor: page.nextCursor, truncated: page.isTruncated) + } + _ = try PluginHostAPIContext(call, contexts: contexts) + return .success(result) + } catch { return .failure(PluginHostAPIValues.failure(error)) } + } + + private static func resource(_ root: String, _ path: String) -> ToolingJSONValue { + .object(["rootID": .string(root), "path": .string(path)]) + } + + private static func page(_ matches: [ToolingJSONValue], cursor: String?, truncated: Bool) -> ToolingJSONValue { + .object(["matches": .array(matches), "nextCursor": cursor.map(ToolingJSONValue.string) ?? .null, + "isTruncated": .bool(truncated), "source": .string("disk")]) + } + + private final class WatchRelay: @unchecked Sendable { + @MainActor weak var subscriptions: PluginHostSubscriptions? + let pluginID: String + let rootID: String + @MainActor var subscriptionID: String? + private let lock = NSLock() + private var paths: Set = [] + private var rescan = false + private var scheduled = false + @MainActor + init(subscriptions: PluginHostSubscriptions, pluginID: String, rootID: String) { + self.subscriptions = subscriptions; self.pluginID = pluginID; self.rootID = rootID + } + + /// At most one main-actor delivery is queued even while UI work delays + /// it. Coalescing before the actor hop bounds the transport adapter too. + func enqueue(_ event: PluginWorkspaceWatchEvent) { + let schedule = lock.withLock { + rescan = rescan || event.requiresRescan + if !rescan { paths.formUnion(event.changedPaths); rescan = paths.count > 1_000 } + if rescan { paths.removeAll() } + guard !scheduled else { return false } + scheduled = true + return true + } + if schedule { Task { @MainActor in self.deliver() } } + } + + @MainActor + private func deliver() { + let event = lock.withLock { + let result = (paths.sorted(), rescan) + paths.removeAll(); rescan = false; scheduled = false + return result + } + guard let subscriptionID else { return } + subscriptions?.notify(pluginID: pluginID, subscriptionID: subscriptionID, event: [ + "rootID": .string(rootID), "changedPaths": .array(event.0.map(ToolingJSONValue.string)), + "requiresRescan": .bool(event.1), + ]) + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostAPIValues.swift b/macos/Sources/Lithe/Application/Features/PluginHostAPIValues.swift new file mode 100644 index 0000000000..26484f1588 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginHostAPIValues.swift @@ -0,0 +1,175 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Strict decoding at the public host boundary. Unknown options are rejected +/// before any side effect; the SDK must not assume an ignored option took effect. +struct PluginHostArguments { + let values: [String: ToolingJSONValue] + + init(_ values: [String: ToolingJSONValue], allowed: Set) throws { + if let option = values.keys.sorted().first(where: { !allowed.contains($0) }) { + throw PluginHostServiceError(.unsupportedOption, L10n.string("The host does not support the requested option yet."), + details: ["option": .string(option)]) + } + self.values = values + } + + static var invalid: PluginHostServiceError { + PluginHostServiceError(.invalidParams, L10n.string("The plugin host request parameters are invalid.")) + } + + func string(_ key: String, default fallback: String? = nil) throws -> String { + guard let value = values[key] else { + if let fallback { return fallback } + throw Self.invalid + } + guard case .string(let text) = value else { throw Self.invalid } + return text + } + + func optionalString(_ key: String) throws -> String? { + guard let value = values[key], value != .null else { return nil } + return try string(key) + } + + func integer(_ key: String, default fallback: Int? = nil) throws -> Int { + guard let value = values[key] else { + if let fallback { return fallback } + throw Self.invalid + } + guard case .integer(let number) = value, number >= 0 else { throw Self.invalid } + return number + } + + func bool(_ key: String, default fallback: Bool = false) throws -> Bool { + guard let value = values[key] else { return fallback } + guard case .bool(let flag) = value else { throw Self.invalid } + return flag + } + + func object(_ key: String) throws -> [String: ToolingJSONValue] { + guard case .object(let object)? = values[key] else { throw Self.invalid } + return object + } + + func array(_ key: String) throws -> [ToolingJSONValue] { + guard case .array(let array)? = values[key] else { throw Self.invalid } + return array + } + + static func position(_ value: ToolingJSONValue) throws -> EditorTextPosition { + guard case .object(let object) = value else { throw invalid } + let args = try Self(object, allowed: ["line", "column"]) + let line = try args.integer("line"), column = try args.integer("column") + guard line > 0, line <= 10_000_000, column > 0, column <= 1_000_000 else { throw invalid } + return EditorTextPosition(line: line, column: column) + } + + static func range(_ value: ToolingJSONValue) throws -> EditorTextRange { + guard case .object(let object) = value else { throw invalid } + let args = try Self(object, allowed: ["start", "end"]) + let start = try position(args.values["start"] ?? .null), end = try position(args.values["end"] ?? .null) + guard start <= end else { throw invalid } + return EditorTextRange(start: start, end: end) + } +} + +/// Converts internal errors once; neither OS paths nor arbitrary NSError text +/// crosses this boundary. Cancellation does not imply rollback of a committed edit. +enum PluginHostAPIValues { + static func failure(_ error: Error) -> PluginHostServiceError { + if let error = error as? PluginHostServiceError { return error } + let code: PluginHostErrorCode + var details: [String: ToolingJSONValue]? + switch error { + case is CancellationError: code = .cancelled + case let error as EditorDocumentServiceError: + switch error { + case .contextMismatch: code = .contextExpired + case .documentNotFound: code = .notFound + case .readOnly: code = .readOnly + case .versionConflict(let expected, let actual): + code = .versionConflict + details = ["expectedVersion": .integer(Int(clamping: expected)), "currentVersion": .integer(Int(clamping: actual))] + case .compositionInProgress, .editorUnavailable: code = .busy + default: code = .invalidParams + } + case let error as PluginWorkspaceFileError: + switch error { + case .unsupportedAuthority: code = .authorityUnsupported + case .staleWorkspace: code = .contextExpired + case .notFound: code = .notFound + case .permissionDenied, .symbolicLinkNotFollowed: code = .permissionDenied + case .limitExceeded: code = .limitExceeded + case .cancelled: code = .cancelled + case .searchUnavailable: code = .busy + case .io: code = .failed + default: code = .invalidParams + } + case let error as PluginHostError: + switch error { + case .windowUnavailable, .workspaceChanged: code = .contextExpired + case .remoteWorkspaceUnsupported: code = .authorityUnsupported + case .resourceNotFound, .documentNotOpen: code = .notFound + case .outsideWorkspace, .unknownRoot: code = .permissionDenied + case .unsupportedOption: code = .unsupportedOption + case .unsavedChanges: code = .preconditionFailed + case .rateLimited: code = .rateLimited + case .messageQueueFull: code = .limitExceeded + case .browserUnavailable, .openFailed: code = .failed + default: code = .invalidParams + } + default: code = .failed + } + return PluginHostServiceError(code, L10n.string("The plugin host request could not be completed."), details: details) + } + + static func position(_ position: EditorTextPosition) -> ToolingJSONValue { + .object(["line": .integer(position.line), "column": .integer(position.column)]) + } + + static func summary(_ value: EditorDocumentSummary) -> ToolingJSONValue { + .object([ + "documentID": .string(value.documentID.uuidString), "version": .integer(Int(clamping: value.version)), + "path": value.workspaceRelativePath.map(ToolingJSONValue.string) ?? .null, + "languageID": value.languageID.map(ToolingJSONValue.string) ?? .null, + "isReadOnly": .bool(value.isReadOnly), "isDirty": .bool(value.isDirty), + "encoding": .string(value.encoding.rawValue), "lineEnding": .string(value.lineEnding.rawValue), + ]) + } +} + +/// A resolved context plus its current window. All public services use this +/// resolver; legacy internal scope values are derived here, never supplied by a backend. +@MainActor +struct PluginHostAPIContext { + let workspace: PluginWorkspaceContext + let window: any PluginHostWindowPort + let snapshot: PluginHostWindowSnapshot + + init(_ call: PluginHostCall, contexts: PluginWorkspaceContexts) throws { + try Task.checkCancellation() + guard let workspace = call.context else { throw PluginHostArguments.invalid } + _ = try contexts.resolve(workspace.reference).get() + guard workspace.authority == .local else { + throw PluginHostServiceError(.authorityUnsupported, L10n.string("Remote workspaces are not supported by this operation yet.")) + } + guard let window = contexts.owner(of: workspace) as? any PluginHostWindowPort, + let snapshot = window.hostSnapshot, !snapshot.isRemoteWorkspace, + snapshot.workspaceURL == workspace.folders.first?.localURL else { + throw PluginHostServiceError(.contextExpired, L10n.string("The window is closed or has no workspace open.")) + } + self.workspace = workspace + self.window = window + self.snapshot = snapshot + } + + var editorScope: EditorDocumentScope { EditorDocumentScope(windowID: snapshot.windowID, workspaceURL: snapshot.workspaceURL) } + + func windowScope(pluginID: String) -> PluginHostCallContext { + PluginHostCallContext(caller: PluginHostCaller(pluginID: pluginID, pluginName: pluginID), + windowID: snapshot.windowID, workspaceURL: snapshot.workspaceURL, + workspaceGeneration: snapshot.workspaceGeneration) + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift b/macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift index 53217463c8..8ad696c0fd 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostBrowserService.swift @@ -76,6 +76,7 @@ final class PluginHostBrowserService { } func openExternal(_ request: PluginHostExternalOpenRequest) async throws -> PluginHostExternalOpenResult { + try Task.checkCancellation() let scope = try gate.enter(request.context) switch request.target { case .webURL(let raw): @@ -88,6 +89,7 @@ final class PluginHostBrowserService { // The editor first reports input it has not handed over, so a // file the user is typing in is not mistaken for a saved one. let state = await scope.port.hostDocumentState(at: url, flushingEditor: true) + try Task.checkCancellation() let hasUnsavedChanges = state?.hasUnsavedChanges ?? false if hasUnsavedChanges && request.unsaved == .requireSaved { throw PluginHostError.unsavedChanges } _ = try gate.enter(request.context) diff --git a/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift index 3e4ef1cea9..bfe288546e 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostEditorFlush.swift @@ -11,7 +11,7 @@ import Foundation /// caller treats the buffer as possibly newer than it knows. @MainActor enum PluginHostEditorFlush { - static let defaultTimeout: Duration = .seconds(2) + nonisolated static let defaultTimeout: Duration = .seconds(2) /// `request` starts the synchronization and must call its argument once /// with the outcome. Later calls are ignored. diff --git a/macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift b/macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift index e0c85b32d9..64b79c0937 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostEditorService.swift @@ -81,12 +81,14 @@ final class PluginHostEditorService { private func perform( _ request: PluginHostEditorOpenRequest, requiresOpenDocument: Bool ) async throws -> PluginHostEditorOpenResult { + try Task.checkCancellation() let scope = try gate.enter(request.context) guard request.tab == .regular else { throw PluginHostError.unsupportedOption } if let range = request.range { try PluginHostPositionValidation.validate(range) } let url = try resolver.resolveFile(request.resource, roots: scope.snapshot.roots) let before = await scope.port.hostDocumentState(at: url, flushingEditor: false) + try Task.checkCancellation() let wasAlreadyOpen = before?.isOpen == true if requiresOpenDocument && !wasAlreadyOpen { throw PluginHostError.documentNotOpen } _ = try gate.enter(request.context) @@ -96,6 +98,7 @@ final class PluginHostEditorService { } // The window may have closed or switched workspace while the file loaded. let current = try gate.enter(request.context) + try Task.checkCancellation() var applied = PluginHostAppliedSelection.none if request.activation == .select, let start = request.range?.start { diff --git a/macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift b/macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift index 66c9c46fcc..e465f59fbb 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostPreviewService.swift @@ -5,8 +5,7 @@ import Foundation enum PluginHostPreviewPresentation: Equatable, Sendable { /// The editor and the preview side by side, the HTML default. case editorAndPreview - /// The preview alone. The editor area keeps this choice per tab as view - /// state, so a service cannot set it yet; asking for it is refused. + /// The preview alone; the document presentation owner applies this mode. case previewOnly } @@ -48,20 +47,24 @@ final class PluginHostPreviewService { static let htmlExtensions: Set = ["html", "htm"] private let editor: PluginHostEditorService + private let directory: any PluginHostWindowDirectory - init(editor: PluginHostEditorService) { + init(editor: PluginHostEditorService, directory: any PluginHostWindowDirectory) { self.editor = editor + self.directory = directory } func openHTML(_ request: PluginHostPreviewOpenRequest) async throws -> PluginHostPreviewOpenResult { - guard request.presentation == .editorAndPreview else { throw PluginHostError.unsupportedOption } guard Self.isHTML(request.resource.relativePath) else { throw PluginHostError.notHTMLDocument } let result = try await editor.open(PluginHostEditorOpenRequest( context: request.context, resource: request.resource, range: request.range, activation: .select, tab: .regular )) + try Task.checkCancellation() + let scope = try PluginHostScopeGate(directory: directory).enter(request.context) + try scope.port.hostSetHTMLPresentation(at: result.document.url, previewOnly: request.presentation == .previewOnly) return PluginHostPreviewOpenResult( - editor: result, usesLiveBuffer: true, presentation: .editorAndPreview + editor: result, usesLiveBuffer: true, presentation: request.presentation ) } diff --git a/macos/Sources/Lithe/Application/Features/PluginHostScope.swift b/macos/Sources/Lithe/Application/Features/PluginHostScope.swift index 72b1c14359..33ff623942 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostScope.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostScope.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import LitheModuleAPI // Note: 编辑器打开、HTML 预览、外部浏览器和窗口消息的宿主内部服务见 .agents/notes/implemented/architecture/2026-10-30-plugin-host-open-preview-services.md @@ -71,6 +72,8 @@ struct PluginHostWindowSnapshot: Equatable, Sendable { /// One document as the window's document owner holds it. struct PluginHostDocumentState: Equatable, Sendable { + var documentID: UUID? = nil + var version: UInt64? = nil let url: URL /// False when the owner holds no buffer for the file. let isOpen: Bool @@ -101,6 +104,9 @@ enum PluginHostEditorActivation: Equatable, Sendable { /// may make. The AppModel of a project session implements it. @MainActor protocol PluginHostWindowPort: AnyObject { + var hostDocumentService: (any EditorDocumentServicing)? { get } + var hostVisibilityRules: FileVisibilityRules { get } + func hostSetHTMLPresentation(at url: URL, previewOnly: Bool) throws /// nil once the session closed or when no folder is open. var hostSnapshot: PluginHostWindowSnapshot? { get } /// Reads the document state. With `flushingEditor` the editor first @@ -118,12 +124,32 @@ protocol PluginHostWindowPort: AnyObject { func hostNoteMessageLimit(pluginName: String) } +extension PluginHostWindowPort { + var hostDocumentService: (any EditorDocumentServicing)? { nil } + var hostVisibilityRules: FileVisibilityRules { .default } + func hostSetHTMLPresentation(at url: URL, previewOnly: Bool) throws { throw PluginHostError.unsupportedOption } +} + /// Finds a window by the ID a plugin was given for it. @MainActor protocol PluginHostWindowDirectory: AnyObject { func hostWindow(id: UUID) -> (any PluginHostWindowPort)? } +/// Resolves windows only through live contexts; never consults global focus. +@MainActor +final class PluginContextWindowDirectory: PluginHostWindowDirectory { + let contexts: PluginWorkspaceContexts + init(contexts: PluginWorkspaceContexts) { self.contexts = contexts } + func hostWindow(id: UUID) -> (any PluginHostWindowPort)? { + for context in contexts.contexts { + if let port = contexts.owner(of: context) as? any PluginHostWindowPort, + port.hostSnapshot?.windowID == id { return port } + } + return nil + } +} + /// A call accepted by the gate: the window and the snapshot it was /// checked against. @MainActor diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift index 198c7e583c..f52816e7dd 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift @@ -79,6 +79,7 @@ struct PluginHostServiceError: Error, Equatable, Sendable { /// resolved the context the params name, so `context` is current. struct PluginHostCall { let pluginID: String + let sessionID: String let request: PluginHostRequest /// The current context the params named under `context`; nil when they /// name none. @@ -98,9 +99,24 @@ struct PluginHostCall { protocol PluginHostServiceHandling: AnyObject { /// The service name of the methods, such as `workspace`. var service: String { get } + var methods: Set { get } /// Blocking work belongs off the main actor. A cancelled task's result /// is dropped: the backend was already answered. func answer(_ call: PluginHostCall) async -> Result + func endSession(pluginID: String, sessionID: String) + func closeContext(_ contextID: String) +} + +extension PluginHostServiceHandling { + var methods: Set { + switch service { + case "credentials": Set(["get", "set", "delete"].map { "lithe/host/credentials/\($0)" }) + case "workspace": Set(["listContexts", "getContext", "getFolders"].map { "lithe/host/workspace/\($0)" }) + default: [] + } + } + func endSession(pluginID: String, sessionID: String) {} + func closeContext(_ contextID: String) {} } /// Subscriptions services created for backends. Each belongs to one plugin @@ -133,6 +149,10 @@ final class PluginHostSubscriptions { ])) } + func contains(pluginID: String, subscriptionID: String) -> Bool { + entries[pluginID]?[subscriptionID] != nil + } + /// Records a subscription and returns its ID for the backend. func add(pluginID: String, contextID: String?, dispose: @escaping @MainActor () -> Void) -> String { let id = "s-\(nextNumber)" @@ -187,36 +207,83 @@ final class PluginHostServiceDispatcher { let subscriptions = PluginHostSubscriptions() private var handlers: [String: any PluginHostServiceHandling] = [:] private var grants: [String: Set] = [:] - private var inFlight: [String: [String: Task]] = [:] + private var sessionIDs: [String: String] = [:] + private struct Pending { + let task: Task + let deadline: Task? + let contextID: String? + let respond: @MainActor (Result) -> Void + } + private var inFlight: [String: [String: Pending]] = [:] private var contextObserver: UUID? init(contexts: PluginWorkspaceContexts) { self.contexts = contexts contextObserver = contexts.observeClose { [weak self] contextID in - self?.subscriptions.disposeAll(contextID: contextID) + guard let self else { return } + self.subscriptions.disposeAll(contextID: contextID) + for handler in self.uniqueHandlers { handler.closeContext(contextID) } + for (plugin, requests) in self.inFlight { + for (id, pending) in requests where pending.contextID == contextID { + self.inFlight[plugin]?[id] = nil + pending.task.cancel() + pending.deadline?.cancel() + pending.respond(.failure(PluginHostServiceError(.contextExpired, + L10n.string("The workspace context is no longer current. Ask the host for it again.")).failure)) + } + } } } - /// Adds or replaces the handler of its service. + /// Registers exact methods; duplicate ownership is a composition error. func register(_ handler: any PluginHostServiceHandling) { - handlers[handler.service] = handler + for method in handler.methods { + precondition(handlers[method] == nil, "Duplicate plugin host method") + handlers[method] = handler + } + } + + private var uniqueHandlers: [any PluginHostServiceHandling] { + var seen: Set = [] + return handlers.values.filter { seen.insert(ObjectIdentifier($0)).inserted } } var registeredServices: [String] { - handlers.keys.sorted() + Array(Set(uniqueHandlers.map(\.service))).sorted() + } + + func supportedGrants(_ requested: [String]) -> [String] { + requested.filter { grant in + if handlers[grant] != nil { return true } + let legacy: [String] + switch grant { + case "workspace": legacy = ["listContexts", "getContext", "getFolders"] + case "credentials": legacy = ["get", "set", "delete"] + default: return false + } + return legacy.allSatisfy { handlers["lithe/host/\(grant)/\($0)"] != nil } + } } /// Starts serving a session with the host services it was granted. - func beginSession(pluginID: String, hostServices: [String]) { + @discardableResult + func beginSession(pluginID: String, hostServices: [String]) -> String { + endSession(pluginID: pluginID) + let id = UUID().uuidString + sessionIDs[pluginID] = id grants[pluginID] = Set(hostServices) + return id } /// Ends serving a session: the work in flight stops unanswered and its /// subscriptions are disposed. func endSession(pluginID: String) { grants[pluginID] = nil + if let id = sessionIDs.removeValue(forKey: pluginID) { + for handler in uniqueHandlers { handler.endSession(pluginID: pluginID, sessionID: id) } + } if let tasks = inFlight.removeValue(forKey: pluginID) { - for task in tasks.values { task.cancel() } + for pending in tasks.values { pending.task.cancel(); pending.deadline?.cancel() } } subscriptions.disposeAll(pluginID: pluginID) } @@ -226,29 +293,55 @@ final class PluginHostServiceDispatcher { func handle( _ request: PluginHostRequest, pluginID: String, + sessionID: String? = nil, respond: @escaping @MainActor (Result) -> Void ) { + guard let currentSession = sessionIDs[pluginID], sessionID == nil || sessionID == currentSession else { return } + guard inFlight[pluginID]?[request.id] == nil else { return } let task = Task { @MainActor [weak self] in - guard let self else { return } + guard let self, !Task.isCancelled, self.sessionIDs[pluginID] == currentSession else { return } let outcome = await self.dispatch(request, pluginID: pluginID) - guard !Task.isCancelled, self.inFlight[pluginID]?.removeValue(forKey: request.id) != nil else { return } + guard !Task.isCancelled, self.sessionIDs[pluginID] == currentSession, + let pending = self.inFlight[pluginID]?.removeValue(forKey: request.id) else { return } + pending.deadline?.cancel() respond(outcome) } - inFlight[pluginID, default: [:]][request.id] = task + let contextID: String? + if case .object(let params) = request.params, let value = params["context"] { + contextID = Self.contextReference(value)?.contextID + } else { contextID = nil } + // User-choice messages have no clock deadline; their session, context + // and cancellation own dismissal. Machine operations are locally bounded. + let deadline: Task? = request.method.hasPrefix("lithe/host/window/show") ? nil : Task { @MainActor [weak self] in + do { try await Task.sleep(for: .seconds(30)) } catch { return } + guard let self, self.sessionIDs[pluginID] == currentSession, + let pending = self.inFlight[pluginID]?.removeValue(forKey: request.id) else { return } + pending.task.cancel() + pending.respond(.failure(PluginHostServiceError(.timeout, + L10n.string("The plugin host request could not be completed.")).failure)) + } + inFlight[pluginID, default: [:]][request.id] = Pending(task: task, deadline: deadline, contextID: contextID, respond: respond) } /// The backend cancelled the request; Core already answered it. - func cancel(hostRequestID: String, pluginID: String) { - inFlight[pluginID]?.removeValue(forKey: hostRequestID)?.cancel() + func cancel(hostRequestID: String, pluginID: String, sessionID: String? = nil) { + guard sessionID == nil || sessionIDs[pluginID] == sessionID else { return } + if let pending = inFlight[pluginID]?.removeValue(forKey: hostRequestID) { + pending.task.cancel() + pending.deadline?.cancel() + } } func dispatch(_ request: PluginHostRequest, pluginID: String) async -> Result { - guard let granted = grants[pluginID] else { + guard let granted = grants[pluginID], let sessionID = sessionIDs[pluginID] else { return .failure(PluginHostServiceError( .backendExited, L10n.string("The plugin backend is no longer running.") ).failure) } - guard Self.implicitServices.contains(request.service) || granted.contains(request.service) else { + let legacyMethod = request.method.hasPrefix("lithe/host/credentials/") + || ["listContexts", "getContext", "getFolders"].map { "lithe/host/workspace/" + $0 }.contains(request.method) + guard Self.implicitServices.contains(request.service) || granted.contains(request.method) + || (legacyMethod && granted.contains(request.service)) else { return .failure(PluginHostServiceError( .permissionDenied, L10n.string("The plugin is not allowed to use the host service \(request.service).") ).failure) @@ -283,13 +376,13 @@ final class PluginHostServiceDispatcher { case .failure(let error): return .failure(error.failure) } } - guard let handler = handlers[request.service] else { + guard let handler = handlers[request.method] else { return .failure(PluginHostServiceError( .unavailable, L10n.string("The host does not serve \(request.method).") ).failure) } let call = PluginHostCall( - pluginID: pluginID, request: request, context: context, params: params, subscriptions: subscriptions + pluginID: pluginID, sessionID: sessionID, request: request, context: context, params: params, subscriptions: subscriptions ) return await handler.answer(call).mapError(\.failure) } diff --git a/macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift b/macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift index 73e81af786..59e9efd0ad 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostWindowServices.swift @@ -42,7 +42,7 @@ struct PluginHostWindowServices { let resolver = PluginHostResourceResolver(fileStorage: fileStorage) let editor = PluginHostEditorService(directory: directory, resolver: resolver) self.editor = editor - preview = PluginHostPreviewService(editor: editor) + preview = PluginHostPreviewService(editor: editor, directory: directory) browser = PluginHostBrowserService( directory: directory, resolver: resolver, opener: PluginHostPlatformBrowser(platformUI: platformUI) ) diff --git a/macos/Sources/Lithe/Application/Features/PluginWindowHostService.swift b/macos/Sources/Lithe/Application/Features/PluginWindowHostService.swift new file mode 100644 index 0000000000..85a690eb36 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginWindowHostService.swift @@ -0,0 +1,99 @@ +import Foundation +import LitheCoreContracts + +/// Public window actions use the same context resolver as file and document +/// access, and return document identity so opening and editing compose. +@MainActor +final class PluginWindowHostService: PluginHostServiceHandling { + let service = "window" + let methods = Set(["editor/open", "editor/reveal", "preview/openHTML", "browser/openExternal", + "window/showInformation", "window/showWarning", "window/showError"].map { "lithe/host/" + $0 }) + private let contexts: PluginWorkspaceContexts + private let metadata: PluginWorkspaceHostService + private let services: PluginHostWindowServices + private let directory: PluginContextWindowDirectory + + init(contexts: PluginWorkspaceContexts, metadata: PluginWorkspaceHostService, fileStorage: any FileStorage, platformUI: any PlatformUI) { + self.contexts = contexts; self.metadata = metadata + directory = PluginContextWindowDirectory(contexts: contexts) + services = PluginHostWindowServices(directory: directory, fileStorage: fileStorage, platformUI: platformUI) + } + + func answer(_ call: PluginHostCall) async -> Result { + do { + let scope = try PluginHostAPIContext(call, contexts: contexts) + let context = scope.windowScope(pluginID: call.pluginID) + let method = String(call.request.method.split(separator: "/").last ?? "") + let allowed: Set + switch method { + case "open", "reveal": allowed = ["context", "resource", "activation", "range", "tab"] + case "openHTML": allowed = ["context", "resource", "presentation", "range"] + case "openExternal": allowed = ["context", "resource", "url", "unsaved"] + default: allowed = ["context", "text", "actions"] + } + let args = try PluginHostArguments(call.params, allowed: allowed) + if method.hasPrefix("show") { + let actions = try (args.values["actions"] == nil ? [] : args.array("actions")).map { value -> String in + guard case .string(let title) = value else { throw PluginHostArguments.invalid }; return title + } + let severity: PluginHostMessageSeverity = method == "showError" ? .error : method == "showWarning" ? .warning : .information + let outcome = try await services.messages.show(PluginHostMessageRequest(context: context, severity: severity, + text: args.string("text"), actions: actions)) + switch outcome { + case .selected(let index): return .success(.object(["selectedIndex": .integer(index)])) + case .dismissed: return .success(.object(["selectedIndex": .null])) + } + } + if method == "openExternal", let url = try args.optionalString("url") { + guard args.values["resource"] == nil else { throw PluginHostArguments.invalid } + _ = try await services.browser.openExternal(PluginHostExternalOpenRequest(context: context, target: .webURL(url))) + return .success(.object(["opened": .bool(true), "source": .string("web")])) + } + let raw = try PluginHostArguments(args.object("resource"), allowed: ["rootID", "path"]) + let root = try raw.string("rootID") + guard scope.workspace.folders.contains(where: { metadata.rootID(of: $0, pluginID: call.pluginID) == root }) else { throw PluginHostError.unknownRoot } + let resource = try PluginHostResource(rootID: PluginHostResource.workspaceRootID, relativePath: raw.string("path")) + var range: PluginHostTextRange? + if let value = args.values["range"] { + let decoded = try PluginHostArguments.range(value) + range = PluginHostTextRange(start: .init(line: decoded.start.line, column: decoded.start.column), + end: .init(line: decoded.end.line, column: decoded.end.column)) + } + if method == "openExternal" { + let unsaved = try args.string("unsaved", default: "openSavedFile") + guard ["openSavedFile", "requireSaved"].contains(unsaved) else { throw PluginHostError.unsupportedOption } + let result = try await services.browser.openExternal(PluginHostExternalOpenRequest(context: context, target: .workspaceFile(resource), + unsaved: unsaved == "requireSaved" ? .requireSaved : .openSavedFile)) + let dirty: Bool = if case .savedFile(let value) = result.content { value } else { false } + return .success(.object(["opened": .bool(true), "source": .string("disk"), "hasUnsavedChanges": .bool(dirty)])) + } + let result: PluginHostEditorOpenResult + if method == "openHTML" { + let mode = try args.string("presentation", default: "editorAndPreview") + guard ["editorAndPreview", "previewOnly"].contains(mode) else { throw PluginHostError.unsupportedOption } + let preview = try await services.preview.openHTML(PluginHostPreviewOpenRequest(context: context, resource: resource, + presentation: mode == "previewOnly" ? .previewOnly : .editorAndPreview, range: range)) + result = preview.editor + } else { + let activation = try args.string("activation", default: "select"), tab = try args.string("tab", default: "regular") + guard ["select", "background"].contains(activation), tab == "regular" else { throw PluginHostError.unsupportedOption } + let request = PluginHostEditorOpenRequest(context: context, resource: resource, range: range, + activation: activation == "background" ? .background : .select) + result = try await method == "reveal" ? services.editor.reveal(request) : services.editor.open(request) + } + _ = try PluginHostAPIContext(call, contexts: contexts) + var object: [String: ToolingJSONValue] = [ + "documentID": result.document.documentID.map { .string($0.uuidString) } ?? .null, + "version": result.document.version.map { .integer(Int(clamping: $0)) } ?? .null, + "isActive": .bool(result.document.isActive), "isReadOnly": .bool(result.document.isReadOnly), + "hasUnsavedChanges": .bool(result.document.hasUnsavedChanges), "wasAlreadyOpen": .bool(result.wasAlreadyOpen), + "windowIsForeground": .bool(result.windowIsForeground), + ] + if case .caret(let at) = result.appliedSelection { + object["appliedSelection"] = .object(["kind": .string("caret"), "line": .integer(at.line), "column": .integer(at.column)]) + } else { object["appliedSelection"] = .null } + if method == "openHTML" { object["usesLiveBuffer"] = .bool(true); object["presentation"] = .string(try args.string("presentation", default: "editorAndPreview")) } + return .success(.object(object)) + } catch { return .failure(PluginHostAPIValues.failure(error)) } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift b/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift index 82e8afd3a9..5d721f4d7e 100644 --- a/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift +++ b/macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift @@ -58,6 +58,11 @@ final class PluginWorkspaceContexts { /// Grows whenever a context opens, closes, or changes. private(set) var revision = 1 private var contextsByOwner: [ObjectIdentifier: PluginWorkspaceContext] = [:] + private final class Owner { + weak var value: AnyObject? + init(_ value: AnyObject) { self.value = value } + } + private var owners: [ObjectIdentifier: Owner] = [:] private var closeObservers: [UUID: @MainActor (String) -> Void] = [:] private var openObservers: [UUID: @MainActor (PluginWorkspaceContext) -> Void] = [:] private var revisionObservers: [UUID: @MainActor (Int) -> Void] = [:] @@ -74,6 +79,11 @@ final class PluginWorkspaceContexts { contextsByOwner[ObjectIdentifier(owner)] } + func owner(of context: PluginWorkspaceContext) -> AnyObject? { + guard let key = contextsByOwner.first(where: { $0.value.reference == context.reference })?.key else { return nil } + return owners[key]?.value + } + /// The context a reference names, when it is open and of that /// generation. Anything else is `plugin.host.contextExpired`. func resolve(_ reference: PluginWorkspaceContextReference) -> Result { @@ -110,6 +120,7 @@ final class PluginWorkspaceContexts { location: stateKey, name: name, localURL: root, remotePath: remote?.remoteRoot ) let key = ObjectIdentifier(owner) + owners[key] = Owner(owner) let previous = contextsByOwner[key] if let previous, previous.authority == authority, previous.folders == [folder] { return previous @@ -143,6 +154,7 @@ final class PluginWorkspaceContexts { /// Closes the window's context; nothing happens when it has none. func close(owner: AnyObject) { + owners[ObjectIdentifier(owner)] = nil guard let context = contextsByOwner.removeValue(forKey: ObjectIdentifier(owner)) else { return } revision += 1 for observer in closeObservers.values { observer(context.contextID) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift index 5de22e5afb..c22d15a828 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorDocumentService.swift @@ -9,7 +9,10 @@ extension AppModel { windowID: id, workspaceURL: { [weak self] in self?.workspaceURL }, documents: { [weak self] in self?.documentFeature.editorDocuments ?? [] }, - activeDocumentID: { [weak self] in self?.documentFeature.activeDocumentID }, + activeDocumentID: { [weak self] in + guard let self, let document = self.documentFeature.editorDocuments.first(where: { self.hostIsForegroundTab($0) }) else { return nil } + return document.id + }, languageIdentifier: { [weak self] url in self?.languageProviderCatalog.provider(for: url)?.languageIdentifier(for: url) }) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift index 2fba3d6c21..bd3b6ba4fe 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginHostWindow.swift @@ -9,6 +9,13 @@ import Foundation /// `id` is the window ID that plugins are given. @MainActor extension AppModel: PluginHostWindowPort { + var hostDocumentService: (any EditorDocumentServicing)? { makeEditorDocumentService() } + var hostVisibilityRules: FileVisibilityRules { settings.fileVisibilityRules } + + func hostSetHTMLPresentation(at url: URL, previewOnly: Bool) throws { + guard let document = hostDocument(matching: url) else { throw PluginHostError.documentNotOpen } + editorTabOrderFeature.documentPreviewModes[document.id] = previewOnly ? .preview : .split + } var hostSnapshot: PluginHostWindowSnapshot? { guard let identity = currentWorkspaceIdentity else { return nil } return PluginHostWindowSnapshot( @@ -38,6 +45,7 @@ extension AppModel: PluginHostWindowPort { // The window or the tab may have gone while the editor was answering. guard currentWorkspaceIdentity != nil, openDocuments.contains(where: { $0 === document }) else { return nil } return PluginHostDocumentState( + documentID: document.id, version: document.contentVersion, url: normalized, isOpen: true, // A buffer whose latest input the editor did not confirm may be @@ -72,6 +80,7 @@ extension AppModel: PluginHostWindowPort { selectEditorTab(.document(document.id)) } return PluginHostDocumentState( + documentID: document.id, version: document.contentVersion, url: normalized, isOpen: true, hasUnsavedChanges: document.isDirty, @@ -106,7 +115,7 @@ extension AppModel: PluginHostWindowPort { /// True when the file's tab is the one in front: selected in the focused /// editor group, with no diff tab over it. - private func hostIsForegroundTab(_ document: EditorDocument) -> Bool { + func hostIsForegroundTab(_ document: EditorDocument) -> Bool { guard activeDocumentID == document.id, !editorTabOrderFeature.repositoryDiffSelected else { return false } // Before the editor area first mounts nothing is focused yet; the // active document then is the foreground one. diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift index 3a0bc6f089..99024c47f9 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift @@ -17,7 +17,7 @@ extension AppModel { func performToolWindowContribution(_ contribution: ModuleContribution, owner: ModuleID, openOnly: Bool = false) { let target = contribution.kind == .toolWindow ? contribution.id : contribution.visibility["toolWindowID"] - guard let target, workspaceURL != nil, + guard workspaceURL != nil, services.moduleRuntime.availableContributions()[owner]?.contains(contribution) == true else { return } // A plugin command reaches its `backend` 2, which starts first when // the plugin declares `onCommand:`; the window opens without @@ -25,6 +25,7 @@ extension AppModel { if contribution.kind == .command, let commandID = contribution.actionID { services.pluginBackendActivation?.runCommand(commandID, owner: owner, context: pluginWebWorkspaceContext) } + guard let target else { return } let window = WorkbenchFeatureModel.ToolWindow(rawValue: target) if !openOnly && contribution.kind == .toolWindow && workbenchFeature.isVisible(window) { workbenchFeature.setVisibility(window, isVisible: false) diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 07b51e5bd4..5c1624aae0 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -420,11 +420,21 @@ final class MacServiceContainer { let hostDispatcher = PluginHostServiceDispatcher(contexts: .shared) hostDispatcher.register(PluginCredentialHostService(credentials: pluginCredentials)) let backendDirectories = MacPluginStateDirectories(fileStorage: fileStorage) - hostDispatcher.register(PluginWorkspaceHostService( + let workspaceHost = PluginWorkspaceHostService( contexts: .shared, backendStateDirectory: { backendDirectories.backendStateDirectory(pluginID: $0) }, rootIDSecret: { MacPluginRootIDSecret.load(from: MacPluginRootIDSecret.fileURL(fileStorage: fileStorage)) } - )) + ) + hostDispatcher.register(workspaceHost) + hostDispatcher.register(PluginDocumentHostService(contexts: .shared)) + hostDispatcher.register(PluginFileHostService(contexts: .shared, metadata: workspaceHost, + files: MacPluginWorkspaceFileService( + searchOperations: RustWorkspaceOperations(core: rustCore), + watcherFactory: MacDirectoryWatcherFactory(registry: remoteWorkspaces, transport: remoteTransport), + isRemoteWorkspaceRoot: { remoteWorkspaces.resolve($0) != nil } + ))) + hostDispatcher.register(PluginWindowHostService(contexts: .shared, metadata: workspaceHost, + fileStorage: fileStorage, platformUI: platformUI)) let sessions = MacPluginBackendSessions( launchResolver: pluginLaunchResolver, tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index ec4fb39727..596994db47 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -72,8 +72,12 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi /// Reports a backend that an activation event failed to start; the /// workspace or command goes on without it. var onActivationFailure: (@MainActor (_ pluginID: String, _ error: Error) -> Void)? - /// Starts activated backends one plugin at a time. - private var activationQueue: Task? + private static let maximumBackgroundStarts = 2 + private static let maximumPendingNotifications = 128 + private var backgroundQueue: [String] = [] + private var backgroundStarts: [String: Task] = [:] + private var commandStarts: [String: Task] = [:] + private var pendingCommands: [String: [ToolingJSONValue]] = [:] private var packages: [String: Package] = [:] /// Disabled plugins start again once enabled. private var disabledPluginIDs: Set = [] @@ -88,10 +92,12 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi let settings: [String: ToolingJSONValue] var task: Task? var session: LanguageServerRuntimeSession? + var ready = false /// What Core granted the session, set when the process launches. var hostServices: Set = [] /// Notifications reach the backend in the order the host sent them. var notifications: Task? + var notificationQueue: [(method: String, params: ToolingJSONValue)] = [] init(settings: [String: ToolingJSONValue]) { self.settings = settings @@ -212,29 +218,32 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi }.keys.sorted() } - /// Starts the backends the event activates, after what is queued, one - /// plugin at a time and without the caller waiting: a slow or failing - /// backend never holds up the workspace or the command. A backend that - /// is already running stays as it is. + /// Queues deduplicated background starts without holding up the workspace. + /// Explicit commands take the independent per-plugin readiness path. func activate(event: String, owner: ModuleID? = nil) { start(pluginIDs(activatedBy: event, owner: owner).filter { running[$0] == nil }) } - /// Queues the start of each plugin's backend; `then` runs for each one - /// that is ready, in the queue's order. - private func start( - _ pluginIDs: [String], - then: (@MainActor (_ pluginID: String) -> Void)? = nil - ) { - guard !pluginIDs.isEmpty else { return } - let previous = activationQueue - activationQueue = Task { @MainActor [weak self] in - await previous?.value - for pluginID in pluginIDs { - guard let self, !Task.isCancelled else { return } + /// Background activation is bounded and deduplicated. User commands have + /// their own start path so an unrelated slow initialize never delays them. + private func start(_ pluginIDs: [String]) { + for id in pluginIDs where backgroundStarts[id] == nil && !backgroundQueue.contains(id) { + backgroundQueue.append(id) + } + drainBackgroundStarts() + } + + private func drainBackgroundStarts() { + while backgroundStarts.count < Self.maximumBackgroundStarts, !backgroundQueue.isEmpty { + let pluginID = backgroundQueue.removeFirst() + backgroundStarts[pluginID] = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if !Task.isCancelled { self.backgroundStarts[pluginID] = nil } + self.drainBackgroundStarts() + } do { _ = try await self.session(for: pluginID) - then?(pluginID) } catch { if !(error is CancellationError) { self.onActivationFailure?(pluginID, error) } } @@ -261,16 +270,32 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi "contextID": .string(context.contextID), "generation": .integer(context.generation), ]) } - let deliver: @MainActor (String) -> Void = { [weak self] pluginID in - self?.notify(pluginID: pluginID, method: "lithe/host/commands/didRun", params: .object(params)) + if commandStarts[pluginID] == nil, running[pluginID]?.ready == true, running[pluginID]?.session?.isRunning == true { + notify(pluginID: pluginID, method: "lithe/host/commands/didRun", params: .object(params)) + return } - // A ready backend gets it in order with the notifications sent from - // now; a starting one after its start. A stopped one starts only on - // its own `onCommand:`. - if running[pluginID]?.session != nil { - deliver(pluginID) - } else if running[pluginID] != nil || package.activationEvents.contains("onCommand:\(commandID)") { - start([pluginID], then: deliver) + guard running[pluginID] != nil || package.activationEvents.contains("onCommand:\(commandID)") else { return } + guard pendingCommands[pluginID, default: []].count < Self.maximumPendingNotifications else { + reportNotificationLimit(pluginID) + return + } + pendingCommands[pluginID, default: []].append(.object(params)) + guard commandStarts[pluginID] == nil else { return } + commandStarts[pluginID] = Task { @MainActor [weak self] in + guard let self else { return } + defer { if !Task.isCancelled { self.commandStarts[pluginID] = nil } } + do { + _ = try await self.session(for: pluginID) + guard !Task.isCancelled else { return } + let commands = self.pendingCommands.removeValue(forKey: pluginID) ?? [] + for command in commands { + self.notify(pluginID: pluginID, method: "lithe/host/commands/didRun", params: command) + } + } catch { + guard !Task.isCancelled else { return } + self.pendingCommands[pluginID] = nil + if !(error is CancellationError) { self.onActivationFailure?(pluginID, error) } + } } } @@ -287,12 +312,47 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi /// ones sent before it. A backend that is not running, or negotiated a /// protocol without the notification, does not receive it. private func notify(pluginID: String, method: String, params: ToolingJSONValue) { - guard let entry = running[pluginID], let session = entry.session else { return } - let previous = entry.notifications - entry.notifications = Task { @MainActor in - await previous?.value - _ = await session.notify(method: method, params: params) + guard let entry = running[pluginID], entry.ready, let session = entry.session else { return } + if method == "lithe/host/workspace/didChangeContexts" { + entry.notificationQueue.removeAll { $0.method == method } } + guard entry.notificationQueue.count < Self.maximumPendingNotifications else { + reportNotificationLimit(pluginID) + stop(pluginID) + return + } + entry.notificationQueue.append((method, params)) + guard entry.notifications == nil else { return } + entry.notifications = Task { @MainActor [weak self, weak entry] in + guard let self, let entry else { return } + defer { entry.notifications = nil } + while !Task.isCancelled, self.running[pluginID] === entry, !entry.notificationQueue.isEmpty { + let next = entry.notificationQueue.removeFirst() + if next.method == "lithe/host/subscriptions/notify", case .object(let params) = next.params, + case .string(let id)? = params["subscriptionID"], + !self.hostDispatcher.subscriptions.contains(pluginID: pluginID, subscriptionID: id) { continue } + if next.method == "lithe/host/commands/didRun", case .object(let params) = next.params, + let value = params["context"], let context = PluginHostServiceDispatcher.contextReference(value), + case .failure(let error) = self.hostDispatcher.contexts.resolve(context) { + self.onActivationFailure?(pluginID, error) + continue + } + guard await session.notify(method: next.method, params: next.params) else { + if !Task.isCancelled, self.running[pluginID] === entry { + self.onActivationFailure?(pluginID, PluginHostServiceError(.failed, + L10n.string("The plugin host request could not be completed."))) + self.stop(pluginID) + } + return + } + } + } + } + + private func reportNotificationLimit(_ pluginID: String) { + onActivationFailure?(pluginID, PluginHostServiceError( + .limitExceeded, L10n.string("This plugin already has too many messages waiting.") + )) } /// Stops the backend until `enable` is called. @@ -317,10 +377,15 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi } private func stop(_ pluginID: String) { + backgroundQueue.removeAll { $0 == pluginID } + backgroundStarts.removeValue(forKey: pluginID)?.cancel() + commandStarts.removeValue(forKey: pluginID)?.cancel() + pendingCommands[pluginID] = nil hostDispatcher.endSession(pluginID: pluginID) guard let entry = running.removeValue(forKey: pluginID) else { return } entry.task?.cancel() entry.notifications?.cancel() + entry.notificationQueue = [] entry.session?.stop() } @@ -362,7 +427,10 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi do { return try await self.launch(backend, pluginID: pluginID, installation: package.installation, entry: entry) } catch { - if self.running[pluginID] === entry { self.running[pluginID] = nil } + if self.running[pluginID] === entry { + self.running[pluginID] = nil + self.hostDispatcher.endSession(pluginID: pluginID) + } throw error } } @@ -396,8 +464,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi // Core lists in `getCapabilities` what it grants, so it is granted // only what this host serves: a listed method never fails for want // of a handler. - let served = Set(hostDispatcher.registeredServices) - let hostServices = backend.hostServices.filter { served.contains($0) } + let hostServices = hostDispatcher.supportedGrants(backend.hostServices) let session = LanguageServerRuntimeSession( providerID: "pluginBackend:\(pluginID)", executableURL: executableURL, @@ -417,23 +484,32 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi let hostDispatcher = hostDispatcher session.stuckSendGrace = stuckSendGrace session.terminateProcess = { endBackendProcesses(backendToken, true) } - session.onReleased = { endBackendProcesses(backendToken, false) } + session.onReleased = { [weak self, weak entry] in + endBackendProcesses(backendToken, false) + guard let self, let entry, self.running[pluginID] === entry else { return } + self.running[pluginID] = nil + entry.ready = false + entry.notifications?.cancel() + entry.notificationQueue = [] + hostDispatcher.endSession(pluginID: pluginID) + } // A new process starts with no subscriptions and nothing in flight. hostDispatcher.endSession(pluginID: pluginID) - hostDispatcher.beginSession(pluginID: pluginID, hostServices: hostServices) + let sessionToken = hostDispatcher.beginSession(pluginID: pluginID, hostServices: hostServices) entry.hostServices = Set(hostServices) session.onHostRequest = { [weak session] request in - hostDispatcher.handle(request, pluginID: pluginID) { outcome in + hostDispatcher.handle(request, pluginID: pluginID, sessionID: sessionToken) { outcome in session?.respond(to: request.id, with: outcome) } } session.onHostRequestCancelled = { hostRequestID in - hostDispatcher.cancel(hostRequestID: hostRequestID, pluginID: pluginID) + hostDispatcher.cancel(hostRequestID: hostRequestID, pluginID: pluginID, sessionID: sessionToken) } entry.session = session try await waitUntilReady(session, rootURL: backend.stateDirectory) try Task.checkCancellation() guard running[pluginID] === entry else { throw CancellationError() } + entry.ready = true return session } diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index aafd729b9c..939e920cc1 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -90,29 +90,6 @@ private struct EditorDocumentTabIcon: View { } } -enum DocumentPreviewMode: String, CaseIterable, Identifiable, Equatable { - case editor - case split - case preview - - var id: String { rawValue } - - var title: String { - switch self { - case .editor: "Editor" - case .split: "Editor and Preview" - case .preview: "Preview" - } - } - - var symbolName: String { - switch self { - case .editor: "pencil.line" - case .split: "rectangle.split.2x1" - case .preview: "doc.richtext" - } - } -} struct EditorAreaView: View { var groupID: UUID? = nil @@ -132,7 +109,7 @@ struct EditorAreaView: View { @State private var tabReorderTarget: EditorTabReorderTarget? @State private var isTerminalTabBarDropTargeted = false @State private var resolvedTabIcons: [UUID: LitheIconKind] = [:] - @State private var documentPreviewModes: [UUID: DocumentPreviewMode] = [:] + private var documentPreviewModes: [UUID: DocumentPreviewMode] { model.editorTabOrderFeature.documentPreviewModes } @State private var markdownScrollPositions: [UUID: MarkdownScrollPosition] = [:] @State private var hoveredPreviewMode: DocumentPreviewMode? @@ -179,7 +156,7 @@ struct EditorAreaView: View { } .onChange(of: model.openDocuments.map(\.id)) { ids in resolvedTabIcons = resolvedTabIcons.filter { ids.contains($0.key) } - documentPreviewModes = documentPreviewModes.filter { ids.contains($0.key) } + model.editorTabOrderFeature.documentPreviewModes = documentPreviewModes.filter { ids.contains($0.key) } markdownScrollPositions = markdownScrollPositions.filter { ids.contains($0.key) } } .onChange(of: model.editorTabItems) { items in @@ -1119,7 +1096,7 @@ struct EditorAreaView: View { private func selectDocumentPreviewMode(_ mode: DocumentPreviewMode) { guard let document = paneDocument else { return } - documentPreviewModes[document.id] = mode + model.editorTabOrderFeature.documentPreviewModes[document.id] = mode } private func isSVGFile(_ document: EditorDocument) -> Bool { diff --git a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift index 26eaf13505..8ba83ec9dc 100644 --- a/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift +++ b/macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift @@ -596,7 +596,9 @@ private struct SearchEverywhereContent: View { private func actionRow(_ action: LitheAction, selected: Bool) -> some View { Button { - performAction(action) + // Native pointer dispatch can still reach the closure of a disabled + // row; use the same execution gate as the keyboard path. + if action.isEnabled { performAction(action) } } label: { HStack(spacing: 8) { if let icon = actionIcon(action) { diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index 6e945d1404..590016a5b9 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -603,7 +603,7 @@ public struct DeclarativeUIContributions: Equatable, Codable, Sendable { public struct Command: Equatable, Codable, Sendable { public let id: String public let title: String - public let toolWindowID: String + public let toolWindowID: String? public let order: Int public let placements: [ModuleContributionPlacement] public let icon: PluginUIIcon? @@ -624,7 +624,7 @@ public struct DeclarativeUIContributions: Equatable, Codable, Sendable { ModuleContribution( id: command.id + "." + placement.rawValue, kind: .command, title: command.title, placement: placement, order: command.order, - actionID: command.id, visibility: ["toolWindowID": command.toolWindowID], uiIcon: command.icon + actionID: command.id, visibility: command.toolWindowID.map { ["toolWindowID": $0] } ?? [:], uiIcon: command.icon ) } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index bc82536744..2d7a520d13 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -937,9 +937,11 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// it is sent off the main actor like a request. package func respond(to requestID: String, with outcome: Result) { guard let sessionID else { return } - let core = core - Task.detached(priority: .userInitiated) { - _ = core.respondToPluginHostRequest(sessionID: sessionID, hostRequestID: requestID, outcome: outcome) + Task { @MainActor [self] in + _ = await send(timeout: requestTimeout) { core in + core.respondToPluginHostRequest(sessionID: sessionID, hostRequestID: requestID, outcome: outcome) + } + sendsInFlight -= 1 } } @@ -948,12 +950,12 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// `false` when the session is not running, the backend negotiated a /// protocol without the notification, or Core refused it. package func notify(method: String, params: ToolingJSONValue) async -> Bool { - guard let sessionID, isRunning else { return false } - let core = core - let sending = Task.detached(priority: .userInitiated) { + guard let sessionID, case .ready = state, !Task.isCancelled else { return false } + let result = await send(timeout: requestTimeout) { core in core.notifyPluginBackend(sessionID: sessionID, method: method, params: params) } - switch await sending.value { + sendsInFlight -= 1 + switch result { case .success(let delivered): return delivered case .failure(let failure): @@ -985,12 +987,10 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// A write that is still blocked when the call's timeout passes, or /// `stuckSendGrace` after the caller was cancelled, ends the process: /// the write then fails and Core fails the session. - private func send( + private func send( timeout: TimeInterval, - _ request: @escaping @Sendable (any LanguageServerRuntimeCore) -> Result< - LanguageServerRuntimeOperation, LanguageServerRuntimeFailure - > - ) async -> Result { + _ request: @escaping @Sendable (any LanguageServerRuntimeCore) -> Result + ) async -> Result { sendsInFlight += 1 // A drain that started before this send must finish before the request // is written; its events then cannot include this request's reply. diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 81dd81db9b..3bed98a4c6 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -518,7 +518,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { "projectGenerators": [1], "serverProcess": [1], "settings": [1, 2], - "ui": [1, 2, 3], + "ui": [1, 2, 3, 4], "vcsProviders": [1] ] diff --git a/macos/Tests/LitheTests/Fixtures/PluginHostAPI/backend.mjs b/macos/Tests/LitheTests/Fixtures/PluginHostAPI/backend.mjs new file mode 100644 index 0000000000..7d19fd9bc2 --- /dev/null +++ b/macos/Tests/LitheTests/Fixtures/PluginHostAPI/backend.mjs @@ -0,0 +1,67 @@ +// Explicit integration fixture, not an SDK: exercises the production stdio +// transport and command -> host request -> versioned edit route. +let buffer = Buffer.alloc(0), nextID = 0, completed; +const pending = new Map(); +function send(message) { + const body = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message })); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} +function request(method, params) { + const id = `fixture-${++nextID}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error("host request deadline")); }, 5000); + pending.set(id, { resolve, reject, timer }); + send({ id, method, params }); + }); +} +async function transform(context) { + const capabilities = await request("lithe/host/getCapabilities", {}); + if (!capabilities.services.editor.methods.includes("lithe/host/editor/applyEdits")) throw new Error("edit not granted"); + const active = await request("lithe/host/editor/getActive", { context }); + const selected = await request("lithe/host/editor/getSelections", { context, documentID: active.documentID }); + const snapshot = await request("lithe/host/documents/getSnapshot", { context, documentID: active.documentID }); + if (snapshot.document.version !== selected.version) throw new Error("inconsistent snapshot"); + const result = await request("lithe/host/editor/applyEdits", { context, documentID: active.documentID, + expectedVersion: selected.version, edits: [{ range: { start: selected.selections[0].anchor, + end: selected.selections[0].active }, text: "DONE" }] }); + return { result, original: snapshot.text }; +} +async function receive(message) { + if (!message.method) { + const waiter = pending.get(message.id); + if (waiter) { + pending.delete(message.id); clearTimeout(waiter.timer); + message.error ? waiter.reject(new Error(message.error.data?.code ?? message.error.message)) : waiter.resolve(message.result); + } + return; + } + if (message.method === "initialize") { + send({ id: message.id, result: { capabilities: { experimental: { litheFunctions: { + version: 3, functions: [{ id: "fixture.result" }], + } } } } }); + } else if (message.method === "lithe/host/commands/didRun") { + completed = transform(message.params.context).then(value => ({ value }), error => ({ failure: error.message })); + } else if (message.method === "lithe/function/invoke") { + const result = completed ? await completed : { failure: "no command delivered" }; + send({ id: message.id, result }); + } else if (message.method === "shutdown") { + send({ id: message.id, result: null }); + } else if (message.method === "exit") { + process.exit(0); + } +} +process.stdin.on("data", bytes => { + buffer = Buffer.concat([buffer, bytes]); + while (true) { + const end = buffer.indexOf("\r\n\r\n"); + if (end < 0) break; + const length = Number(/Content-Length:\s*(\d+)/i.exec(buffer.subarray(0, end).toString())?.[1]); + if (!Number.isSafeInteger(length) || length > 2 ** 20) process.exit(2); + if (buffer.length < end + 4 + length) break; + const message = JSON.parse(buffer.subarray(end + 4, end + 4 + length).toString()); + buffer = buffer.subarray(end + 4 + length); + void receive(message).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); + } +}); +process.stdin.on("end", () => process.exit(0)); diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift index f9c3b1b71e..4ed500feb6 100644 --- a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -10,6 +10,44 @@ import Testing /// receives, how its credential requests are answered, and when it stops. @MainActor struct MacPluginBackendSessionsTests { + @Test + func commandsWaitForInitializeAndRestartAfterExit() async throws { + let fixture = try BackendFixture(backendVersion: 2, commandActivation: true) + defer { fixture.remove() } + fixture.core.delayReady = true + let owner = ModuleID("\(BackendFixture.pluginID).ui") + fixture.sessions.runCommand("dev.lithe.fixture.backend.open", owner: owner, context: nil) + try #require(await fixture.core.started.waitUntilOpen()) + fixture.sessions.runCommand("dev.lithe.fixture.backend.open", owner: owner, context: nil) + #expect(fixture.core.notifications.isEmpty) + fixture.core.finishInitialize() + try #require(await fixture.core.waitForNotifications(2)) + #expect(fixture.core.starts.count == 1) + + let disposed = TestGate() + _ = fixture.sessions.hostDispatcher.subscriptions.add(pluginID: BackendFixture.pluginID, contextID: nil) { disposed.open() } + fixture.core.exit() + try #require(await disposed.waitUntilOpen()) + fixture.core.delayReady = false + fixture.sessions.runCommand("dev.lithe.fixture.backend.open", owner: owner, context: nil) + try #require(await fixture.core.waitForNotifications(3)) + #expect(fixture.core.starts.count == 2) + #expect(fixture.core.notifications.last?.sessionID == "backend-2") + } + + @Test + func stoppingABlockedNotificationReleasesTheWriteOffMainActor() async throws { + let fixture = try BackendFixture(backendVersion: 2) + defer { fixture.core.releaseWrite.open(); fixture.remove() } + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + fixture.core.blockNotification = true + fixture.sessions.runCommand("query.run", owner: ModuleID("\(BackendFixture.pluginID).ui"), context: nil) + try #require(await fixture.core.writeStarted.waitUntilOpen()) + fixture.sessions.disable(BackendFixture.pluginID) + try #require(await fixture.core.forcedStop.waitUntilOpen()) + try #require(await fixture.core.writeFinished.waitUntilOpen()) + } + /// The first page call starts the backend in its own state directory, /// outside the package, with the services its permissions grant; later /// calls reuse the process. @@ -122,10 +160,13 @@ struct MacPluginBackendSessionsTests { let owner = ModuleID("\(BackendFixture.pluginID).ui") let subscriptions = fixture.sessions.hostDispatcher.subscriptions let subscriptionID = subscriptions.add(pluginID: BackendFixture.pluginID, contextID: nil) {} + let window = NSObject() + let context = fixture.sessions.hostDispatcher.contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/fixture/project"), remote: nil) + defer { fixture.sessions.hostDispatcher.contexts.close(owner: window) } fixture.sessions.runCommand( "query.run", owner: owner, - context: PluginWorkspaceContextReference(contextID: "w-1", generation: 2) + context: context.reference ) subscriptions.notify(pluginID: BackendFixture.pluginID, subscriptionID: subscriptionID, event: ["kind": .string("changed")]) fixture.sessions.runCommand("query.stop", owner: owner, context: nil) @@ -136,7 +177,7 @@ struct MacPluginBackendSessionsTests { ]) #expect(fixture.core.notifications.first?.params == .object([ "commandID": .string("query.run"), - "context": .object(["contextID": .string("w-1"), "generation": .integer(2)]), + "context": .object(["contextID": .string(context.contextID), "generation": .integer(context.generation)]), ])) #expect(fixture.core.notifications.last?.params == .object(["commandID": .string("query.stop")])) } @@ -224,6 +265,7 @@ private struct BackendFixture { init( backendVersion: Int = 1, + commandActivation: Bool = false, prepareState: @escaping @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void = { _, _ in } ) throws { root = FileManager.default.temporaryDirectory @@ -239,6 +281,14 @@ private struct BackendFixture { requires["backend"] = backendVersion object["requires"] = requires } + if commandActivation { + var ui = object["ui"] as? [String: Any] ?? [:] + let windows = ui["toolWindows"] as? [[String: Any]] ?? [] + let commandID = "dev.lithe.fixture.backend.open" + ui["commands"] = [["id": commandID, "title": "Open", "toolWindowID": windows[0]["id"]!, "placements": ["commandPalette"]]] + object["ui"] = ui + object["activationEvents"] = (object["activationEvents"] as? [String] ?? []) + ["onCommand:\(commandID)"] + } _ = try DeclarativePluginTestSupport.writePackage(at: packageURL, manifest: object) let executable = packageURL.appendingPathComponent("bin/backend") try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) @@ -261,7 +311,10 @@ private struct BackendFixture { processRegistry: nil, credentials: credentials, prepareState: prepareState, - endBackendProcesses: { _, _ in } + stuckSendGrace: .milliseconds(20), + endBackendProcesses: { [core] _, immediately in + if immediately { core.forcedStop.open(); core.releaseWrite.open() } + } ) sessions.register([InstalledPluginPackage( descriptor: descriptor, @@ -308,6 +361,23 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S private var _notifications: [Notification] = [] private var waiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] private var notificationWaiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] + private var _delayReady = false + private var _blockNotification = false + let started = TestGate() + let writeStarted = TestGate() + let releaseWrite = TestGate() + let writeFinished = TestGate() + let forcedStop = TestGate() + var delayReady: Bool { get { lock.withLock { _delayReady } } set { lock.withLock { _delayReady = newValue } } } + var blockNotification: Bool { get { lock.withLock { _blockNotification } } set { lock.withLock { _blockNotification = newValue } } } + + func finishInitialize() { + lock.withLock { events["backend-\(nextSession)", default: []].append(LanguageServerRuntimeEvent(type: "stateChanged", state: "ready")) } + } + + func exit() { + lock.withLock { events["backend-\(nextSession)", default: []].append(LanguageServerRuntimeEvent(type: "stateChanged", state: "stopped")) } + } var starts: [Start] { lock.withLock { _starts } } var stops: [String] { lock.withLock { _stops } } @@ -329,8 +399,9 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S type: "functionsChanged", result: .object(["functions": .array([.string("query.run")]), "removed": .array([])]) ), - LanguageServerRuntimeEvent(type: "stateChanged", state: "ready"), ] + if !_delayReady { events[sessionID, default: []].append(LanguageServerRuntimeEvent(type: "stateChanged", state: "ready")) } + started.open() return .success(LanguageServerRuntimeStart(sessionID: sessionID, state: "initializing", processID: nil)) } } @@ -379,6 +450,15 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S func notifyPluginBackend( sessionID: String, method: String, params: ToolingJSONValue ) -> Result { + if blockNotification { + writeStarted.open() + defer { writeFinished.open() } + // Native synchronous transport is exercised on its owned worker. + guard releaseWrite.waitSynchronously(timeout: 5) else { + return .failure(LanguageServerRuntimeFailure(code: "timeout", message: "write watchdog did not terminate")) + } + return .failure(LanguageServerRuntimeFailure(code: "processFailed", message: "backend ended")) + } let satisfied = lock.withLock { _notifications.append(Notification(sessionID: sessionID, method: method, params: params)) let ready = notificationWaiters.filter { $0.value.count <= _notifications.count } diff --git a/macos/Tests/LitheTests/PluginHostEditorServiceTests.swift b/macos/Tests/LitheTests/PluginHostEditorServiceTests.swift index 5bab494d94..a05264e139 100644 --- a/macos/Tests/LitheTests/PluginHostEditorServiceTests.swift +++ b/macos/Tests/LitheTests/PluginHostEditorServiceTests.swift @@ -383,7 +383,7 @@ struct PluginHostPreviewServiceTests { } } - @Test func refusesOtherFilesAndPreviewOnlyBeforeTouchingTheWindow() async { + @Test func refusesOtherFilesBeforeTouchingTheWindow() async { let fixture = PluginHostFixture() defer { fixture.tearDown() } fixture.addFile("notes.md") @@ -394,11 +394,6 @@ struct PluginHostPreviewServiceTests { context: fixture.context(), resource: fixture.resource("notes.md") )) } - await #expect(throws: PluginHostError.unsupportedOption) { - try await fixture.preview.openHTML(PluginHostPreviewOpenRequest( - context: fixture.context(), resource: fixture.resource("a.html"), presentation: .previewOnly - )) - } #expect(fixture.window.openCalls.isEmpty) } diff --git a/macos/Tests/LitheTests/PluginHostProcessIntegrationTests.swift b/macos/Tests/LitheTests/PluginHostProcessIntegrationTests.swift new file mode 100644 index 0000000000..24ede9b28e --- /dev/null +++ b/macos/Tests/LitheTests/PluginHostProcessIntegrationTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import LitheModuleAPI +@testable import Lithe + +/// Opt-in integration using an explicitly supplied Node executable. All package +/// and state files are private test copies; cleanup waits for process release. +@MainActor +struct PluginHostProcessIntegrationTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_PLUGIN_HOST_TEST_NODE"] != nil, + "Requires an explicit Node runtime for the real backend integration")) + func commandRunsThroughCoreAndPublicHostWithoutMutatingThePackage() async throws { + let node = URL(fileURLWithPath: try #require(ProcessInfo.processInfo.environment["LITHE_PLUGIN_HOST_TEST_NODE"])) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("plugin-host-process-\(UUID().uuidString)") + let package = root.appendingPathComponent("package") + try FileManager.default.createDirectory(at: package, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let contexts = PluginWorkspaceContexts() + let context = contexts.open(owner: fixture.window, rootURL: PluginHostFixture.workspaceURL, remote: nil) + defer { contexts.close(owner: fixture.window) } + let document = EditorDocument(url: fixture.url("sample.txt"), text: "saved", modificationDate: nil) + document.text = "dirty 😀" + let changed = TestGate() + let surface = ProcessSurface(changed: changed) + document.editorSurface = surface + fixture.window.hostDocumentService = EditorDocumentService(windowID: fixture.windowID, + workspaceURL: { PluginHostFixture.workspaceURL }, documents: { [document] }, activeDocumentID: { document.id }, languageIdentifier: { _ in nil }) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(PluginDocumentHostService(contexts: contexts)) + let repository = DeclarativePluginTestSupport.repositoryRoot + let script = try Data(contentsOf: repository.appendingPathComponent("macos/Tests/LitheTests/Fixtures/PluginHostAPI/backend.mjs")) + try script.write(to: package.appendingPathComponent("backend.mjs")) + var manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: repository.appendingPathComponent( + "shared/contracts/plugins/v1/fixtures/command-backend.plugin.json"))) as? [String: Any]) + manifest["backend"] = ["launch": ["kind": "runtime", "tool": "node", "entry": "backend.mjs"]] + let manifestData = try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]) + try manifestData.write(to: package.appendingPathComponent("plugin.json")) + let core = RustCoreBridge() + let operations = RustDeclarativePluginOperations(core: core) + let descriptor = try operations.validate(manifest: manifestData) + let runtime = ExplicitNode(node: node) + let released = TestGate() + let sessions = MacPluginBackendSessions(launchResolver: MacPluginLanguageServerLaunchResolver(planner: operations, + directories: MacPluginStateDirectories(rootURL: root.appendingPathComponent("state")), settings: InMemoryPluginSettings()), + tools: runtime, versionChecker: operations, runtime: runtime, core: core, processRegistry: nil, + credentials: MemoryPluginCredentialStore(), hostDispatcher: dispatcher, endBackendProcesses: { token, _ in + MacPluginBackendProcesses.end(token: token, gracePeriod: 0); released.open() + }) + sessions.register([InstalledPluginPackage(descriptor: descriptor, installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .localDevelopment), packageURL: package)]) + try await WorkbenchTestScope.withCleanup({ + sessions.disable(descriptor.id.rawValue) + #expect(await released.waitUntilOpen(timeout: .seconds(5))) + }) { scope in + try await scope.run("backend command and host edit", timeout: .seconds(10)) { + sessions.runCommand("dev.lithe.fixture.workspacebackend.transform", owner: ModuleID(descriptor.id.rawValue + ".ui"), context: context.reference) + try #require(await changed.waitUntilOpen(timeout: .seconds(5))) + let result = try await sessions.invokePluginFunction("fixture.result", params: .object([:]), ofPlugin: descriptor.id.rawValue, + context: context.reference, timeout: .seconds(5), progress: { _ in }) + guard case .object(let object) = result, case .object(let value)? = object["value"] else { + Issue.record("Backend workflow failed: \(result)"); return + } + #expect(value["original"] == .string("dirty 😀")) + #expect(document.text == "DONE 😀") + #expect(try Data(contentsOf: package.appendingPathComponent("backend.mjs")) == script) + #expect(try Data(contentsOf: package.appendingPathComponent("plugin.json")) == manifestData) + } + } + } + + private final class ProcessSurface: EditorDocumentSurface { + let changed: TestGate + init(changed: TestGate) { self.changed = changed } + func readSelections(of document: EditorDocument) async throws -> EditorSurfaceSelections { + .init(selections: [.init(anchor: .init(line: 1, column: 1), active: .init(line: 1, column: 6))], version: document.contentVersion) + } + func apply(_ request: EditorSurfaceEditRequest, to document: EditorDocument) async throws -> UInt64 { + document.text = EditorTextEditPlanner.apply(request.edits, to: document.text) + changed.open() + return document.contentVersion + } + } + + private final class ExplicitNode: BuildToolResolving, LanguageToolRuntimePort { + let node: URL + init(node: URL) { self.node = node } + func resolve(_ invocation: BuildInvocation, displayName: String, workspaceURL: URL, + checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck) async throws -> ResolvedBuildTool { + ResolvedBuildTool(executableURL: node, environment: ProcessInfo.processInfo.environment, executesProjectCode: false, version: nil) + } + func executableOnPath(_ name: String) -> URL? { name == "node" ? node : nil } + func executableURL(at path: String) -> URL? { URL(fileURLWithPath: path) } + func executableCandidates(_ name: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment(runtimeTools: [String]) -> [String: String] { ProcessInfo.processInfo.environment } + func missingLanguageToolMessage(_ name: String) -> String { name } + } +} diff --git a/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift index 462a0ff35e..6503ab3d67 100644 --- a/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift +++ b/macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift @@ -98,7 +98,7 @@ struct PluginHostServiceDispatchTests { func aGrantedServiceWithoutAHandlerIsUnavailable() async { let fixture = Fixture() defer { fixture.remove() } - fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: ["files"]) + fixture.dispatcher.beginSession(pluginID: Self.pluginID, hostServices: ["lithe/host/files/read"]) let outcome = await fixture.dispatcher.dispatch( PluginHostRequest(id: "h-1", method: "lithe/host/files/read", service: "files", params: .object([:])), diff --git a/macos/Tests/LitheTests/PluginHostTestSupport.swift b/macos/Tests/LitheTests/PluginHostTestSupport.swift index 9f09df520a..7b5bba49da 100644 --- a/macos/Tests/LitheTests/PluginHostTestSupport.swift +++ b/macos/Tests/LitheTests/PluginHostTestSupport.swift @@ -43,6 +43,11 @@ final class FakePluginHostWindow: PluginHostWindowPort { } var snapshot: PluginHostWindowSnapshot? + var hostDocumentService: (any EditorDocumentServicing)? + var htmlPreviewOnly: [String: Bool] = [:] + func hostSetHTMLPresentation(at url: URL, previewOnly: Bool) throws { + htmlPreviewOnly[url.path] = previewOnly + } /// Open buffers by file path; true when the buffer holds unsaved edits. var openBuffers: [String: Bool] = [:] /// The file whose tab is in front, or nil when another kind of tab is. @@ -170,7 +175,7 @@ final class PluginHostFixture { window = FakePluginHostWindow(snapshot: Self.snapshot(windowID: id)) directory.windows[id] = window editor = PluginHostEditorService(directory: directory, resolver: table.resolver()) - preview = PluginHostPreviewService(editor: editor) + preview = PluginHostPreviewService(editor: editor, directory: directory) browser = PluginHostBrowserService(directory: directory, resolver: table.resolver(), opener: opener) } diff --git a/macos/Tests/LitheTests/PluginPublicHostAPITests.swift b/macos/Tests/LitheTests/PluginPublicHostAPITests.swift new file mode 100644 index 0000000000..242bc9e939 --- /dev/null +++ b/macos/Tests/LitheTests/PluginPublicHostAPITests.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing +import LitheCoreContracts +@testable import Lithe + +@MainActor +struct PluginPublicHostAPITests { + @Test + func dispatchReadsDirtySelectionsEditsAndRejectsAnOldVersion() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let contexts = PluginWorkspaceContexts() + let context = contexts.open(owner: fixture.window, rootURL: PluginHostFixture.workspaceURL, remote: nil) + let document = EditorDocument(url: fixture.url("sample.txt"), text: "hello 😀", modificationDate: nil) + document.text = "dirty 😀" + let surface = Surface() + document.editorSurface = surface + fixture.window.hostDocumentService = EditorDocumentService(windowID: fixture.windowID, + workspaceURL: { PluginHostFixture.workspaceURL }, documents: { [document] }, activeDocumentID: { document.id }, languageIdentifier: { _ in nil }) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(PluginDocumentHostService(contexts: contexts)) + dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/documents/getSnapshot", "lithe/host/editor/getSelections", "lithe/host/editor/applyEdits"]) + defer { dispatcher.endSession(pluginID: "plugin") } + let base: [String: ToolingJSONValue] = ["context": reference(context), "documentID": .string(document.id.uuidString)] + let snapshot = try await dispatcher.dispatch(request("documents/getSnapshot", base), pluginID: "plugin").get() + guard case .object(let object) = snapshot else { Issue.record("snapshot missing"); return } + #expect(object["text"] == .string("dirty 😀")) + let selected = try await dispatcher.dispatch(request("editor/getSelections", base), pluginID: "plugin").get() + guard case .object(let selections) = selected else { Issue.record("selections missing"); return } + #expect(selections["version"] == .integer(Int(document.contentVersion))) + var edit = base + edit["expectedVersion"] = .integer(Int(document.contentVersion)) + edit["edits"] = .array([.object(["range": .object([ + "start": .object(["line": .integer(1), "column": .integer(1)]), + "end": .object(["line": .integer(1), "column": .integer(6)]), + ]), "text": .string("DONE")])]) + _ = try await dispatcher.dispatch(request("editor/applyEdits", edit), pluginID: "plugin").get() + #expect(document.text == "DONE 😀") + let stale = await dispatcher.dispatch(request("editor/applyEdits", edit), pluginID: "plugin") + #expect(code(stale) == "plugin.host.versionConflict") + #expect(surface.before == ["dirty 😀"]) + let previous = surface.before.popLast() + document.text = try #require(previous) + #expect(document.text == "dirty 😀") + contexts.close(owner: fixture.window) + #expect(code(await dispatcher.dispatch(request("documents/getSnapshot", base), pluginID: "plugin")) == "plugin.host.contextExpired") + } + + @Test + func metadataPermissionNeverGrantsFileOrEditorMethods() async { + let dispatcher = PluginHostServiceDispatcher(contexts: PluginWorkspaceContexts()) + dispatcher.register(PluginDocumentHostService(contexts: dispatcher.contexts)) + dispatcher.beginSession(pluginID: "plugin", hostServices: ["workspace", "editor"]) + defer { dispatcher.endSession(pluginID: "plugin") } + #expect(code(await dispatcher.dispatch(request("workspace/readFile", [:]), pluginID: "plugin")) == "plugin.host.permissionDenied") + #expect(code(await dispatcher.dispatch(request("editor/applyEdits", [:]), pluginID: "plugin")) == "plugin.host.permissionDenied") + #expect(dispatcher.supportedGrants(["lithe/host/editor/applyEdits", "lithe/host/editor/missing"]) == ["lithe/host/editor/applyEdits"]) + } + + @Test + func closingContextCancelsAnInflightRequestExactlyOnce() async throws { + let contexts = PluginWorkspaceContexts() + let window = NSObject() + let context = contexts.open(owner: window, rootURL: URL(fileURLWithPath: "/fixture/workspace"), remote: nil) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + let handler = HeldHandler() + dispatcher.register(handler) + dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/test/wait"]) + defer { handler.release.open(); dispatcher.endSession(pluginID: "plugin") } + let answered = TestGate() + var answers: [Result] = [] + dispatcher.handle(request("test/wait", ["context": reference(context)]), pluginID: "plugin") { + answers.append($0); answered.open() + } + try #require(await handler.started.waitUntilOpen()) + contexts.close(owner: window) + try #require(await answered.waitUntilOpen()) + try #require(await handler.finished.waitUntilOpen()) + #expect(answers.count == 1) + #expect(code(answers[0]) == "plugin.host.contextExpired") + } + + @Test + func anOldSessionCannotCancelOrAnswerForItsReplacement() async throws { + let dispatcher = PluginHostServiceDispatcher(contexts: PluginWorkspaceContexts()) + let handler = HeldHandler() + dispatcher.register(handler) + let old = dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/test/wait"]) + let current = dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/test/wait"]) + defer { handler.release.open(); dispatcher.endSession(pluginID: "plugin") } + let answered = TestGate() + var count = 0 + dispatcher.handle(request("test/wait", [:]), pluginID: "plugin", sessionID: current) { _ in count += 1; answered.open() } + try #require(await handler.started.waitUntilOpen()) + dispatcher.cancel(hostRequestID: "test-request", pluginID: "plugin", sessionID: old) + #expect(!handler.finished.isOpen) + handler.release.open() + try #require(await answered.waitUntilOpen()) + #expect(count == 1) + dispatcher.handle(request("test/wait", [:]), pluginID: "plugin", sessionID: old) { _ in count += 1 } + #expect(count == 1) + } + + @Test + func previewExplicitlyChangesAnExistingEditorsMode() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + fixture.addFile("page.html") + fixture.window.openBuffers[fixture.path("page.html")] = true + let preview = PluginHostPreviewService(editor: fixture.editor, directory: fixture.directory) + let result = try await preview.openHTML(.init(context: fixture.context(), resource: fixture.resource("page.html"))) + #expect(result.presentation == .editorAndPreview) + #expect(fixture.window.htmlPreviewOnly[fixture.path("page.html")] == false) + _ = try await preview.openHTML(.init(context: fixture.context(), resource: fixture.resource("page.html"), presentation: .previewOnly)) + #expect(fixture.window.htmlPreviewOnly[fixture.path("page.html")] == true) + #expect(fixture.window.diskLoads.isEmpty) + } + + private func reference(_ context: PluginWorkspaceContext) -> ToolingJSONValue { + .object(["contextID": .string(context.contextID), "generation": .integer(context.generation)]) + } + private func request(_ method: String, _ params: [String: ToolingJSONValue]) -> PluginHostRequest { + PluginHostRequest(id: "test-request", method: "lithe/host/" + method, + service: String(method.split(separator: "/").first!), params: .object(params)) + } + private func code(_ result: Result) -> String? { + if case .failure(let error) = result { return error.code }; return nil + } + + private final class Surface: EditorDocumentSurface { + var before: [String] = [] + func readSelections(of document: EditorDocument) async throws -> EditorSurfaceSelections { + EditorSurfaceSelections(selections: [.init(anchor: .init(line: 1, column: 1), active: .init(line: 1, column: 6))], version: document.contentVersion) + } + func apply(_ request: EditorSurfaceEditRequest, to document: EditorDocument) async throws -> UInt64 { + before.append(document.text) + document.text = EditorTextEditPlanner.apply(request.edits, to: document.text) + return document.contentVersion + } + } + + private final class HeldHandler: PluginHostServiceHandling { + let service = "test" + let methods: Set = ["lithe/host/test/wait"] + let started = TestGate(), release = TestGate(), finished = TestGate() + func answer(_ call: PluginHostCall) async -> Result { + defer { finished.open() } + started.open() + _ = await release.waitUntilOpen() + return .success(.null) + } + } +} diff --git a/macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift b/macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift index 2153f1da54..bd81a3fd4a 100644 --- a/macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift +++ b/macos/Tests/LitheTests/PluginWorkspaceFileServiceTests.swift @@ -580,6 +580,46 @@ struct PluginWorkspaceFileServiceTests { #expect(!release.isOpen) } + @MainActor + @Test func publicFileLeasesSurviveAnotherWindowClosingAndReopening() async throws { + let workspace = try TemporaryWorkspace() + defer { workspace.remove() } + try workspace.write("a.txt", "hello") + let contexts = PluginWorkspaceContexts() + let first = FakePluginHostWindow(snapshot: PluginHostWindowSnapshot(windowID: UUID(), workspaceURL: workspace.base.standardizedFileURL, + workspaceGeneration: 1, isRemoteWorkspace: false, isForeground: true, roots: [])) + let second = FakePluginHostWindow(snapshot: first.snapshot) + let metadata = PluginWorkspaceHostService(contexts: contexts, backendStateDirectory: { _ in workspace.base }, rootIDSecret: { Data(repeating: 1, count: 32) }) + let files = Self.makeService() + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(PluginFileHostService(contexts: contexts, metadata: metadata, files: files)) + dispatcher.beginSession(pluginID: "plugin", hostServices: ["lithe/host/workspace/readFile", "lithe/host/workspace/watch"]) + defer { dispatcher.endSession(pluginID: "plugin"); contexts.close(owner: first); contexts.close(owner: second) } + let a = contexts.open(owner: first, rootURL: workspace.base, remote: nil) + let b = contexts.open(owner: second, rootURL: workspace.base, remote: nil) + let rootID = metadata.rootID(of: a.folders[0], pluginID: "plugin") + func request(_ method: String, _ context: PluginWorkspaceContext, path: String) -> PluginHostRequest { + PluginHostRequest(id: UUID().uuidString, method: "lithe/host/workspace/" + method, service: "workspace", params: .object([ + "context": .object(["contextID": .string(context.contextID), "generation": .integer(context.generation)]), + "resource": .object(["rootID": .string(rootID), "path": .string(path)]), + ])) + } + _ = try await dispatcher.dispatch(request("watch", a, path: ""), pluginID: "plugin").get() + _ = try await dispatcher.dispatch(request("watch", b, path: ""), pluginID: "plugin").get() + #expect(files.activeWatchSubscriptionCount == 2) + contexts.close(owner: first) + #expect(files.activeWatchSubscriptionCount == 1) + _ = try await dispatcher.dispatch(request("readFile", b, path: "a.txt"), pluginID: "plugin").get() + let reopened = contexts.open(owner: first, rootURL: workspace.base, remote: nil) + #expect(reopened.generation == 1) + #expect(metadata.rootID(of: reopened.folders[0], pluginID: "plugin") == rootID) + let result = try await dispatcher.dispatch(request("readFile", reopened, path: "a.txt"), pluginID: "plugin").get() + guard case .object(let object) = result else { Issue.record("missing read result"); return } + #expect(object["text"] == .string("hello")) + dispatcher.endSession(pluginID: "plugin") + #expect(files.activeWatchSubscriptionCount == 0) + } + // MARK: Helpers private static func makeService( diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 24d547c4bf..912c3468d8 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -2633,7 +2633,22 @@ impl RuntimeSession { data, )? } - (result, None) => json_rpc_result(&backend_id, result.unwrap_or(Value::Null))?, + (result, None) => { + let result = result.unwrap_or(Value::Null); + if state.client.plugin_function_version.unwrap_or(0) + >= super::plugin_function::CONTEXT_VERSION + && result.to_string().len() > super::plugin_function::MAX_RESULT_BYTES + { + json_rpc_error_with_code( + &backend_id, + -32014, + "Host result exceeds the byte limit.", + super::plugin_function::codes::HOST_LIMIT_EXCEEDED, + )? + } else { + json_rpc_result(&backend_id, result)? + } + } } }; self.send_messages_or_fail(&outbound_order, vec![message], "hostResponse") @@ -5294,9 +5309,10 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> // know could never be forwarded. if !request.host_services.is_empty() && (request.plugin_id.is_none() - || request.host_services.iter().any(|service| { - !super::plugin_function::GRANTABLE_HOST_SERVICES.contains(&service.as_str()) - })) + || request + .host_services + .iter() + .any(|service| !super::plugin_function::is_host_grant(service))) { return Err(invalid_field("hostServices")); } diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 150fa0a40b..6ca174a28a 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -54,7 +54,7 @@ const MAX_PLUGIN_ERROR_CODE_BYTES: usize = 64; const MAX_MESSAGE_BYTES: usize = 1024; /// Bound of one serialized function result. Larger documents belong in a /// domain protocol with its own decoder, not in a plugin-private reply. -const MAX_RESULT_BYTES: usize = 1 << 20; +pub(crate) const MAX_RESULT_BYTES: usize = 1 << 20; /// Upper bound of one invocation's deadline, matching build-provider requests. pub(crate) const MAX_FUNCTION_TIMEOUT_MS: u64 = 60 * 60_000; @@ -70,10 +70,42 @@ const HOST_SERVICE_HOST: &str = "host"; /// of version 3 has it, because only the session that holds a subscription /// can name it. const HOST_SERVICE_SUBSCRIPTIONS: &str = "subscriptions"; -/// Services a launch plan may grant through `lsp.startServer.hostServices`. -/// The implicit `host` and `subscriptions` services are never listed. +/// Legacy aggregate grants. New methods are granted individually so adding a +/// method to `workspace` or `editor` never widens an existing permission. pub(crate) const GRANTABLE_HOST_SERVICES: &[&str] = &[HOST_SERVICE_CREDENTIALS, HOST_SERVICE_WORKSPACE]; + +/// Whether a launch grant names an implemented legacy service or host method. +pub(crate) fn is_host_grant(grant: &str) -> bool { + GRANTABLE_HOST_SERVICES.contains(&grant) + || HOST_METHODS + .iter() + .any(|entry| entry.method == grant && entry.permission.is_some()) +} + +/// Methods admitted by one manifest permission. The platform intersects these +/// with its registered method handlers before starting the backend. +pub(crate) fn methods_for_permission(permission: &str) -> Vec<&'static str> { + HOST_METHODS + .iter() + .filter(|entry| entry.permission == Some(permission)) + .map(|entry| entry.method) + .collect() +} + +/// Permissions for the additive host services of backend 2. +pub(crate) const HOST_PERMISSIONS: &[&str] = &[ + "workspace.files.read", + "workspace.files.watch", + "workspace.search", + "documents.read", + "editor.edit", + "editor.open", + "preview.open", + "browser.open", + "window.messages", +]; + const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; @@ -457,6 +489,8 @@ pub(crate) struct HostMethod { /// First protocol version that defines the method. pub since: u64, pub params: HostParams, + /// A separate grant per method for new services; legacy services use their aggregate grant. + pub permission: Option<&'static str>, } /// Every backend-to-host method Core admits. A method missing here is @@ -467,51 +501,199 @@ pub(crate) const HOST_METHODS: &[HostMethod] = &[ HostMethod { method: CREDENTIALS_GET, service: HOST_SERVICE_CREDENTIALS, + permission: None, since: 2, params: HostParams::Credential { with_secret: false }, }, HostMethod { method: CREDENTIALS_SET, service: HOST_SERVICE_CREDENTIALS, + permission: None, since: 2, params: HostParams::Credential { with_secret: true }, }, HostMethod { method: CREDENTIALS_DELETE, service: HOST_SERVICE_CREDENTIALS, + permission: None, since: 2, params: HostParams::Credential { with_secret: false }, }, HostMethod { method: GET_CAPABILITIES, service: HOST_SERVICE_HOST, + permission: None, since: CONTEXT_VERSION, params: HostParams::Empty, }, HostMethod { method: SUBSCRIPTIONS_DISPOSE, service: HOST_SERVICE_SUBSCRIPTIONS, + permission: None, since: CONTEXT_VERSION, params: HostParams::Subscription, }, HostMethod { method: WORKSPACE_LIST_CONTEXTS, service: HOST_SERVICE_WORKSPACE, + permission: None, since: CONTEXT_VERSION, params: HostParams::Empty, }, HostMethod { method: WORKSPACE_GET_CONTEXT, service: HOST_SERVICE_WORKSPACE, + permission: None, since: CONTEXT_VERSION, params: HostParams::Context, }, HostMethod { method: WORKSPACE_GET_FOLDERS, service: HOST_SERVICE_WORKSPACE, + permission: None, since: CONTEXT_VERSION, params: HostParams::Context, }, + HostMethod { + method: "lithe/host/workspace/readDirectory", + service: "workspace", + permission: Some("workspace.files.read"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/stat", + service: "workspace", + permission: Some("workspace.files.read"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/readFile", + service: "workspace", + permission: Some("workspace.files.read"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/watch", + service: "workspace", + permission: Some("workspace.files.watch"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/findFiles", + service: "workspace", + permission: Some("workspace.search"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/workspace/findText", + service: "workspace", + permission: Some("workspace.search"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/documents/list", + service: "documents", + permission: Some("documents.read"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/documents/getSnapshot", + service: "documents", + permission: Some("documents.read"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/getActive", + service: "editor", + permission: Some("documents.read"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/getSelections", + service: "editor", + permission: Some("documents.read"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/applyEdits", + service: "editor", + permission: Some("editor.edit"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/replaceSelections", + service: "editor", + permission: Some("editor.edit"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/insertText", + service: "editor", + permission: Some("editor.edit"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/open", + service: "editor", + permission: Some("editor.open"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/editor/reveal", + service: "editor", + permission: Some("editor.open"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/preview/openHTML", + service: "preview", + permission: Some("preview.open"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/browser/openExternal", + service: "browser", + permission: Some("browser.open"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/window/showInformation", + service: "window", + permission: Some("window.messages"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/window/showWarning", + service: "window", + permission: Some("window.messages"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/window/showError", + service: "window", + permission: Some("window.messages"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, ]; /// Services every plugin-owned session of their version has without a @@ -547,7 +729,13 @@ pub(crate) fn host_request( details: None, }); }; - if !is_implicit_service(entry.service) && !granted.contains(entry.service) { + if !is_implicit_service(entry.service) + && !granted.contains(if entry.permission.is_some() { + entry.method + } else { + entry.service + }) + { return Err(HostRequestRefusal { // Version 2 answered a missing grant like a missing method. rpc_code: if version >= CONTEXT_VERSION { @@ -560,6 +748,16 @@ pub(crate) fn host_request( details: None, }); } + if version >= CONTEXT_VERSION + && params.is_some_and(|value| value.to_string().len() > MAX_RESULT_BYTES) + { + return Err(HostRequestRefusal { + rpc_code: host_rpc_code(codes::HOST_LIMIT_EXCEEDED), + code: codes::HOST_LIMIT_EXCEEDED, + message: "Host request exceeds the byte limit.", + details: None, + }); + } check_host_params(entry.params, params, version)?; if entry.method == GET_CAPABILITIES { return Ok(HostRoute::Answer(capabilities_answer(version, granted))); @@ -585,10 +783,14 @@ pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> V list.push(json!(method)); } }; - for entry in HOST_METHODS - .iter() - .filter(|entry| version >= entry.since && admitted(entry.service)) - { + for entry in HOST_METHODS.iter().filter(|entry| { + version >= entry.since + && if entry.permission.is_some() { + granted.contains(entry.method) + } else { + admitted(entry.service) + } + }) { add(entry.service, "methods", entry.method); } for entry in HOST_NOTIFICATIONS @@ -606,6 +808,8 @@ pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> V "limits": { "pendingHostRequests": MAX_PENDING_HOST_REQUESTS, "functionResultBytes": MAX_RESULT_BYTES, + "hostRequestBytes": MAX_RESULT_BYTES, + "hostResultBytes": MAX_RESULT_BYTES, "errorMessageBytes": MAX_MESSAGE_BYTES, "errorDetailsBytes": MAX_HOST_DETAILS_BYTES, "notificationBytes": MAX_HOST_NOTIFICATION_BYTES, diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 27606442cc..e1e56208c8 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -85,7 +85,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (PROJECT_GENERATORS, &[1]), (SERVER_PROCESS, &[1, 2]), (SETTINGS, &[1, 2]), - (UI, &[1, 2, 3]), + (UI, &[1, 2, 3, 4]), (VCS_PROVIDERS, &[1]), ]; diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 01d9cb2f4d..62770d46be 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -275,8 +275,13 @@ enum UIDock { struct UICommand { id: String, title: String, - #[serde(rename = "toolWindowID")] - tool_window_id: String, + /// Absent for a backend command from `ui` 4 on. + #[serde( + default, + rename = "toolWindowID", + skip_serializing_if = "Option::is_none" + )] + tool_window_id: Option, #[serde(default)] order: i32, placements: Vec, @@ -1180,14 +1185,17 @@ impl DeclarativePluginManifest { "UI contributions require plugin API level 2.", )); } - if ui.tool_windows.is_empty() || ui.tool_windows.len() > 64 || ui.commands.len() > 128 { + let version = self.requires.get(capabilities::UI).copied().unwrap_or(1); + if (ui.tool_windows.is_empty() && (version < 4 || ui.commands.is_empty())) + || ui.tool_windows.len() > 64 + || ui.commands.len() > 128 + { return Err(invalid( "ui", "UI requires 1-64 tool windows and at most 128 commands.", )); } // Below level 3 there is no `requires`, and the section means `ui` 1. - let version = self.requires.get(capabilities::UI).copied().unwrap_or(1); let prefix = format!("{}.", self.id); let mut ids = BTreeSet::new(); let mut windows = BTreeSet::new(); @@ -1237,7 +1245,19 @@ impl DeclarativePluginManifest { || command.title.len() > 256 || !ids.insert(command.id.clone()) || !action_ids.insert(command.id.clone()) - || !windows.contains(&command.tool_window_id) + || match &command.tool_window_id { + Some(id) => !windows.contains(id), + None => { + version < 4 + || self.backend.is_none() + || self + .requires + .get(capabilities::BACKEND) + .copied() + .unwrap_or(1) + < 2 + } + } || command.placements.is_empty() { return Err(invalid( @@ -1269,6 +1289,11 @@ impl DeclarativePluginManifest { // the plugin from hosts that could run it. if (version == 2 && ui.tool_windows.iter().all(|window| window.web.is_none())) || (version == 3 && ui.tool_windows.iter().all(|window| window.native.is_none())) + || (version == 4 + && ui + .commands + .iter() + .all(|command| command.tool_window_id.is_some())) { return Err(invalid( "requires.ui", @@ -1284,7 +1309,10 @@ impl DeclarativePluginManifest { fn validate_permissions(&mut self) -> Validation<()> { for permission in &self.permissions { - if !KNOWN_PERMISSIONS.contains(&permission.as_str()) { + if !KNOWN_PERMISSIONS.contains(&permission.as_str()) + && !crate::lsp::interface::plugin_function::HOST_PERMISSIONS + .contains(&permission.as_str()) + { return Err(PluginRejection::at( "plugin.permission.unknown", "permissions", @@ -1367,6 +1395,17 @@ impl DeclarativePluginManifest { "workspace.metadata is used by a plugin backend 2; require backend 2 or remove the permission.", )); } + if self + .permissions + .iter() + .any(|p| crate::lsp::interface::plugin_function::HOST_PERMISSIONS.contains(&p.as_str())) + && (self.backend.is_none() || self.backend_version() < 2) + { + return Err(invalid( + "permissions", + "Host API permissions require backend 2.", + )); + } for permission in required { if !self .permissions @@ -1515,6 +1554,12 @@ impl DeclarativePluginManifest { { services.push(super::backend::HOST_SERVICE_WORKSPACE); } + for permission in &self.permissions { + services + .extend(crate::lsp::interface::plugin_function::methods_for_permission(permission)); + } + services.sort_unstable(); + services.dedup(); services } diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index ac97e9a9d4..4908e58b5f 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -2083,3 +2083,26 @@ fn debug_start_arguments_refuse_stale_registrations_and_missing_target_facts() { .expect("payload is well formed"); assert_eq!(stale["error"]["code"], "debug.provider.stale"); } + +/// Backend commands are install-time gated by ui 4, and read permissions grant +/// only their own methods rather than an entire growing service namespace. +#[test] +fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/command-backend.plugin.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let mut old = manifest.clone(); + old["requires"]["ui"] = json!(3); + assert_ne!(validate(old)["status"], "ok"); + let hosted = validate_command(json!({"manifest": manifest, "hostCapabilities": {"backend":[2],"serverProcess":[1],"ui":[1,2,3]}})).unwrap(); + assert_eq!(hosted["error"]["code"], "plugin.api.unsupported"); + let plan = language_server_launch_plan_command(json!({ + "manifest": manifest, "backend": true, + "request": {"pluginDirectory":"/plugins/example","stateDirectory":"/state/example","architecture":"arm64"} + })).unwrap(); + let grants = plan["plan"]["backend"]["hostServices"].as_array().unwrap(); + assert!(grants.contains(&json!("lithe/host/editor/getSelections"))); + assert!(grants.contains(&json!("lithe/host/editor/applyEdits"))); + assert!(!grants.contains(&json!("editor"))); + assert!(!grants.contains(&json!("lithe/host/workspace/readFile"))); +} diff --git a/scripts/resources/reuse-worktree-resources.mjs b/scripts/resources/reuse-worktree-resources.mjs index d2c910f8c9..19f3e51934 100644 --- a/scripts/resources/reuse-worktree-resources.mjs +++ b/scripts/resources/reuse-worktree-resources.mjs @@ -85,6 +85,8 @@ async function readRegistry() { // rebuild from the target checkout, never copy another worktree's translations. // AutoBuild publication checkpoints and baseline downloads are invocation-owned, // not reusable build inputs, even without plugin mirror staging. + // Plugin host secrets and workspace state also take this rejection route, + // never a download/cache validator: their identity belongs to the installation. const excludedResources = registry.excludedResources ?? []; if (!Array.isArray(excludedResources)) throw new Error("Invalid excluded worktree resources"); for (const resource of excludedResources) { diff --git a/scripts/resources/test-reuse-worktree-resources.mjs b/scripts/resources/test-reuse-worktree-resources.mjs index 67d8fc0c15..f2ddc9b21b 100644 --- a/scripts/resources/test-reuse-worktree-resources.mjs +++ b/scripts/resources/test-reuse-worktree-resources.mjs @@ -309,7 +309,7 @@ try { await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); - for (const id of ["official-plugin-packages", "plugin-marketplace-state", "mcp-registry-metadata", "mcp-tool-probe-state", "agent-instruction-library-state", "agent-hook-library-state"]) { + for (const id of ["official-plugin-packages", "plugin-marketplace-state", "plugin-host-runtime-state", "mcp-registry-metadata", "mcp-tool-probe-state", "agent-instruction-library-state", "agent-hook-library-state"]) { assert.ok(!listed.stdout.includes(id)); const refused = reuse(["--resource", id]); assert.notEqual(refused.status, 0); diff --git a/scripts/resources/worktree-resources.json b/scripts/resources/worktree-resources.json index f181f344b5..a24cb19aa0 100644 --- a/scripts/resources/worktree-resources.json +++ b/scripts/resources/worktree-resources.json @@ -440,6 +440,16 @@ ], "identity": "Current source palettes, upstream pinned theme revisions, license/NOTICE files, Monaco and Bun versions; no validated build identity stamp", "reason": "Rebuild JavaScript and bundled theme licenses together from this checkout. No copy stage or runtime writes; packaged assets remain read-only and preserve code signatures and Sparkle delta baselines." + }, + { + "id": "plugin-host-runtime-state", + "reusable": false, + "locations": [ + "/Lithe/PluginHost/root-id.key", + "/Lithe/PluginState//backend/workspaces//" + ], + "identity": "Mutable per-installation root identity secret and per-plugin workspace state; no version/platform/architecture/toolchain artifact identity stamp", + "reason": "Created only by the platform host outside the app bundle and plugin package. The secret is installation-owned; workspace state survives disable/update and is removed on uninstall. Never copy, share, or validate as reusable worktree resources at any stage; no effect on code signing or Sparkle delta baselines." } ] } diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index cd3d02e6a8..b1b23c92b0 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1876,7 +1876,7 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's - A session holds at most 16 unanswered host requests; a further one is refused with `plugin.host.failed`. Unanswered requests end with the session. - `hostServices` needs `pluginId` and names only services this Core defines - (`credentials`, `workspace`); otherwise `lsp.startServer` is + (`credentials`, `workspace`, or registered method-grant names); otherwise `lsp.startServer` is `invalid_request`. ### Workspace contexts and capability discovery (protocol version 3) @@ -2071,6 +2071,103 @@ So that a version 3 backend works with every host of version 3: `fixtures/function-protocol.json` (`hostRequests`) holds the unsupported-option cases. +### Public document, file and window methods (protocol version 3) + +These methods are implemented by the macOS host. Windows does not advertise +`backend` support. `getCapabilities` lists only the intersection of the plugin's +permission-derived grants and this host's registered method handlers. + +New grants in `lsp.startServer.hostServices` use **full method names**, not service +names. The legacy `workspace` grant still admits only `listContexts`, `getContext` +and `getFolders`; it never grants file reads. The legacy `credentials` grant is +unchanged. Registering another handler cannot replace a method already registered. + +All methods below require `context: {contextID, generation}`. Resource arguments +use `{rootID, path}` from that plugin's `getFolders` response; paths use `/` and +are relative to the root. A root ID is not a lifetime or an authorization token. +Opening the same folder in two windows creates independent context-owned leases; +closing one does not invalidate the other, and reopening works with generation 1 +in its new context. Remote contexts are explicitly refused by this first set of +methods. Unknown options fail before side effects with `unsupportedOption`. + +| Methods (prefix `lithe/host/`) | Permission | Params in addition to `context` | Result | +| --- | --- | --- | --- | +| `documents/list` | `documents.read` | none | `{documents:[]}` | +| `documents/getSnapshot` | `documents.read` | `documentID` | `{document:, text}` from the dirty live buffer | +| `editor/getActive` | `documents.read` | none | summary of the foreground text tab, or `null` | +| `editor/getSelections` | `documents.read` | `documentID` | `{documentID,version,selections:[{anchor,active}],primaryIndex}` | +| `editor/applyEdits` | `editor.edit` | `documentID,expectedVersion,edits:[{range,text}]` | `{documentID,version,applied}` | +| `editor/replaceSelections` | `editor.edit` | `documentID,expectedVersion,selections:[{anchor,active}],replacements:[string]` | same; one replacement for all selections or one per selection | +| `editor/insertText` | `editor.edit` | `documentID,expectedVersion,at,text` | same | +| `workspace/readDirectory` | `workspace.files.read` | `resource,cursor?,limit?` | `{entries:[{name,resource,kind}],nextCursor}` | +| `workspace/stat` | `workspace.files.read` | `resource` | `{resource,kind,size,modifiedMilliseconds}` | +| `workspace/readFile` | `workspace.files.read` | `resource,maximumBytes?` | `{resource,source:"disk",byteCount,isTruncated,identity,text?,encoding?,base64?}` | +| `workspace/watch` | `workspace.files.watch` | `resource,recursive?` (default false) | `{subscriptionID}`; events contain `{rootID,changedPaths,requiresRescan}` | +| `workspace/findFiles` | `workspace.search` | `resource` naming the root (`path:""`), `query,fileMask?,cursor?,limit?` | `{matches:[],nextCursor,isTruncated,source:"disk"}` | +| `workspace/findText` | `workspace.search` | root `resource,pattern,fileMask?,caseSensitive?,wholeWords?,regularExpression?,cursor?,limit?` | same envelope, matches `{resource,line,preview,range:null}`; the upstream search owner currently reports lines, not exact match spans | +| `editor/open`, `editor/reveal` | `editor.open` | `resource,activation?` (`select` or `background`), `range?,tab?` (`regular` only) | document identity/version and state, `wasAlreadyOpen,windowIsForeground,appliedSelection` | +| `preview/openHTML` | `preview.open` | HTML `resource,presentation?` (`editorAndPreview` or `previewOnly`), `range?` | open result plus `usesLiveBuffer:true,presentation`; explicitly changes the existing tab's presentation | +| `browser/openExternal` | `browser.open` | exactly one of HTTP(S) `url` or HTML `resource`; `unsaved?` (`openSavedFile` or `requireSaved`) for a resource | `{opened:true,source:"web"|"disk",hasUnsavedChanges?}` | +| `window/showInformation`, `window/showWarning`, `window/showError` | `window.messages` | `text,actions?` | `{selectedIndex}`; zero-based, or `null` on dismissal | + +A document summary has `documentID,version,path,languageID,isReadOnly,isDirty, +encoding,lineEnding`. `path` is workspace-relative or null for a document outside +the root; `languageID` may be null. Positions `{line,column}` are one-based UTF-16; +ranges `{start,end}` are half-open and cannot split a surrogate pair. Selections +keep anchor/active direction. Batches are atomic and one Monaco undo step. Stale +versions, read-only buffers, pending editor synchronization and IME composition +are refused. Background documents without a mounted editor can be listed/read, +but edits/selections may return `busy`; opening a foreground tab does not imply +that its web editor is already ready. Retry reads after readiness, not edits +blindly. `editor.open/reveal` currently applies a range's start as a caret and +reports `{kind:"caret",line,column}`, not a fabricated full selection. + +Limits: snapshots and file reads expose at most 128 KiB of source text/bytes per +call; oversized snapshots return `limitExceeded`, file reads report truncation. +This leaves room for worst-case JSON escaping under the 1 MiB serialized host +request/result budget. Batches contain at most 1,000 edits/selections. Directory +pages default to 200 and cap at 1,000; search pages default to 100 and cap at 200, +with the search owner's overall 200-result cap. Explicit file reads use +component-wise `O_NOFOLLOW`; listings omit symlinks. Hidden-file rules affect +listing/search/watch, not authorization for explicit reads. Open/browser paths +retain the existing canonical containment checks and their documented +check-to-open race; native backends are not OS-sandboxed. + +Machine host requests have a 30-second host deadline. User-choice messages wait +until the user answers, the request is cancelled, or the context/session closes. +Messages retain the per-plugin/window rate and queue limits (5/10 seconds, +3 pending per plugin, 10 per window, 2,000 text characters, 3 actions of 40 +characters). Session exit revokes grants and cancels requests/subscriptions; +old session callbacks cannot affect a replacement. Closing a context cancels its +requests with `contextExpired`. Notifications have a 128-entry per-session queue; +context revisions coalesce to the latest, and overflow ends the session instead +of allowing unbounded memory or silently losing commands. Notification/response +writes share the process write watchdog and stop cleanup with function requests. + +Cancellation or timeout **does not promise rollback**: an edit or external open +may already have committed. The SDK must not replay a mutation on these failures. +On `versionConflict`, reread and recompute; on `contextExpired`, resolve the +original target again, never substitute the focused window. `busy`/`rateLimited` +may be retried with bounds; `permissionDenied`/`readOnly` need user intervention. + +### Backend-only commands (`ui` 4) + +`requires.ui:4` permits `ui.commands` without `toolWindowID` and allows an empty +or omitted `toolWindows` array. Such a command requires `backend:2`, the existing +`ui.contribute` and `backend.launch` permissions, and its `onCommand:` event. +Versions 1-3 retain their window-required behavior. An old host rejects ui 4 at +installation, rather than accepting a command it cannot execute. See +`fixtures/command-backend.plugin.json`. + +The command appears through the ordinary workbench command contributions and +sends `commands/didRun` without opening a dummy tool window. Commands arriving +while initialize is pending wait until ready; a crashed backend can start again +on its declared command event. Background workspace activation has two concurrent +start slots; explicit command activation does not wait for unrelated background +plugins. `didRun` remains an event, not an acknowledgement that the command's +business action succeeded; a future result-bearing execute API must use the +existing request lifecycle rather than infer success from notification delivery. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. diff --git a/shared/contracts/plugins/v1/fixtures/command-backend.plugin.json b/shared/contracts/plugins/v1/fixtures/command-backend.plugin.json new file mode 100644 index 0000000000..31fdec6565 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/command-backend.plugin.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.workspacebackend", + "displayName": "Workspace Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "serverProcess": 1, + "ui": 4 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "workspace.metadata", + "ui.contribute", + "documents.read", + "editor.edit" + ], + "activationEvents": [ + "onCommand:dev.lithe.fixture.workspacebackend.transform" + ], + "languages": [], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + }, + "ui": { + "toolWindows": [], + "commands": [ + { + "id": "dev.lithe.fixture.workspacebackend.transform", + "title": "Transform selection", + "placements": [ + "commandPalette" + ] + } + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index 5ae2cc5303..b79fdcb094 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -278,6 +278,9 @@ } ], "hostRequests": [ + {"name": "workspace metadata never authorizes file reads", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/readFile", "params": {"context": {"contextID": "w1", "generation": 1}}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "editor read method grant never authorizes edits", "version": 3, "hostServices": ["lithe/host/editor/getSelections"], "method": "lithe/host/editor/applyEdits", "params": {}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "only the explicit file method is forwarded", "version": 3, "hostServices": ["lithe/host/workspace/readFile"], "method": "lithe/host/workspace/readFile", "params": {"context": {"contextID": "w1", "generation": 1}, "resource": {"rootID": "r1", "path": "hello.txt"}}, "expected": {"forward": "workspace"}}, { "name": "version 1 sessions get no host service even when granted", "version": 1, @@ -410,6 +413,8 @@ "limits": { "pendingHostRequests": 16, "functionResultBytes": 1048576, + "hostRequestBytes": 1048576, + "hostResultBytes": 1048576, "errorMessageBytes": 1024, "errorDetailsBytes": 4096, "notificationBytes": 1048576 diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 96126938a8..1d2b09a9ee 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -40,7 +40,7 @@ "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "enum": [1] }, - "ui": { "description": "Version 2 adds web content; version 3 adds native controls.", "enum": [1, 2, 3] }, + "ui": { "description": "Version 2 adds web content; version 3 adds native controls; version 4 adds backend-only commands.", "enum": [1, 2, 3, 4] }, "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } }, @@ -60,7 +60,16 @@ }, "permissions": { "type": "array", - "items": { "enum": ["backend.launch", "credentials.store", "debug.attach", "debug.launch", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web", "workspace.metadata"] } + "items": { "enum": ["backend.launch", "credentials.store", "debug.attach", "debug.launch", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web", "workspace.metadata", + "workspace.files.read", + "workspace.files.watch", + "workspace.search", + "documents.read", + "editor.edit", + "editor.open", + "preview.open", + "browser.open", + "window.messages"] } }, "activationEvents": { "type": "array", @@ -114,13 +123,11 @@ "ui": { "type": "object", "additionalProperties": false, - "required": [ - "toolWindows" - ], + "description": "ui 4 permits backend commands without toolWindowID or tool windows; older ui versions require a tool window.", "properties": { "toolWindows": { "type": "array", - "minItems": 1, + "minItems": 0, "maxItems": 64, "items": { "type": "object", @@ -189,7 +196,6 @@ "required": [ "id", "title", - "toolWindowID", "placements" ], "properties": { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 28cf86e41a..b9854d8248 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -2561,7 +2561,7 @@ directory, initialization options, optional runtime executable, optional `pluginId` of the plugin that owns the process, its optional `workspaceConfiguration` (an object, plugin-owned sessions only), its optional `hostServices` (the launch plan's `backend.hostServices`; -plugin-owned sessions only, and only services Core defines), plus +plugin-owned sessions only; legacy service grants or full public host-method names Core defines), plus initialize, post-initialize readiness, request, Java project build (`javaBuildTimeoutMilliseconds`), and shutdown deadlines. Java callers may also provide the versioned `mavenContext` accepted by @@ -2933,6 +2933,15 @@ JSON-RPC number, and `details`, an object of at most 4 KiB, as `error.data.details` ([failure codes](plugins/v1/README.md#workspace-contexts-and-capability-discovery-protocol-version-3)). +The macOS public document/file/window adapters use method-level grants for new +permissions. Core intersects grants with its method table for capability discovery; +macOS first intersects them with actual registered handlers. Legacy `workspace` +never authorizes file methods. Host request/result JSON is bounded to 1 MiB; +oversized v3 results return `plugin.host.limitExceeded`. See the plugin contract's +public document, file and window method table for exact shapes and cancellation +semantics. The underlying `PluginFunctionRequest` context and legacy SDK wire +shapes remain compatible. + `lsp.hostNotify` accepts `{ sessionId, method, params }` for a ready plugin-owned session and returns `{ delivered }`. Core sends the [host notification](plugins/v1/README.md#host-notifications-protocol-version-3) diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index a8dbee0acf..f76798ba02 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9626,7 +9626,9 @@ "native.theResourceNamesARootThisWindowDoesNotOffer": "The resource names a root this window does not offer.", "native.theWorkspaceContextIsNoLongerCurrent": "The workspace context is no longer current. Ask the host for it again.", "native.noWebBrowserCouldBeOpened": "No web browser could be opened.", - "native.thisPluginAlreadyHasTooManyMessagesWaiting": "This plugin already has too many messages waiting." + "native.thisPluginAlreadyHasTooManyMessagesWaiting": "This plugin already has too many messages waiting.", + "plugin.host.requestFailed": "The plugin host request could not be completed.", + "plugin.host.invalidParameters": "The plugin host request parameters are invalid." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index a4573e5d64..2d8b06ab52 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9626,7 +9626,9 @@ "native.theResourceNamesARootThisWindowDoesNotOffer": "资源指定的根目录不在此窗口提供的范围内。", "native.theWorkspaceContextIsNoLongerCurrent": "工作区上下文已失效,请重新向宿主获取。", "native.noWebBrowserCouldBeOpened": "无法打开网页浏览器。", - "native.thisPluginAlreadyHasTooManyMessagesWaiting": "此插件已有过多消息在等待。" + "native.thisPluginAlreadyHasTooManyMessagesWaiting": "此插件已有过多消息在等待。", + "plugin.host.requestFailed": "未能完成插件宿主请求。", + "plugin.host.invalidParameters": "插件宿主请求的参数无效。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 02d2f9c76c..649eab1944 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -91976,6 +91976,28 @@ "key": "This plugin already has too many messages waiting.", "arguments": [] } + }, + { + "id": "plugin.host.requestFailed", + "description": "Generic sanitized host API failure; structured code and details identify the recovery action.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin host request could not be completed.", + "arguments": [] + } + }, + { + "id": "plugin.host.invalidParameters", + "description": "Invalid public plugin host request parameters.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin host request parameters are invalid.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json index a9d9e202df..04fb623bb1 100644 --- a/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json +++ b/shared/platform-feature-matrix/features/plugin-backend-workspace-context.json @@ -18,7 +18,8 @@ "macos/Sources/Lithe/Application/Features/PluginWorkspaceContexts.swift", "macos/Tests/LitheTests/PluginHostServiceDispatchTests.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRootIDSecret.swift", - "macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift" + "macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift", + "macos/Tests/LitheTests/PluginPublicHostAPITests.swift" ] }, "windows": { @@ -30,5 +31,5 @@ }, "owner": "Extensions", "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core,核对 function-protocol.json 的 hostRequests/hostFailures/hostNotifications、v2 清单拒绝 onStartupFinished 与 *、旧宿主拒绝 backend 2、v3 会话第 17 个挂起请求返回 plugin.host.limitExceeded(-32014)、$/cancelRequest 立即返回 -32800 并发布 hostRequestCancelled、details 只以不超过 4 KiB 的对象送到 v3 后端、v2 会话的 lsp.hostNotify 返回 delivered:false。macOS 宿主:scripts/macos/test/test-macos.sh --filter \"PluginHostServiceDispatchTests|MacPluginBackendSessionsTests\"。macOS 原生验收:debug 应用安装一个 backend 2、声明 onWorkspaceOpen 与 workspace.metadata 的示例插件,打开本地工作区后确认后端自动启动、listContexts/getFolders 返回该窗口的根;同一窗口换工作区后收到 didChangeContexts,旧 contextID 得到 plugin.host.contextExpired;执行插件命令后后端收到 commands/didRun;打开远程工作区时 folders 不含 path;关闭窗口、停用插件后用 pgrep 确认后端不残留。", - "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)以及 PR #1595 评审补充(宿主通知、错误码细分与 details、换工作区发新 contextID、按插件的 rootID、listContexts 不建目录、能力表只列有 handler 的服务)。macOS 已有 Core 共享测试与宿主单测,尚未做原生验收,所以验证状态为待验证。新安装带后端的包仍需重启才能激活;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。协议 3 内只增不改:新的宿主服务、选项和错误码靠 getCapabilities 发现,不再升协议版本;v3 请求里不认识的字段回 plugin.host.unsupportedOption(-32018)并在 details.option 写明。" + "notes": "本条覆盖 #1548 计划的 API-01、API-05、API-40、API-41(M0)以及 PR #1595 评审补充(宿主通知、错误码细分与 details、换工作区发新 contextID、按插件的 rootID、listContexts 不建目录、能力表只列有 handler 的服务)。macOS 已有 Core 共享测试与宿主单测,尚未做原生验收,所以验证状态为待验证。新安装带后端的包仍需重启才能激活;启动预算未定。工作区信任暂不引入。Windows/Tauri 尚无插件后端宿主。协议 3 内只增不改:新的宿主服务、选项和错误码靠 getCapabilities 发现,不再升协议版本;v3 请求里不认识的字段回 plugin.host.unsupportedOption(-32018)并在 details.option 写明。 本轮增加 ui:4 纯后端命令、初始化中排队与崩溃后再激活、后台两路启动、逐方法授权、128 项通知队列、发送 watchdog、会话 token 隔离和 context 关闭时取消。" } diff --git a/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json b/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json index 7712b54383..2c0401fbe1 100644 --- a/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json +++ b/shared/platform-feature-matrix/features/plugin-host-editor-file-window-services.json @@ -2,7 +2,7 @@ "id": "plugin-host-editor-file-window-services", "area": "扩展", "group": "插件与主题", - "capability": "插件宿主服务(内部实现,尚未对插件开放):读取已打开文档与选区并按版本整批编辑(一个撤销步骤,IME 组字期间拒绝);工作区文件分页读目录、stat、读文件、监听与文件名/全文搜索(逐段 O_NOFOLLOW,远程拒绝);在指定窗口打开/定位编辑器、打开 HTML 预览、用系统浏览器打开 http/https 或本地文件、显示带按钮的插件消息", + "capability": "插件公共宿主服务:按方法授权的文档读取、选区、版本化编辑、工作区文件/搜索/watch、编辑器打开、HTML 预览与外部浏览器、窗口消息", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", @@ -22,7 +22,13 @@ "macos/Tests/LitheTests/PluginHostEditorServiceTests.swift", "macos/Tests/LitheTests/PluginHostBrowserServiceTests.swift", "macos/Tests/LitheTests/PluginHostMessageServiceTests.swift", - "macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift" + "macos/Tests/LitheTests/HTMLPreviewBridgeIsolationTests.swift", + "macos/Sources/Lithe/Application/Features/PluginDocumentHostService.swift", + "macos/Sources/Lithe/Application/Features/PluginFileHostService.swift", + "macos/Sources/Lithe/Application/Features/PluginWindowHostService.swift", + "macos/Tests/LitheTests/PluginPublicHostAPITests.swift", + "macos/Experiments/Monaco/workbench.integration.ts", + "macos/Tests/LitheTests/PluginHostProcessIntegrationTests.swift" ] }, "windows": { @@ -33,6 +39,6 @@ ] }, "owner": "Extensions", - "verification": "macOS 单测:scripts/macos/test/test-macos.sh --filter \"EditorTextEditPlannerTests|EditorDocumentServiceTests|PluginWorkspaceFileServiceTests|PluginHostEditorServiceTests|PluginHostBrowserServiceTests|PluginHostMessageServiceTests|HTMLPreviewBridgeIsolationTests\";前端:frontend/editor 下运行 plugin-edits.test.ts。服务接入宿主分发、对插件开放后,按计划 §7 用三个验收插件(选区转换器、分页文件浏览器、HTML 预览启动器)在 debug 应用中原生验收:版本冲突与 IME 组字时编辑被拒、撤销一步回退整批编辑;符号链接与 .. 越界被拒、远程工作区返回 authorityUnsupported;后台窗口打开文件不抢焦点、消息限频生效;验收后关闭应用并确认无残留进程。", - "notes": "本条覆盖 #1548 计划的 API-06–API-10、API-15–API-17(三种消息)。这些服务目前只是 Mac 内部实现,没有注册进 hostDispatcher,也没在 Core HOST_METHODS 登记,用户与插件都还观察不到,所以实现状态为部分实现、验证为待验证。HTML 预览不暴露插件 bridge 已有回归测试。QuickPick/InputBox、可替换预览标签和纯预览模式未做。Windows/Tauri 不在本轮范围。" + "verification": "macOS 单测:scripts/macos/test/test-macos.sh --filter \"EditorTextEditPlannerTests|EditorDocumentServiceTests|PluginWorkspaceFileServiceTests|PluginHostEditorServiceTests|PluginHostBrowserServiceTests|PluginHostMessageServiceTests|HTMLPreviewBridgeIsolationTests\";前端:frontend/editor 下运行 plugin-edits.test.ts。服务接入宿主分发、对插件开放后,按计划 §7 用三个验收插件(选区转换器、分页文件浏览器、HTML 预览启动器)在 debug 应用中原生验收:版本冲突与 IME 组字时编辑被拒、撤销一步回退整批编辑;符号链接与 .. 越界被拒、远程工作区返回 authorityUnsupported;后台窗口打开文件不抢焦点、消息限频生效;验收后关闭应用并确认无残留进程。 显式设置 LITHE_PLUGIN_HOST_TEST_NODE 后运行 PluginHostProcessIntegrationTests,验证真实 Node 后端经 Core 请求宿主、编辑 dirty 缓冲区、退出清理及插件包字节不变;scripts/macos/probe/probe-macos-monaco.sh --workbench-tests 验证真实 WebKit 的整批编辑与一次撤销。", + "notes": "公共方法已注册到 Core 与 Mac dispatcher;新增权限生成逐方法授权,不扩大 workspace.metadata。窗口入口统一解析 PluginWorkspaceContext,内部类型仅为 owner 适配值。HTML 预览显式设置呈现模式,支持 previewOnly;editor.open 返回 documentID/version。文件 lease 绑定 contextID,不用稳定 rootID 作为关闭边界。findText 的上游只给行号,range 如实为 null;纯命令通过 ui:4 开放。不含 Windows 实现。保留 pending,直到完成真实安装插件的整机验收。 纯命令入口同时修复命令面板鼠标点击禁用行仍执行的问题;回归由现有 PluginUIIconTests 覆盖。" } From ffd72466e5aa33521b607bc41f7fcde187238a59 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 18:35:50 +0800 Subject: [PATCH 15/16] fix(plugins): preserve older backends when host notifications are unsupported --- ...26-10-30-plugin-backend-workspace-context.md | 2 +- .../Plugins/MacPluginBackendSessions.swift | 2 +- .../Runtime/LanguageServerSession.swift | 10 +++++----- .../MacPluginBackendSessionsTests.swift | 17 ++++++++++++++++- shared/contracts/plugins/v1/README.md | 4 ++-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md index cd75f7d65c..988be82fff 100644 --- a/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md +++ b/.agents/notes/implemented/architecture/2026-10-30-plugin-backend-workspace-context.md @@ -131,7 +131,7 @@ Core 新增命令 `lsp.hostNotify { sessionId, method, params }`,返回 `{ del - context 关闭取消在途请求并返回 contextExpired;普通机器操作有 30 秒宿主截止时间。 等待用户按钮的消息不设总时限,仍随会话、context 和取消回收。 - 通知队列每会话最多 128 项,工作区 revision 合并为最新值。溢出报告失败并结束会话; - 不静默丢命令。发送前复核订阅/context。通知和响应与函数请求共用发送 watchdog,卡住的 stdin 不阻塞主线程停用。 + 不静默丢命令。发送前复核订阅/context。通知和响应与函数请求共用发送 watchdog,卡住的 stdin 不阻塞主线程停用。旧协议的 delivered:false 是正常降级,不因不支持通知结束仍可用的后端。 - `ui:4` 允许没有工具窗口的后端命令,旧 ui 版本保持不变。命令事件没有业务成功确认, 不把送出通知当成编辑已完成;有结果的执行将沿用函数请求生命周期。 diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 596994db47..5efd2773b8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -337,7 +337,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi self.onActivationFailure?(pluginID, error) continue } - guard await session.notify(method: next.method, params: next.params) else { + guard await session.notify(method: next.method, params: next.params) != nil else { if !Task.isCancelled, self.running[pluginID] === entry { self.onActivationFailure?(pluginID, PluginHostServiceError(.failed, L10n.string("The plugin host request could not be completed."))) diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 2d7a520d13..ed38c27299 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -947,10 +947,10 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// Sends the backend one host notification. Core writes it to the /// backend's input like an answer, so it is sent off the main actor. - /// `false` when the session is not running, the backend negotiated a - /// protocol without the notification, or Core refused it. - package func notify(method: String, params: ToolingJSONValue) async -> Bool { - guard let sessionID, case .ready = state, !Task.isCancelled else { return false } + /// `false` is an expected protocol downgrade (keep the backend alive); + /// `nil` means the session ended or the transport/Core refused the send. + package func notify(method: String, params: ToolingJSONValue) async -> Bool? { + guard let sessionID, case .ready = state, !Task.isCancelled else { return nil } let result = await send(timeout: requestTimeout) { core in core.notifyPluginBackend(sessionID: sessionID, method: method, params: params) } @@ -960,7 +960,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { return delivered case .failure(let failure): onLog?(.warning, "Plugin host notification was not delivered", failure.message, nil) - return false + return nil } } diff --git a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift index 4ed500feb6..505be25c2e 100644 --- a/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift +++ b/macos/Tests/LitheTests/MacPluginBackendSessionsTests.swift @@ -10,6 +10,19 @@ import Testing /// receives, how its credential requests are answered, and when it stops. @MainActor struct MacPluginBackendSessionsTests { + @Test + func anOlderNegotiatedProtocolSkipsNotificationsWithoutStoppingTheBackend() async throws { + let fixture = try BackendFixture(backendVersion: 2) + defer { fixture.remove() } + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + fixture.core.notificationSupported = false + fixture.sessions.runCommand("query.run", owner: ModuleID("\(BackendFixture.pluginID).ui"), context: nil) + try #require(await fixture.core.waitForNotifications(1)) + _ = try await fixture.sessions.pluginFunctions(ofPlugin: BackendFixture.pluginID) + #expect(fixture.core.starts.count == 1) + #expect(fixture.core.stops.isEmpty) + } + @Test func commandsWaitForInitializeAndRestartAfterExit() async throws { let fixture = try BackendFixture(backendVersion: 2, commandActivation: true) @@ -363,6 +376,7 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S private var notificationWaiters: [UUID: (count: Int, continuation: CheckedContinuation)] = [:] private var _delayReady = false private var _blockNotification = false + private var _notificationSupported = true let started = TestGate() let writeStarted = TestGate() let releaseWrite = TestGate() @@ -370,6 +384,7 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S let forcedStop = TestGate() var delayReady: Bool { get { lock.withLock { _delayReady } } set { lock.withLock { _delayReady = newValue } } } var blockNotification: Bool { get { lock.withLock { _blockNotification } } set { lock.withLock { _blockNotification = newValue } } } + var notificationSupported: Bool { get { lock.withLock { _notificationSupported } } set { lock.withLock { _notificationSupported = newValue } } } func finishInitialize() { lock.withLock { events["backend-\(nextSession)", default: []].append(LanguageServerRuntimeEvent(type: "stateChanged", state: "ready")) } @@ -466,7 +481,7 @@ private final class ScriptedBackendCore: LanguageServerRuntimeCore, @unchecked S return ready.values.map(\.continuation) } satisfied.forEach { $0.resume(returning: true) } - return .success(true) + return .success(notificationSupported) } /// Notifications are sent off the main actor; like `waitForResponses`, diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index b1b23c92b0..bd94d74663 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -405,7 +405,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | -| `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | +| `ui` | `ui` | `1`, `2` (web content), `3` (native controls), `4` ([backend-only commands](#backend-only-commands-ui-4)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | | `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation, v6 read-only model inputs | `1`, `2`, `3`, `4`, `5`, `6` | | `buildModelInputs` | Optional public model sharing between enabled workspace plugins | `1` | @@ -1902,7 +1902,7 @@ notifications. grants only the services it has handlers for, so a listed method never fails for want of one. `backendAuthority` is `"local"`: the backend runs on the machine that runs Lithe. `limits` gives `pendingHostRequests`, - `functionResultBytes`, `errorMessageBytes` (the cut of a host failure's + `functionResultBytes`, `hostRequestBytes`, `hostResultBytes`, `errorMessageBytes` (the cut of a host failure's message), `errorDetailsBytes`, and `notificationBytes`. A backend must check `services` instead of assuming a method from the version. - A context is one open workspace in one window: From 7c749385bce566eced1778e75b7a3ad7897162af Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 30 Oct 2026 19:24:42 +0800 Subject: [PATCH 16/16] =?UTF-8?q?test(macos):=20stabilize=20Agent=20popup?= =?UTF-8?q?=20clicks=20and=20record=20API-01=E2=80=9344=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agent selector/activity tests: fix the hosting view size only where the layout needs it, display windows before synthesized clicks, and compute click points from LitheDropdownMetrics in flipped and unflipped documents. - macOS plugin API plan: add the per-API completion table for PR #1595 and a summary of the public host methods with their permissions. --- docs/development/macos-plugin-api-plan.md | 79 +++++++++++++++++++ .../AgentActivityPresentationTests.swift | 3 + .../AgentSessionSelectorLayoutTests.swift | 19 ++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index 5b3ddf90b8..0245ef5aa0 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -13,6 +13,85 @@ 数据源、精确全文搜索匹配范围、文件修改/保存事务、可替换预览标签和 Windows 适配。三个完整安装插件的 UI 验收尚未全部完成,不把分层集成测试等同于整机验收。 +## API-01–44 完成度总表(PR #1595) + +这是**实现范围**,不是发布或全平台验收结论。已公开表示已注册到 Core 白名单及 macOS +宿主分发、插件可按权限调用;仍需以 `getCapabilities` 判断当前宿主支持的方法。 +既有能力是本次复用的基线,不计为本 PR 新增交付。部分完成逐项写明缺口;未做不代表 +IDE 内部没有相关功能。官方 SDK 新 API 封装/发布和三个完整安装插件整机验收仍未全部完成。 +Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 + +| ID | 本轮状态 | 可用部分与明确缺口 | +| --- | --- | --- | +| API-01 | 已公开 | 能力发现、backend:2 激活、订阅 dispose;新装后端仍需重启 | +| API-02 | 部分完成 | ui:4 无工具窗口的声明命令与 didRun;动态 register/有结果的 execute 未做 | +| API-03 | 部分完成 | 既有命令 placements 可用;通用菜单、快捷键贡献、context.set/状态栏未做 | +| API-04 | 部分完成 | didChangeContexts 与 watch 事件;文档/选区/活动编辑器语义事件未做 | +| API-05 | 已公开 | listContexts/getContext/getFolders,context/generation 与 rootID | +| API-06 | 已公开 | readDirectory/stat/readFile;公共读上限 128 KiB,内部上限不冒充协议能力 | +| API-07 | 已公开 | watch + subscriptions/notify/dispose,context/session 回收与溢出重扫 | +| API-08 | 部分完成 | findFiles/findText 分页与取消;只搜磁盘,精确匹配范围尚为 null | +| API-09 | 已公开 | documents/list/getSnapshot、editor/getActive/getSelections,含 dirty/版本/编码/EOL | +| API-10 | 已公开 | applyEdits/replaceSelections/insertText;版本校验、IME/只读拒绝、一次 Undo | +| API-11 | 未做 | 公共 save/saveAs/close 与工作区文件变更接口未接入 | +| API-12 | 未做 | 公共多文件 edit、content provider、showDiff 未接入 | +| API-13 | 部分完成 | 既有 ui:3 原生 Tree/List/Form;通用独立后端数据源/懒加载未做 | +| API-14 | 部分完成 | 既有包内网页 bridge、状态/生命周期;通用双向页面消息未做 | +| API-15 | 部分完成 | editor/open/reveal 返回 documentID/version;range 只起点光标,替换预览标签未做 | +| API-16 | 已公开 | preview/openHTML 支持 split/previewOnly;browser/openExternal 支持 HTTP(S)/已保存 HTML | +| API-17 | 部分完成 | 三类窗口消息和按钮/取消;通用 QuickPick/InputBox/授权文件句柄未做 | +| API-18 | 未做 | 通用剪贴板与 revealInFinder API 未开放 | +| API-19 | 部分完成 | 已有声明式设置 1/2;通用 settings.get/inspect/update/events 未做 | +| API-20 | 部分完成 | global/backend/workspace 可写目录与网页状态;统一 KV/配额/session API 未做 | +| API-21 | 既有能力 | 协议 2 credentials/get/set/delete;权限与插件隔离不变 | +| API-22 | 部分完成 | 既有函数进度与取消;独立 output channel、通用 log/progress API 未做 | +| API-23 | 部分完成 | 已有进程声明与运行时解析;通用 toolchains/process 服务未开放 | +| API-24 | 未做 | 插件专属终端 create/sendText/onExit 未开放 | +| API-25 | 既有能力 | 语言/LSP 贡献与成熟上游链路;本 PR 不新增语言语义 Provider | +| API-26 | 未做 | 公共诊断 collection/events API 未开放;既有 LSP 诊断不等于该 API | +| API-27 | 未做 | 通用主题、片段、装饰贡献未实现;既有 UI 图标不等价于完整主题 API | +| API-28 | 未做 | 公共 custom editor 文档 owner/保存/撤销/恢复未开放 | +| API-29 | 既有能力 | #1517 BuildProvider 1–6/model inputs;本轮旧 SDK E2E 兼容,不代表新指南齐全 | +| API-30 | 既有能力 | 已有 debugAdapters 1/2 与 DAP 宿主;本 PR 不重建调试协议 | +| API-31 | 既有能力 | 已有 vcsProviders:1;不包含内置 Git 公共读取面 | +| API-32 | 未做 | 通用 ServiceProvider 仍为提案 | +| API-33 | 既有能力 | 已有 projectGenerators:1;新 hooks 不在本轮 | +| API-34 | 未做 | 插件公共 IDE/AI/MCP 授权面未开放;独立 broker 不能直接转授 | +| API-35 | 未做 | 跨插件依赖/导出、远程 Provider 剩余接续未完成 | +| API-36 | 未做 | WASM/WASI 非本轮前置,未引入运行时 | +| API-37 | 部分完成 | getCapabilities 提供协议/方法/预算/后端执行位置;语言包与环境/主题事件未做 | +| API-38 | 未做 | 通用受管 fetch/download 未开放 | +| API-39 | 未做 | 通用 authentication session/provider 未开放 | +| API-40 | 部分完成 | 上下文携带 local/remote;新文件/编辑器服务对 remote 明确拒绝,非远程执行实现 | +| API-41 | 部分完成 | onWorkspaceOpen/onCommand、后台两路、initialize 超时/有界通知;内容匹配与全局冷启预算未做 | +| API-42 | 未做 | 内置 Git 公共 SCM 只读快照/events 未开放 | +| API-43 | 未做 | 保存前事件/带超时编辑尚未开放 | +| API-44 | 未做 | 插件贡献 Skills/Hooks/MCP 候选及独立授权评审未完成 | + +### 当前公共调用面汇总 + +所有宿主请求以 `lithe/host/` 为前缀;这里的点号是阅读简写,正式方法使用 `/`。 +精确参数、返回、权限、预算及失败语义见 +[契约公共方法表](../../shared/contracts/plugins/v1/README.md#public-document-file-and-window-methods-protocol-version-3)。 + +| 分组 | 已开放请求 | 权限 | +| --- | --- | --- | +| 协商/释放 | getCapabilities、subscriptions.dispose | 隐式,仅本会话 | +| 工作区上下文 | workspace.listContexts/getContext/getFolders | workspace.metadata | +| 文件读取 | workspace.readDirectory/stat/readFile | workspace.files.read | +| 监听/搜索 | workspace.watch;workspace.findFiles/findText | workspace.files.watch;workspace.search | +| 文档/选区 | documents.list/getSnapshot;editor.getActive/getSelections | documents.read | +| 版本化编辑 | editor.applyEdits/replaceSelections/insertText | editor.edit | +| 打开/定位 | editor.open/reveal | editor.open | +| 预览/浏览器 | preview.openHTML;browser.openExternal | preview.open;browser.open | +| 窗口消息 | window.showInformation/showWarning/showError | window.messages | +| 本插件凭据 | credentials.get/set/delete | credentials.store | + +宿主通知:workspace.didChangeContexts、subscriptions.notify、commands.didRun。 +纯命令:ui:4 + backend:2,不再需要假窗口;didRun 不代表业务执行成功,也不伪装成 +有结果的 commands.execute。完整工作流、旧协议降级、dirty/Unicode/Undo、资源清理的 +实际验证证据见 PR 说明与 `.artifacts/test-stability/`,不要用这张表替代运行时验收。 + ## 1. 目标与范围 让第三方开发者不修改 Lithe 源码,就能完成“读取选区并转换文本”“显示工程文件树”“打开 HTML 预览”等完整工作流。优先把 Mac 已有功能接成有权限、版本、生命周期和文档的公共 API,而不是重写 IDE 或先换插件运行时。 diff --git a/macos/Tests/LitheTests/AgentActivityPresentationTests.swift b/macos/Tests/LitheTests/AgentActivityPresentationTests.swift index 58051ae73e..092ec8a1cb 100644 --- a/macos/Tests/LitheTests/AgentActivityPresentationTests.swift +++ b/macos/Tests/LitheTests/AgentActivityPresentationTests.swift @@ -23,6 +23,7 @@ struct AgentActivityPresentationTests { for popup in window.childWindows ?? [] { popup.close() } window.close() } + window.displayIfNeeded() host.layoutSubtreeIfNeeded() try await openPanel(.edits, in: host, window: window) let popup = try #require(await waitForPopup(in: window, updating: host)) @@ -534,6 +535,8 @@ struct AgentActivityPresentationTests { } private func click(_ pointFromTop: NSPoint, in view: NSView, window: NSWindow, throughApplication: Bool = false) throws { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() let point = NSPoint(x: pointFromTop.x, y: view.isFlipped ? pointFromTop.y : view.bounds.height - pointFromTop.y) for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { let event = try #require(NSEvent.mouseEvent(with: type, location: view.convert(point, to: nil), modifierFlags: [], diff --git a/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift b/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift index b92be01e7e..4b1e089d4f 100644 --- a/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift +++ b/macos/Tests/LitheTests/AgentSessionSelectorLayoutTests.swift @@ -115,13 +115,16 @@ struct AgentSessionSelectorLayoutTests { showsToolbar: showsToolbar).environment(\.colorScheme, scheme).environment(\.locale, locale) } let host = NSHostingView(rootView: composer("first")) + host.sizingOptions = [] let window = makeWindow(host, size: NSSize(width: width, height: 180)) defer { window.contentView = nil; window.close() } try #require(await waitUntil(updating: host) { dropdownAnchors(in: host).count == 2 }) let trigger = try #require(dropdownAnchors(in: host).max { $0.convert($0.bounds, to: host).midX < $1.convert($1.bounds, to: host).midX }) - #expect(trigger.convert(trigger.bounds, to: host).midY < 90, + let frame = trigger.convert(trigger.bounds, to: host) + let distanceFromBottom = host.isFlipped ? host.bounds.maxY - frame.midY : frame.midY + #expect(distanceFromBottom < host.bounds.height / 2, "The single details entry stays beside Send with or without context usage") func openDetails() async throws -> NSPanel { try click(NSPoint(x: trigger.bounds.midX, y: trigger.bounds.midY), in: trigger, window: window) @@ -576,6 +579,7 @@ struct AgentSessionSelectorLayoutTests { onSelectAgent: { _ in }, onOpenSettings: {}, onError: { _ in }, configOptions: options, onSetConfig: { selections[$0] = $1 } ).environment(\.colorScheme, scheme)) + host.sizingOptions = [] let window = makeWindow(host, size: NSSize(width: width, height: 200)) defer { // Detaching the anchors dismisses their panels and removes event monitors. @@ -608,8 +612,12 @@ struct AgentSessionSelectorLayoutTests { let content = try #require(panel.contentView) let scroll = try #require(scrollView(in: content)) let document = try #require(scroll.documentView) - let rowHeight = document.bounds.height / 2 - try click(NSPoint(x: 100, y: rowHeight * 1.5), in: document, window: panel) + panel.displayIfNeeded() + let fromTop = category == "mode" + ? LitheDropdownMetrics.popupPadding + LitheDropdownMetrics.rowHeight * 2.5 + : LitheDropdownMetrics.rowHeight * 1.5 + let y = document.isFlipped ? document.bounds.minY + fromTop : document.bounds.maxY - fromTop + try click(NSPoint(x: document.bounds.midX, y: y), in: document, window: panel) #expect(await waitUntil(updating: host) { selections[category] == value }, "The open dropdown must preserve its selection callback") #expect(await waitUntil(updating: host) { popup(in: window) == nil }, "Choosing a value must dismiss the dropdown") } @@ -636,7 +644,10 @@ struct AgentSessionSelectorLayoutTests { try capture(content, name: "mode-menu-\(scheme)-\(count)") if count == 2 { #expect(panel.frame.height < 100, "A heading and two single-line options remain compact") - try click(NSPoint(x: 90, y: LitheDropdownMetrics.popupPadding + LitheDropdownMetrics.rowHeight * 1.5), in: document, window: panel) + panel.displayIfNeeded() + let fromTop = LitheDropdownMetrics.popupPadding + LitheDropdownMetrics.rowHeight * 1.5 + let y = document.isFlipped ? document.bounds.minY + fromTop : document.bounds.maxY - fromTop + try click(NSPoint(x: document.bounds.midX, y: y), in: document, window: panel) #expect(await waitUntil(updating: host) { selected == "choice-0" }) } else { for _ in 1..